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.
- habit_tracker_cli/__init__.py +5 -0
- habit_tracker_cli/cli.py +594 -0
- habit_tracker_cli/dates.py +124 -0
- habit_tracker_cli/db.py +50 -0
- habit_tracker_cli/maintenance.py +44 -0
- habit_tracker_cli/models.py +71 -0
- habit_tracker_cli/paths.py +88 -0
- habit_tracker_cli/render.py +86 -0
- habit_tracker_cli/repository.py +188 -0
- habit_tracker_cli/services.py +141 -0
- habit_tracker_cli-0.1.0.dist-info/METADATA +201 -0
- habit_tracker_cli-0.1.0.dist-info/RECORD +16 -0
- habit_tracker_cli-0.1.0.dist-info/WHEEL +5 -0
- habit_tracker_cli-0.1.0.dist-info/entry_points.txt +2 -0
- habit_tracker_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- habit_tracker_cli-0.1.0.dist-info/top_level.txt +1 -0
habit_tracker_cli/cli.py
ADDED
|
@@ -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()
|