lazyplan-tui 0.7.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.
lazyplan/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """lazyplan: Projects > UserStories > Tasks. TUI first, agent-injectable."""
2
+
3
+ from .models import Criterion, Link, Milestone, Project, Risk, RiskStatus, Status, Task, UserStory
4
+
5
+ __all__ = [
6
+ "Criterion",
7
+ "Link",
8
+ "Milestone",
9
+ "Project",
10
+ "Risk",
11
+ "RiskStatus",
12
+ "Status",
13
+ "Task",
14
+ "UserStory",
15
+ ]
lazyplan/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
lazyplan/cli.py ADDED
@@ -0,0 +1,530 @@
1
+ """`lp` companion CLI. Human + agent ingress: every mutation writes SQLite;
2
+ the running TUI auto-refreshes. Stdlib only (no Textual import here)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+
12
+ from .cli_parser import build_parser
13
+ from .design import (
14
+ EMPTY_FEED,
15
+ EMPTY_NO_PROJECTS,
16
+ HELP_TEXT,
17
+ msg_error,
18
+ msg_not_found,
19
+ )
20
+ from .gantt import bar, span
21
+ from .models import Project, Status, Task, UserStory
22
+ from .render import render_full, render_markdown, tree_lines
23
+ from .repo import ensure_plan_dir, find_repo_root, repo_label
24
+ from .seed import seed as seed_demo
25
+ from .skills import handle_init as _init_skills
26
+ from .store import Store, _ser
27
+
28
+
29
+ def _emit(args: argparse.Namespace, payload: object) -> int:
30
+ print(json.dumps(payload, indent=2, default=str))
31
+ return 0
32
+
33
+
34
+ def main(argv: list[str] | None = None) -> int:
35
+ """Dispatch one lp invocation; exit codes are agent-readable."""
36
+ args = build_parser().parse_args(argv)
37
+ # `watch` is `feed --follow`
38
+ if args.cmd == "watch":
39
+ args.cmd = "feed"
40
+ args.follow = True
41
+ args.interval = 1.0
42
+ args.json = getattr(args, "json", False)
43
+ store = Store(args.db)
44
+ actor = getattr(args, "actor", "human")
45
+ try:
46
+ if args.cmd == "init":
47
+ plan_dir = ensure_plan_dir(store.path)
48
+ root = find_repo_root(os.getcwd())
49
+ skills_info = _init_skills(args, root)
50
+ return _emit(
51
+ args,
52
+ {
53
+ "db": str(store.path),
54
+ "repo": str(root) if root else None,
55
+ "plan_dir": str(plan_dir),
56
+ "scope": repo_label(store.path),
57
+ "counts": store.counts(),
58
+ "skills": skills_info,
59
+ },
60
+ )
61
+ if args.cmd == "info":
62
+ root = find_repo_root(os.getcwd())
63
+ plan_dir = store.path.parent
64
+ try:
65
+ contents = sorted(f.name for f in plan_dir.iterdir()) if plan_dir.is_dir() else []
66
+ except OSError:
67
+ contents = []
68
+ return _emit(
69
+ args,
70
+ {
71
+ "db": str(store.path),
72
+ "repo": str(root) if root else None,
73
+ "scope": repo_label(store.path),
74
+ "plan_files": contents,
75
+ "counts": store.counts(),
76
+ },
77
+ )
78
+ if args.cmd == "seed":
79
+ seed_demo(store)
80
+ return _emit(args, {"db": str(store.path), "counts": store.counts()})
81
+ if args.cmd == "list":
82
+ status = Status(args.status) if args.status else None
83
+ if args.json:
84
+ payload = [
85
+ {
86
+ "project": _ser(vars(p)),
87
+ "stories": [
88
+ {
89
+ "story": _ser(vars(s)),
90
+ "links": [vars(li) for li in store.list_links("story", s.id)],
91
+ "criteria": [vars(c) for c in store.list_criteria("story", s.id)],
92
+ "tasks": [
93
+ {
94
+ "task": _ser(vars(t)),
95
+ "links": [
96
+ vars(li) for li in store.list_links("task", t.id)
97
+ ],
98
+ "criteria": [
99
+ vars(c) for c in store.list_criteria("task", t.id)
100
+ ],
101
+ }
102
+ for t in ts
103
+ ],
104
+ }
105
+ for s, ts in stories
106
+ ],
107
+ }
108
+ for p, stories in store.tree(
109
+ status=status,
110
+ project_id=args.project,
111
+ owner=args.owner,
112
+ priority=args.priority,
113
+ )
114
+ ]
115
+ return _emit(args, payload)
116
+ lines = tree_lines(store, status, args.project, args.owner, args.priority)
117
+ print("\n".join(lines) if lines else EMPTY_NO_PROJECTS)
118
+ return 0
119
+ if args.cmd == "gantt":
120
+ status = Status(args.status) if args.status else None
121
+ tree = store.tree(
122
+ status=status, project_id=args.project, owner=args.owner, priority=args.priority
123
+ )
124
+ ms_by_project: dict[int, list] = {}
125
+ dates = []
126
+ for _, stories in tree:
127
+ for s, tasks in stories:
128
+ dates += [s.start_date, s.end_date]
129
+ if args.tasks:
130
+ for t in tasks:
131
+ dates += [t.start_date, t.end_date]
132
+ if args.project is not None:
133
+ ms_by_project[args.project] = store.list_milestones(args.project)
134
+ else:
135
+ for p, _ in tree:
136
+ ms_by_project[p.id] = store.list_milestones(p.id)
137
+ for ms in ms_by_project.values():
138
+ dates += [m.date for m in ms if m.date]
139
+ dates = [d for d in dates if d]
140
+ lo, hi = span(dates, args.width)
141
+ print(f"{lo} .. {hi} (w={args.width})")
142
+ for p, stories in tree:
143
+ print(f"P#{p.id} {p.name}")
144
+ crit_ids = set(store.critical_path(p.id))
145
+ for m in ms_by_project.get(p.id, []):
146
+ print(f" ◆ {m.date or '????-??-??'} M#{m.id} {m.title}")
147
+ for s, tasks in stories:
148
+ mark = ("*" if s.id in crit_ids else " ") + (
149
+ "!" if store.is_blocked("story", s.id) else " "
150
+ )
151
+ print(
152
+ f"{mark} S#{s.id:>3} {bar(s.start_date, s.end_date, lo, hi, args.width)} {s.title} [{s.status.value}]"
153
+ )
154
+ if args.tasks:
155
+ for t in tasks:
156
+ tmark = " " + ("!" if store.is_blocked("task", t.id) else " ")
157
+ b = (
158
+ bar(t.start_date, t.end_date, lo, hi, args.width)
159
+ if (t.start_date and t.end_date)
160
+ else "(no dates)"
161
+ )
162
+ print(f"{tmark} T#{t.id:>3} {b} {t.title} [{t.status.value}]")
163
+ if not tree:
164
+ print(EMPTY_NO_PROJECTS)
165
+ return 0
166
+ if args.cmd == "help":
167
+ if args.json:
168
+ return _emit(args, {"help": HELP_TEXT})
169
+ print(HELP_TEXT)
170
+ return 0
171
+ if args.cmd == "add-project":
172
+ p = store.add_project(
173
+ args.name,
174
+ args.desc,
175
+ args.start,
176
+ args.end,
177
+ goal=args.goal,
178
+ success_metrics=args.metrics,
179
+ scope_in=args.scope_in,
180
+ scope_out=args.scope_out,
181
+ assumptions=args.assumptions,
182
+ actor=actor,
183
+ )
184
+ return _emit(args, {"project": _ser(vars(p))})
185
+ if args.cmd == "add-story":
186
+ s = store.add_story(
187
+ args.project,
188
+ args.title,
189
+ args.desc,
190
+ args.start,
191
+ args.end,
192
+ owner=args.owner,
193
+ priority=args.priority,
194
+ labels=args.labels,
195
+ estimate=args.estimate,
196
+ blocked_by=args.blocked_by,
197
+ actor=actor,
198
+ )
199
+ return _emit(args, {"story": _ser(vars(s))})
200
+ if args.cmd == "add-task":
201
+ t = store.add_task(
202
+ args.story,
203
+ args.title,
204
+ args.estimate,
205
+ args.start,
206
+ args.end,
207
+ description=args.desc,
208
+ owner=args.owner,
209
+ priority=args.priority,
210
+ labels=args.labels,
211
+ blocked_by=args.blocked_by,
212
+ actor=actor,
213
+ )
214
+ return _emit(args, {"task": _ser(vars(t))})
215
+ if args.cmd == "update":
216
+ title = args.title or args.name
217
+ priority = (
218
+ None
219
+ if args.priority is None
220
+ else ("" if args.priority == "none" else args.priority)
221
+ )
222
+ updated: Project | UserStory | Task | None = None
223
+ if args.kind == "project":
224
+ updated = store.update_project(
225
+ args.id,
226
+ name=title,
227
+ description=args.desc,
228
+ start_date=args.start,
229
+ end_date=args.end,
230
+ goal=args.goal,
231
+ success_metrics=args.metrics,
232
+ scope_in=args.scope_in,
233
+ scope_out=args.scope_out,
234
+ assumptions=args.assumptions,
235
+ actor=actor,
236
+ )
237
+ elif args.kind == "story":
238
+ updated = store.update_story(
239
+ args.id,
240
+ title=title,
241
+ description=args.desc,
242
+ start_date=args.start,
243
+ end_date=args.end,
244
+ owner=args.owner,
245
+ priority=priority,
246
+ labels=args.labels,
247
+ estimate=args.estimate,
248
+ blocked_by=args.blocked_by,
249
+ actor=actor,
250
+ )
251
+ else:
252
+ updated = store.update_task(
253
+ args.id,
254
+ title=title,
255
+ estimate=args.estimate,
256
+ start_date=args.start,
257
+ end_date=args.end,
258
+ description=args.desc,
259
+ owner=args.owner,
260
+ priority=priority,
261
+ labels=args.labels,
262
+ blocked_by=args.blocked_by,
263
+ actor=actor,
264
+ )
265
+ if updated is None:
266
+ print(msg_not_found(args.kind, args.id), file=sys.stderr)
267
+ return 1
268
+ return _emit(args, {"updated": _ser(vars(updated))})
269
+ if args.cmd == "show":
270
+ full = store.get_full(args.kind, args.id)
271
+ if full is None:
272
+ print(msg_not_found(args.kind, args.id), file=sys.stderr)
273
+ return 1
274
+ if args.json:
275
+ return _emit(args, full)
276
+ print(render_full(args.kind, full))
277
+ return 0
278
+ if args.cmd == "add-link":
279
+ link = store.add_link(args.kind, args.id, args.url, args.label, actor=actor)
280
+ return _emit(args, {"link": vars(link)})
281
+ if args.cmd == "links":
282
+ found = store.get_story(args.id) if args.kind == "story" else store.get_task(args.id)
283
+ if found is None:
284
+ print(msg_not_found(args.kind, args.id), file=sys.stderr)
285
+ return 1
286
+ return _emit(args, [vars(li) for li in store.list_links(args.kind, args.id)])
287
+ if args.cmd == "delete-link":
288
+ if not store.delete_link(args.id, actor=actor):
289
+ print(msg_not_found("link", args.id), file=sys.stderr)
290
+ return 1
291
+ return _emit(args, {"ok": True, "deleted": f"link #{args.id}"})
292
+ if args.cmd == "status":
293
+ if args.to == "done" and args.kind in ("story", "task"):
294
+ left = store.open_criteria(args.kind, args.id)
295
+ if left:
296
+ print(
297
+ f"warning: {len(left)} open acceptance criteria "
298
+ f"({', '.join(c.text[:40] for c in left[:3])}); closing anyway",
299
+ file=sys.stderr,
300
+ )
301
+ ok = store.set_status(args.kind, args.id, Status(args.to), actor=actor)
302
+ if not ok:
303
+ print(msg_not_found(args.kind, args.id), file=sys.stderr)
304
+ return 1
305
+ return _emit(args, {"ok": True, "kind": args.kind, "id": args.id, "to": args.to})
306
+ if args.cmd == "delete":
307
+ ok = store.delete(args.kind, args.id, actor=actor)
308
+ if not ok:
309
+ print(msg_not_found(args.kind, args.id), file=sys.stderr)
310
+ return 1
311
+ return _emit(args, {"ok": True, "deleted": f"{args.kind} #{args.id}"})
312
+ if args.cmd == "add-criteria":
313
+ c = store.add_criteria(args.kind, args.id, args.text, actor=actor)
314
+ return _emit(args, {"criteria": vars(c)})
315
+ if args.cmd == "criteria":
316
+ found = store.get_story(args.id) if args.kind == "story" else store.get_task(args.id)
317
+ if found is None:
318
+ print(msg_not_found(args.kind, args.id), file=sys.stderr)
319
+ return 1
320
+ return _emit(args, [vars(c) for c in store.list_criteria(args.kind, args.id)])
321
+ if args.cmd in ("check", "uncheck"):
322
+ crit = store.set_criteria(args.id, args.cmd == "check", actor=actor)
323
+ if crit is None:
324
+ print(msg_not_found("criteria", args.id), file=sys.stderr)
325
+ return 1
326
+ return _emit(args, {"criteria": vars(crit)})
327
+ if args.cmd == "delete-criteria":
328
+ if not store.delete_criteria(args.id, actor=actor):
329
+ print(msg_not_found("criteria", args.id), file=sys.stderr)
330
+ return 1
331
+ return _emit(args, {"ok": True, "deleted": f"criteria #{args.id}"})
332
+ if args.cmd == "add-risk":
333
+ from .models import RiskStatus as _RS
334
+
335
+ r = store.add_risk(
336
+ args.project, args.title, args.likelihood, args.impact, args.mitigation, actor=actor
337
+ )
338
+ d = vars(r)
339
+ d["status"] = r.status.value
340
+ return _emit(args, {"risk": d})
341
+ if args.cmd == "risks":
342
+ if store.get_project(args.project) is None:
343
+ print(msg_not_found("project", args.project), file=sys.stderr)
344
+ return 1
345
+ return _emit(args, [store._risk_to_dict(r) for r in store.list_risks(args.project)])
346
+ if args.cmd == "risk-status":
347
+ from .models import RiskStatus as _RS
348
+
349
+ to = _RS(args.to) if args.to else None
350
+ risk = store.set_risk(args.id, status=to, mitigation=args.mitigation, actor=actor)
351
+ if risk is None:
352
+ print(msg_not_found("risk", args.id), file=sys.stderr)
353
+ return 1
354
+ return _emit(args, {"risk": store._risk_to_dict(risk)})
355
+ if args.cmd == "add-milestone":
356
+ m = store.add_milestone(args.project, args.title, args.date, actor=actor)
357
+ return _emit(args, {"milestone": vars(m)})
358
+ if args.cmd == "milestones":
359
+ if store.get_project(args.project) is None:
360
+ print(msg_not_found("project", args.project), file=sys.stderr)
361
+ return 1
362
+ return _emit(args, [vars(m) for m in store.list_milestones(args.project)])
363
+ if args.cmd == "delete-milestone":
364
+ if not store.delete_milestone(args.id, actor=actor):
365
+ print(msg_not_found("milestone", args.id), file=sys.stderr)
366
+ return 1
367
+ return _emit(args, {"ok": True, "deleted": f"milestone #{args.id}"})
368
+ if args.cmd == "propose":
369
+ fields = {
370
+ "title": args.title,
371
+ "description": args.desc,
372
+ "owner": args.owner,
373
+ "priority": args.priority,
374
+ "labels": args.labels,
375
+ "estimate": args.estimate,
376
+ "start_date": args.start,
377
+ "end_date": args.end,
378
+ "blocked_by": args.blocked_by,
379
+ }
380
+ return _emit(args, store.propose(args.kind, args.parent, fields, actor=actor))
381
+ if args.cmd == "proposals":
382
+ return _emit(args, store.list_proposals(args.status))
383
+ if args.cmd in ("approve", "reject"):
384
+ try:
385
+ res = store.decide_proposal(args.id, args.cmd == "approve", actor=actor)
386
+ except ValueError as exc:
387
+ print(msg_error(exc), file=sys.stderr)
388
+ return 2
389
+ if res is None:
390
+ print(msg_not_found("proposal", args.id), file=sys.stderr)
391
+ return 1
392
+ return _emit(args, res)
393
+ if args.cmd == "ask":
394
+ return _emit(args, store.ask(actor, args.question))
395
+ if args.cmd == "questions":
396
+ return _emit(args, store.list_questions(open_only=not args.all))
397
+ if args.cmd == "answer":
398
+ res = store.answer_question(args.id, args.text, actor=actor)
399
+ if res is None:
400
+ print(msg_not_found("question", args.id), file=sys.stderr)
401
+ return 1
402
+ return _emit(args, res)
403
+ if args.cmd == "export":
404
+ data = store.export_all()
405
+ if args.format == "json":
406
+ return _emit(args, data)
407
+ print(render_markdown(data))
408
+ return 0
409
+ if args.cmd == "import":
410
+ import json as _json
411
+
412
+ with open(args.file) as fh:
413
+ data = _json.load(fh)
414
+ try:
415
+ counts = store.import_all(data, actor=actor)
416
+ except ValueError as exc:
417
+ print(msg_error(exc), file=sys.stderr)
418
+ return 2
419
+ return _emit(args, {"imported": counts})
420
+ if args.cmd == "feed":
421
+ seen = -1
422
+ while True:
423
+ events = store.feed(args.limit)
424
+ if args.json or not args.follow:
425
+ if args.follow and args.json:
426
+ fresh = [e for e in events if e["id"] > seen]
427
+ for e in reversed(fresh):
428
+ print(json.dumps(e, default=str))
429
+ if fresh:
430
+ seen = max(e["id"] for e in fresh)
431
+ else:
432
+ if args.json:
433
+ print(json.dumps(events, indent=2, default=str))
434
+ else:
435
+ if not events:
436
+ print(EMPTY_FEED)
437
+ for e in reversed(events):
438
+ print(
439
+ f"#{e['id']} [{e['actor']}] {e['kind']}/{e['action']}: {e['detail']}"
440
+ )
441
+ if not args.follow:
442
+ return 0
443
+ time.sleep(args.interval)
444
+ continue
445
+ # plain follow: only print new rows
446
+ fresh = [e for e in events if e["id"] > seen]
447
+ for e in reversed(fresh):
448
+ print(
449
+ f"#{e['id']} [{e['actor']}] {e['kind']}/{e['action']}: {e['detail']}",
450
+ flush=True,
451
+ )
452
+ if fresh:
453
+ seen = max(e["id"] for e in fresh)
454
+ time.sleep(args.interval)
455
+ if args.cmd == "sync":
456
+ import os as _os
457
+
458
+ dsn = args.dsn.strip() or _os.environ.get("LAZYPLAN_PG_DSN", "").strip()
459
+ if not dsn:
460
+ print("need --dsn or $LAZYPLAN_PG_DSN", file=sys.stderr)
461
+ return 2
462
+ from .postgres_sync import sync_sqlite_to_pg
463
+
464
+ try:
465
+ counts = sync_sqlite_to_pg(str(store.path), dsn)
466
+ except RuntimeError as exc:
467
+ print(msg_error(exc), file=sys.stderr)
468
+ return 2
469
+ return _emit(args, {"synced_to": dsn.split("@")[-1], "counts": counts})
470
+ if args.cmd == "completion":
471
+ cmds = [
472
+ "init",
473
+ "seed",
474
+ "list",
475
+ "gantt",
476
+ "add-project",
477
+ "add-story",
478
+ "add-task",
479
+ "update",
480
+ "show",
481
+ "add-link",
482
+ "links",
483
+ "delete-link",
484
+ "add-criteria",
485
+ "criteria",
486
+ "check",
487
+ "uncheck",
488
+ "delete-criteria",
489
+ "add-risk",
490
+ "risks",
491
+ "risk-status",
492
+ "add-milestone",
493
+ "milestones",
494
+ "delete-milestone",
495
+ "propose",
496
+ "proposals",
497
+ "approve",
498
+ "reject",
499
+ "ask",
500
+ "questions",
501
+ "answer",
502
+ "export",
503
+ "import",
504
+ "sync",
505
+ "status",
506
+ "delete",
507
+ "feed",
508
+ "watch",
509
+ "info",
510
+ "help",
511
+ "ui",
512
+ "completion",
513
+ ]
514
+ if args.shell == "bash":
515
+ print(f'complete -W "{" ".join(cmds)}" lp')
516
+ else:
517
+ print(f"#compdef lp\n_arguments '1:command:({' '.join(cmds)})'")
518
+ return 0
519
+ if args.cmd == "ui":
520
+ from .tui import run
521
+
522
+ return run(db_path=args.db)
523
+ except (ValueError, KeyError) as exc:
524
+ print(msg_error(exc), file=sys.stderr)
525
+ return 2
526
+ raise AssertionError(args.cmd)
527
+
528
+
529
+ if __name__ == "__main__":
530
+ raise SystemExit(main())