habit-tracker-cli 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.
@@ -0,0 +1,5 @@
1
+ """habit_tracker_cli package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,594 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shlex
5
+ import sys
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass
8
+ from io import StringIO
9
+ from typing import Callable
10
+
11
+ import click
12
+ import typer
13
+ from rich.console import Console, Group, RenderableType
14
+ from rich.panel import Panel
15
+ from rich.text import Text
16
+
17
+ from habit_tracker_cli.db import get_connection
18
+ from habit_tracker_cli.maintenance import clear_data_paths, describe_data_paths
19
+ from habit_tracker_cli.paths import AppPaths, ensure_parent_directories, resolve_app_paths
20
+ from habit_tracker_cli.render import (
21
+ render_habit_list,
22
+ render_path_statuses,
23
+ render_streak,
24
+ render_today,
25
+ render_weekly_report,
26
+ schedule_label,
27
+ )
28
+ from habit_tracker_cli.repository import HabitRepository
29
+ from habit_tracker_cli.services import HabitNotFoundError, HabitService, HabitValidationError
30
+
31
+ try:
32
+ from prompt_toolkit import PromptSession
33
+ from prompt_toolkit.completion import Completer, Completion
34
+ from prompt_toolkit.history import FileHistory
35
+
36
+ PROMPT_TOOLKIT_AVAILABLE = True
37
+ except ImportError: # pragma: no cover - dependency is installed in normal use.
38
+ PromptSession = None # type: ignore[assignment]
39
+ FileHistory = None # type: ignore[assignment]
40
+ PROMPT_TOOLKIT_AVAILABLE = False
41
+
42
+ class Completer: # type: ignore[no-redef]
43
+ pass
44
+
45
+ class Completion: # type: ignore[no-redef]
46
+ pass
47
+
48
+ app = typer.Typer(help="Track habits from the terminal.")
49
+ console = Console()
50
+ error_console = Console(stderr=True)
51
+ SHELL_EXIT_COMMANDS = {"exit", "q"}
52
+ SHELL_TITLE = "habit_tracker_cli"
53
+ SHELL_COMMAND_NAMES = (
54
+ "add",
55
+ "list",
56
+ "today",
57
+ "done",
58
+ "streak",
59
+ "report",
60
+ "help",
61
+ "man",
62
+ "clear",
63
+ "clear-data",
64
+ "exit",
65
+ "q",
66
+ )
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class CommandDoc:
71
+ short: str
72
+ usage: tuple[str, ...]
73
+ description: str
74
+ options: tuple[str, ...] = ()
75
+ examples: tuple[str, ...] = ()
76
+ notes: tuple[str, ...] = ()
77
+
78
+
79
+ COMMAND_DOCS: dict[str, CommandDoc] = {
80
+ "add": CommandDoc(
81
+ short="Create a habit with a daily schedule or specific weekdays.",
82
+ usage=("add NAME --daily", "add NAME --days mon,wed,fri"),
83
+ description=(
84
+ "Adds a new habit to the database. Habit names are trim-safe and matched case-insensitively."
85
+ ),
86
+ options=(
87
+ "--daily Create a daily habit.",
88
+ "--days Comma-separated weekdays using mon,tue,wed,thu,fri,sat,sun.",
89
+ ),
90
+ examples=('add "Read 20 min" --daily', 'add "Go to the gym" --days mon,wed,fri'),
91
+ notes=(
92
+ "--daily and --days are mutually exclusive.",
93
+ "Duplicate names are rejected using a normalized name.",
94
+ ),
95
+ ),
96
+ "list": CommandDoc(
97
+ short="Show all active habits and their schedules.",
98
+ usage=("list",),
99
+ description="Displays all stored habits in a table with their schedule and creation date.",
100
+ examples=("list",),
101
+ ),
102
+ "today": CommandDoc(
103
+ short="Show habits scheduled for today and their current status.",
104
+ usage=("today",),
105
+ description="Lists only the habits due on the current local date and marks each as done or pending.",
106
+ examples=("today",),
107
+ ),
108
+ "done": CommandDoc(
109
+ short="Mark a habit as completed for today.",
110
+ usage=("done NAME",),
111
+ description="Marks the named habit as completed for the current local date.",
112
+ examples=('done "Read 20 min"',),
113
+ notes=(
114
+ "The operation is idempotent.",
115
+ "Names are matched case-insensitively and ignore surrounding spaces.",
116
+ ),
117
+ ),
118
+ "streak": CommandDoc(
119
+ short="Show the current streak for a habit.",
120
+ usage=("streak NAME",),
121
+ description="Calculates the current streak up to the most recent scheduled date that is not in the future.",
122
+ examples=('streak "Read 20 min"',),
123
+ ),
124
+ "report": CommandDoc(
125
+ short="Show the weekly report for the current week.",
126
+ usage=("report", "report --week"),
127
+ description="Displays a Monday-to-Sunday summary of scheduled, completed, and missed habit occurrences.",
128
+ options=("--week Explicitly request the weekly report.",),
129
+ examples=("report", "report --week"),
130
+ notes=("In v1, report and report --week are equivalent.",),
131
+ ),
132
+ "clear": CommandDoc(
133
+ short="Clear the current result panel.",
134
+ usage=("clear",),
135
+ description="Removes the last shell output while keeping the shell header visible.",
136
+ examples=("clear",),
137
+ ),
138
+ "clear-data": CommandDoc(
139
+ short="Delete the local database, history, and app state without uninstalling the app.",
140
+ usage=("clear-data", "clear-data --yes"),
141
+ description=(
142
+ "Deletes the SQLite database, shell history, and app-managed data/state/config/cache paths."
143
+ ),
144
+ options=("--yes Skip the confirmation prompt.",),
145
+ examples=("clear-data", "clear-data --yes"),
146
+ notes=(
147
+ "Missing files are ignored safely.",
148
+ "If you run this inside an active shell, history/state files may be recreated until the session exits.",
149
+ ),
150
+ ),
151
+ "help": CommandDoc(
152
+ short="Show general shell help or a short help entry for one command.",
153
+ usage=("help", "help COMMAND"),
154
+ description="Shows the list of available commands or a short command-specific summary inside the shell view.",
155
+ examples=("help", "help add"),
156
+ ),
157
+ "man": CommandDoc(
158
+ short="Show the manual index or a full manual page for one command.",
159
+ usage=("man", "man COMMAND"),
160
+ description="Displays a command index or a richer manual page with usage, examples, and notes.",
161
+ examples=("man", "man add"),
162
+ ),
163
+ "exit": CommandDoc(
164
+ short="Exit the interactive shell.",
165
+ usage=("exit",),
166
+ description="Closes the shell and returns control to the current terminal session.",
167
+ examples=("exit",),
168
+ ),
169
+ "q": CommandDoc(
170
+ short="Exit the interactive shell.",
171
+ usage=("q",),
172
+ description="Short alias for exiting the shell.",
173
+ examples=("q",),
174
+ ),
175
+ }
176
+
177
+
178
+ def build_service() -> HabitService:
179
+ connection = get_connection()
180
+ repository = HabitRepository(connection)
181
+ return HabitService(repository)
182
+
183
+
184
+ def abort_with_error(message: str) -> None:
185
+ error_console.print(f"[bold red]Error:[/bold red] {message}")
186
+ raise typer.Exit(code=1)
187
+
188
+
189
+ def shell_help_text() -> RenderableType:
190
+ body = Text()
191
+ body.append("Available commands:\n", style="bold")
192
+ for name in ("add", "list", "today", "done", "streak", "report", "clear-data"):
193
+ body.append(f" {name:<12}", style="cyan")
194
+ body.append(f"{COMMAND_DOCS[name].short}\n")
195
+ body.append("\nSpecial commands:\n", style="bold")
196
+ for name in ("help", "man", "clear", "exit", "q"):
197
+ body.append(f" {name:<12}", style="magenta")
198
+ body.append(f"{COMMAND_DOCS[name].short}\n")
199
+ return body
200
+
201
+
202
+ def render_command_help(command_name: str) -> RenderableType:
203
+ command = command_name.lower()
204
+ doc = COMMAND_DOCS.get(command)
205
+ if doc is None:
206
+ return f"Error: Unknown command '{command_name}'. Use `help` to see the available commands."
207
+
208
+ body = Text()
209
+ body.append(f"{command}\n", style="bold cyan")
210
+ body.append(f"{doc.short}\n\n")
211
+ body.append("Usage:\n", style="bold")
212
+ for usage in doc.usage:
213
+ body.append(f" {usage}\n")
214
+ return body
215
+
216
+
217
+ def render_manual_index() -> RenderableType:
218
+ body = Text()
219
+ body.append("Manual index:\n", style="bold")
220
+ for name in ("add", "list", "today", "done", "streak", "report", "clear-data", "help", "man", "clear", "exit", "q"):
221
+ body.append(f" {name:<12}", style="cyan")
222
+ body.append(f"{COMMAND_DOCS[name].short}\n")
223
+ return body
224
+
225
+
226
+ def render_manual_page(command_name: str) -> RenderableType:
227
+ command = command_name.lower()
228
+ doc = COMMAND_DOCS.get(command)
229
+ if doc is None:
230
+ return f"Error: Unknown command '{command_name}'. Use `man` to see the command index."
231
+
232
+ body = Text()
233
+ body.append("NAME\n", style="bold")
234
+ body.append(f" {command} - {doc.short}\n\n")
235
+ body.append("USAGE\n", style="bold")
236
+ for usage in doc.usage:
237
+ body.append(f" {usage}\n")
238
+ body.append("\nDESCRIPTION\n", style="bold")
239
+ body.append(f" {doc.description}\n")
240
+
241
+ if doc.options:
242
+ body.append("\nOPTIONS\n", style="bold")
243
+ for option in doc.options:
244
+ body.append(f" {option}\n")
245
+
246
+ if doc.examples:
247
+ body.append("\nEXAMPLES\n", style="bold")
248
+ for example in doc.examples:
249
+ body.append(f" {example}\n")
250
+
251
+ if doc.notes:
252
+ body.append("\nNOTES\n", style="bold")
253
+ for note in doc.notes:
254
+ body.append(f" {note}\n")
255
+
256
+ return body
257
+
258
+
259
+ def render_shell_header(status: str | None = None) -> Panel:
260
+ body = Text()
261
+ body.append(f"{SHELL_TITLE}\n", style="bold cyan")
262
+ body.append("Commands: add, list, today, done, streak, report, clear-data\n")
263
+ body.append("Special: help, man, clear, exit, q")
264
+ if status:
265
+ body.append(f"\nStatus: {status}", style="green")
266
+ return Panel(body, title="Interactive Shell", border_style="cyan")
267
+
268
+
269
+ def render_shell_view(last_output: RenderableType | None, status: str | None = None) -> Group:
270
+ has_output = last_output is not None and (not isinstance(last_output, str) or bool(last_output.strip()))
271
+ if has_output and last_output is not None:
272
+ output_panel = Panel(last_output, title="Result", border_style="green")
273
+ else:
274
+ output_panel = Panel("No output yet. Type `help`, `man`, or run a command.", title="Result", border_style="dim")
275
+ return Group(render_shell_header(status=status), output_panel)
276
+
277
+
278
+ @contextmanager
279
+ def command_capture() -> tuple[StringIO, StringIO]:
280
+ global console, error_console
281
+
282
+ stdout_buffer = StringIO()
283
+ stderr_buffer = StringIO()
284
+ original_console = console
285
+ original_error_console = error_console
286
+ console = Console(file=stdout_buffer, force_terminal=False, color_system=None)
287
+ error_console = Console(file=stderr_buffer, force_terminal=False, color_system=None)
288
+ try:
289
+ yield stdout_buffer, stderr_buffer
290
+ finally:
291
+ console = original_console
292
+ error_console = original_error_console
293
+
294
+
295
+ def dispatch_command(tokens: list[str]) -> str:
296
+ try:
297
+ with command_capture() as (stdout_buffer, stderr_buffer):
298
+ app(args=tokens, prog_name="habit-tracker", standalone_mode=False)
299
+ except click.ClickException as exc:
300
+ return exc.format_message()
301
+ except click.exceptions.Exit:
302
+ return ""
303
+
304
+ stdout_text = stdout_buffer.getvalue().strip()
305
+ stderr_text = stderr_buffer.getvalue().strip()
306
+ parts = [part for part in (stdout_text, stderr_text) if part]
307
+ return "\n".join(parts)
308
+
309
+
310
+ class HabitShellCompleter(Completer):
311
+ def __init__(self, service_factory: Callable[[], HabitService]) -> None:
312
+ self.service_factory = service_factory
313
+
314
+ def get_completions(self, document, complete_event): # type: ignore[override]
315
+ text = document.text_before_cursor
316
+ current_word = document.get_word_before_cursor(WORD=True)
317
+ try:
318
+ tokens = shlex.split(text)
319
+ new_token = not text or text[-1].isspace()
320
+ except ValueError:
321
+ tokens = text.split()
322
+ new_token = not text or text[-1].isspace()
323
+
324
+ if not tokens:
325
+ yield from self._complete_command(current_word)
326
+ return
327
+
328
+ if len(tokens) == 1 and not new_token:
329
+ yield from self._complete_command(current_word)
330
+ return
331
+
332
+ command = tokens[0].lower()
333
+ prefix = "" if new_token else current_word
334
+ if command in {"done", "streak"}:
335
+ yield from self._complete_habit_names(prefix)
336
+ return
337
+ if command in {"help", "man"}:
338
+ yield from self._complete_command(prefix)
339
+ return
340
+
341
+ def _complete_command(self, prefix: str):
342
+ for name in SHELL_COMMAND_NAMES:
343
+ if name.startswith(prefix):
344
+ yield Completion(name, start_position=-len(prefix))
345
+
346
+ def _complete_habit_names(self, prefix: str):
347
+ service = self.service_factory()
348
+ try:
349
+ for name in service.list_habit_names():
350
+ candidate = _quote_completion(name)
351
+ if name.lower().startswith(prefix.lower()):
352
+ yield Completion(candidate, start_position=-len(prefix))
353
+ finally:
354
+ service.close()
355
+
356
+
357
+ def _quote_completion(value: str) -> str:
358
+ if " " in value:
359
+ escaped = value.replace('"', '\\"')
360
+ return f'"{escaped}"'
361
+ return value
362
+
363
+
364
+ def _save_shell_state(app_paths: AppPaths, status: str, last_command: str | None) -> None:
365
+ ensure_parent_directories(app_paths)
366
+ payload = {"status": status, "last_command": last_command}
367
+ app_paths.state_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
368
+
369
+
370
+ def _build_prompt_session(app_paths: AppPaths):
371
+ if not PROMPT_TOOLKIT_AVAILABLE or not sys.stdin.isatty() or not sys.stdout.isatty():
372
+ return None
373
+ ensure_parent_directories(app_paths)
374
+ return PromptSession(
375
+ history=FileHistory(str(app_paths.history_path)),
376
+ completer=HabitShellCompleter(build_service),
377
+ complete_while_typing=False,
378
+ )
379
+
380
+
381
+ def _prompt_input(prompt_text: str, session=None) -> str:
382
+ if session is None:
383
+ return input(prompt_text)
384
+ return session.prompt(prompt_text)
385
+
386
+
387
+ def _confirm_action(prompt_text: str, *, session=None) -> bool:
388
+ if session is None:
389
+ return typer.confirm(prompt_text, default=False)
390
+ response = session.prompt(f"{prompt_text} [y/N]: ")
391
+ return response.strip().lower() in {"y", "yes"}
392
+
393
+
394
+ def _show_preview(renderable: RenderableType, *, session=None) -> None:
395
+ if session is None:
396
+ console.print(renderable)
397
+ return
398
+ console.clear()
399
+ console.print(render_shell_view(last_output=renderable, status="Pending confirmation"))
400
+
401
+
402
+ def _run_clear_data_flow(*, assume_yes: bool, session=None) -> RenderableType:
403
+ statuses = describe_data_paths()
404
+ preview = render_path_statuses("App Data Paths", statuses)
405
+ if not assume_yes:
406
+ _show_preview(preview, session=session)
407
+ confirmed = assume_yes or _confirm_action("Delete all habit_tracker_cli data?", session=session)
408
+ summary_title = "Clear Data"
409
+ if confirmed:
410
+ deleted_paths = clear_data_paths()
411
+ message = (
412
+ f"Deleted {len(deleted_paths)} path(s)." if deleted_paths else "No app data was found to delete."
413
+ )
414
+ else:
415
+ message = "Clear-data cancelled."
416
+ return Group(preview, Panel(message, title=summary_title))
417
+
418
+
419
+ @app.command()
420
+ def add(
421
+ name: str = typer.Argument(..., metavar="NAME", help="Habit name. Wrap in quotes when it contains spaces."),
422
+ daily: bool = typer.Option(False, "--daily", help="Create a daily habit."),
423
+ days: str | None = typer.Option(None, "--days", help="Comma-separated weekdays such as mon,wed,fri."),
424
+ ) -> None:
425
+ service = build_service()
426
+ try:
427
+ habit = service.add_habit(name, daily=daily, days_csv=days)
428
+ except HabitValidationError as exc:
429
+ abort_with_error(str(exc))
430
+ finally:
431
+ service.close()
432
+
433
+ console.print(f"Added habit '[cyan]{habit.name}[/cyan]' with schedule [magenta]{schedule_label(habit)}[/magenta].")
434
+
435
+
436
+ @app.command(name="list")
437
+ def list_habits() -> None:
438
+ service = build_service()
439
+ try:
440
+ habits = service.list_habits()
441
+ finally:
442
+ service.close()
443
+
444
+ if not habits:
445
+ console.print("No habits found. Add one with `habit-tracker add`.")
446
+ return
447
+
448
+ console.print(render_habit_list(habits))
449
+
450
+
451
+ @app.command()
452
+ def today() -> None:
453
+ service = build_service()
454
+ try:
455
+ habits = service.get_today_habits()
456
+ finally:
457
+ service.close()
458
+
459
+ if not habits:
460
+ console.print("No habits are scheduled for today.")
461
+ return
462
+
463
+ console.print(render_today(habits))
464
+
465
+
466
+ @app.command()
467
+ def done(name: str = typer.Argument(..., metavar="NAME", help="Habit name to mark as completed today.")) -> None:
468
+ service = build_service()
469
+ try:
470
+ habit, inserted = service.mark_done(name)
471
+ except HabitValidationError as exc:
472
+ abort_with_error(str(exc))
473
+ except HabitNotFoundError as exc:
474
+ abort_with_error(str(exc))
475
+ finally:
476
+ service.close()
477
+
478
+ if inserted:
479
+ console.print(f"Marked '[cyan]{habit.name}[/cyan]' as done for today.")
480
+ else:
481
+ console.print(f"'{habit.name}' was already marked as done for today.")
482
+
483
+
484
+ @app.command()
485
+ def streak(name: str = typer.Argument(..., metavar="NAME", help="Habit name to inspect.")) -> None:
486
+ service = build_service()
487
+ try:
488
+ habit, current_streak = service.get_streak(name)
489
+ except HabitValidationError as exc:
490
+ abort_with_error(str(exc))
491
+ except HabitNotFoundError as exc:
492
+ abort_with_error(str(exc))
493
+ finally:
494
+ service.close()
495
+
496
+ console.print(render_streak(habit, current_streak))
497
+
498
+
499
+ @app.command()
500
+ def report(
501
+ week: bool = typer.Option(False, "--week", help="Show the current weekly report."),
502
+ ) -> None:
503
+ _ = week
504
+ service = build_service()
505
+ try:
506
+ weekly_report = service.get_weekly_report()
507
+ finally:
508
+ service.close()
509
+
510
+ if not weekly_report.rows:
511
+ console.print("No habits found. Add one with `habit-tracker add`.")
512
+ return
513
+
514
+ console.print(render_weekly_report(weekly_report))
515
+
516
+
517
+ @app.command(name="clear-data")
518
+ def clear_data(
519
+ yes: bool = typer.Option(False, "--yes", help="Delete data without asking for confirmation."),
520
+ ) -> None:
521
+ console.print(_run_clear_data_flow(assume_yes=yes))
522
+
523
+
524
+ def _handle_shell_command(tokens: list[str], session=None) -> tuple[RenderableType | None, bool]:
525
+ command = tokens[0].lower()
526
+
527
+ if command in SHELL_EXIT_COMMANDS:
528
+ return None, True
529
+ if command == "help":
530
+ return (shell_help_text() if len(tokens) == 1 else render_command_help(tokens[1])), False
531
+ if command == "man":
532
+ return (render_manual_index() if len(tokens) == 1 else render_manual_page(tokens[1])), False
533
+ if command == "clear":
534
+ return "", False
535
+ if command == "clear-data":
536
+ return _run_clear_data_flow(assume_yes="--yes" in tokens, session=session), False
537
+
538
+ return dispatch_command(tokens), False
539
+
540
+
541
+ @app.command()
542
+ def shell() -> None:
543
+ run_shell()
544
+
545
+
546
+ def run_shell() -> None:
547
+ app_paths = resolve_app_paths()
548
+ ensure_parent_directories(app_paths)
549
+ session = _build_prompt_session(app_paths)
550
+ last_output: RenderableType | None = shell_help_text()
551
+ status = "Ready"
552
+ last_command: str | None = None
553
+ _save_shell_state(app_paths, status=status, last_command=last_command)
554
+
555
+ while True:
556
+ console.clear()
557
+ console.print(render_shell_view(last_output=last_output, status=status))
558
+ try:
559
+ raw_line = _prompt_input("habit> ", session=session)
560
+ except (EOFError, KeyboardInterrupt):
561
+ console.print()
562
+ break
563
+
564
+ line = raw_line.strip()
565
+ if not line:
566
+ status = "Waiting for command"
567
+ _save_shell_state(app_paths, status=status, last_command=last_command)
568
+ continue
569
+
570
+ try:
571
+ tokens = shlex.split(line)
572
+ except ValueError as exc:
573
+ last_output = f"Error: {exc}"
574
+ status = "Parse error"
575
+ _save_shell_state(app_paths, status=status, last_command=last_command)
576
+ continue
577
+
578
+ last_command = line
579
+ last_output, should_exit = _handle_shell_command(tokens, session=session)
580
+ status = f"Last command: {line}"
581
+ _save_shell_state(app_paths, status=status, last_command=last_command)
582
+ if should_exit:
583
+ break
584
+
585
+
586
+ def main() -> None:
587
+ if len(sys.argv) <= 1:
588
+ run_shell()
589
+ return
590
+ app()
591
+
592
+
593
+ if __name__ == "__main__":
594
+ main()