studylife-cli 1.3.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.
File without changes
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.3.0'
22
+ __version_tuple__ = version_tuple = (1, 3, 0)
23
+
24
+ __commit_id__ = commit_id = None
studylife_cli/cli.py ADDED
@@ -0,0 +1,546 @@
1
+ """`studylife` - command-line client for a self-hosted StudyLife instance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as json_module
6
+ import sys
7
+ from dataclasses import dataclass
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from studylife_cli.client import ApiError, StudyLifeClient
14
+ from studylife_cli.credentials import Credentials, clear_credentials, load_credentials
15
+ from studylife_cli.login import DEFAULT_CLIENT_ID, LoginError, login_and_save
16
+ from studylife_cli.models import CourseGoal, Note, Session
17
+
18
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
19
+ notes_app = typer.Typer(no_args_is_help=True, help="Manage notes.")
20
+ sessions_app = typer.Typer(no_args_is_help=True, help="Manage study sessions.")
21
+ goals_app = typer.Typer(no_args_is_help=True, help="Manage course goals.")
22
+ courses_app = typer.Typer(no_args_is_help=True, help="Browse courses.")
23
+ programs_app = typer.Typer(no_args_is_help=True, help="Browse study programs.")
24
+ webhooks_app = typer.Typer(no_args_is_help=True, help="Manage webhooks.")
25
+ app.add_typer(notes_app, name="notes")
26
+ app.add_typer(sessions_app, name="sessions")
27
+ app.add_typer(goals_app, name="goals")
28
+ app.add_typer(courses_app, name="courses")
29
+ app.add_typer(programs_app, name="programs")
30
+ app.add_typer(webhooks_app, name="webhooks")
31
+
32
+ # StudyLife content (course icons, note text) is arbitrary Unicode, but Python's stdout/stderr
33
+ # on Windows default to the console's legacy OEM/ANSI codepage (e.g. cp1252) unless overridden -
34
+ # printing anything outside that codepage then crashes the whole command with a
35
+ # UnicodeEncodeError instead of just rendering oddly. errors="replace" trades a perfect glyph
36
+ # for "never crashes on real StudyLife content" on whatever terminal this happens to run in.
37
+ if sys.platform == "win32":
38
+ for _stream in (sys.stdout, sys.stderr):
39
+ if hasattr(_stream, "reconfigure"):
40
+ _stream.reconfigure(encoding="utf-8", errors="replace")
41
+
42
+ # Also disables rich's separate "legacy Windows console" writer (a different code path, used on
43
+ # terminals it can't detect ANSI/VT support for), which encodes through the same codepage
44
+ # independently of sys.stdout and would otherwise still crash even with the reconfigure above.
45
+ console = Console(legacy_windows=False)
46
+ error_console = Console(stderr=True, legacy_windows=False)
47
+
48
+
49
+ @dataclass
50
+ class CliState:
51
+ as_json: bool
52
+
53
+
54
+ # Click/Typer options declared on the root app's callback only parse when given BEFORE the
55
+ # subcommand (`studylife --json notes list`), not after (`studylife notes list --json`) - each
56
+ # subcommand has its own parser that doesn't know about the parent's options. Re-declaring the
57
+ # same flag on every leaf command and OR-ing it with the root-level one (see _use_json below)
58
+ # makes both orders work, matching how most people instinctively type it.
59
+ JSON_OPTION = typer.Option(False, "--json", help="Print machine-readable JSON instead of a table.")
60
+
61
+
62
+ def _use_json(ctx: typer.Context, local: bool) -> bool:
63
+ state: CliState = ctx.obj
64
+ return state.as_json or local
65
+
66
+
67
+ # Table-view field whitelists, one per resource type shown by a list/search/history command -
68
+ # the full field set (used unconditionally by --json) is often too wide for a terminal (a note's
69
+ # content, a session's course_color, a course's full topic list). Deliberately not applied to
70
+ # create/edit/delete confirmations (_confirm) or to single-item "get" views - those already show
71
+ # one thing at a time and benefit from seeing everything.
72
+ NOTE_TABLE_COLUMNS = ["id", "title", "course_id", "updated_at"]
73
+ SESSION_TABLE_COLUMNS = ["id", "course_name", "start_time", "end_time", "topic", "is_completed"]
74
+ COURSE_GOAL_TABLE_COLUMNS = ["course_id", "course_name", "target_date", "grade", "tag"]
75
+ COURSE_TABLE_COLUMNS = ["id", "name", "code", "semester", "ects"]
76
+
77
+
78
+ def _print_version_and_exit(value: bool) -> None:
79
+ if not value:
80
+ return
81
+ from importlib.metadata import PackageNotFoundError, version
82
+
83
+ try:
84
+ console.print(version("studylife-cli"))
85
+ except PackageNotFoundError:
86
+ # Editable/uninstalled checkout (e.g. `uv run` before a build has ever run) - hatch-vcs
87
+ # only writes package metadata as part of a real build/install, not on a bare source tree.
88
+ console.print("unknown (not installed from a built package)")
89
+ raise typer.Exit()
90
+
91
+
92
+ @app.callback()
93
+ def main(
94
+ ctx: typer.Context,
95
+ as_json: bool = typer.Option(
96
+ False, "--json", help="Print machine-readable JSON instead of a table."
97
+ ),
98
+ version: bool = typer.Option(
99
+ False,
100
+ "--version",
101
+ callback=_print_version_and_exit,
102
+ is_eager=True,
103
+ help="Show the installed version and exit.",
104
+ ),
105
+ ) -> None:
106
+ ctx.obj = CliState(as_json=as_json)
107
+
108
+
109
+ def _require_client(ctx: typer.Context) -> StudyLifeClient:
110
+ credentials = load_credentials()
111
+ if credentials is None:
112
+ error_console.print(
113
+ "Not logged in - run [bold]studylife login <instance-url>[/bold] first."
114
+ )
115
+ raise typer.Exit(1)
116
+ return StudyLifeClient(credentials.instance_url, credentials.api_key)
117
+
118
+
119
+ def _print(
120
+ as_json: bool, rows: list[dict[str, object]], title: str, columns: list[str] | None = None
121
+ ) -> None:
122
+ """columns restricts which fields the TABLE view shows (a resource can have far more fields
123
+ than fit a terminal - e.g. a note's full content, or a session's course_color) - --json always
124
+ returns every field regardless, since a table's readability limit doesn't apply there."""
125
+ if as_json:
126
+ print(json_module.dumps(rows, indent=2, default=str))
127
+ return
128
+ if not rows:
129
+ console.print(f"No {title.lower()}.")
130
+ return
131
+ display_rows = [{k: row.get(k) for k in columns} for row in rows] if columns else rows
132
+ table = Table(title=title)
133
+ for key in display_rows[0]:
134
+ table.add_column(key)
135
+ for row in display_rows:
136
+ table.add_row(*(str(value) if value is not None else "" for value in row.values()))
137
+ console.print(table)
138
+
139
+
140
+ def _confirm(as_json: bool, payload: dict[str, object], message: str) -> None:
141
+ """Reports the outcome of a create/edit/delete command - the full affected resource (or, for
142
+ a delete, just its id) as JSON with --json, otherwise the same short human message every
143
+ mutation command already printed."""
144
+ if as_json:
145
+ print(json_module.dumps(payload, indent=2, default=str))
146
+ else:
147
+ console.print(message)
148
+
149
+
150
+ def _run(ctx: typer.Context, fn: object) -> object:
151
+ """Calls fn() against a fresh client, translating ApiError/network failures into a clean
152
+ exit instead of a raw traceback - the client is opened/closed per invocation since this is
153
+ a short-lived CLI process, not a long-running one."""
154
+ client = _require_client(ctx)
155
+ try:
156
+ return fn(client)
157
+ except ApiError as exc:
158
+ error_console.print(f"[red]{exc}[/red]")
159
+ raise typer.Exit(1) from exc
160
+ finally:
161
+ client.close()
162
+
163
+
164
+ @app.command()
165
+ def login(
166
+ instance_url: str = typer.Argument(
167
+ ..., help="Base URL of your StudyLife instance, e.g. https://studylife.example.com"
168
+ ),
169
+ client_id: str = typer.Option(
170
+ DEFAULT_CLIENT_ID, help="ClientId this CLI was registered under via studylife-developers."
171
+ ),
172
+ ) -> None:
173
+ """Log in via your browser and store the resulting credential locally."""
174
+ try:
175
+ credentials: Credentials = login_and_save(instance_url, client_id=client_id)
176
+ except LoginError as exc:
177
+ error_console.print(f"[red]Login failed: {exc}[/red]")
178
+ raise typer.Exit(1) from exc
179
+ console.print(f"[green]Logged in[/green] to {credentials.instance_url}.")
180
+
181
+
182
+ @app.command()
183
+ def logout() -> None:
184
+ """Remove the locally stored credential."""
185
+ clear_credentials()
186
+ console.print("Logged out.")
187
+
188
+
189
+ # -- Notes --------------------------------------------------------------------------
190
+
191
+
192
+ @notes_app.command("list")
193
+ def notes_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
194
+ """List notes. Table view shows a few fields - use --json for every field."""
195
+ notes = _run(ctx, lambda c: c.list_notes())
196
+ _print(
197
+ _use_json(ctx, as_json),
198
+ [n.model_dump(mode="json") for n in notes],
199
+ "Notes",
200
+ columns=NOTE_TABLE_COLUMNS,
201
+ )
202
+
203
+
204
+ @notes_app.command("search")
205
+ def notes_search(ctx: typer.Context, query: str, as_json: bool = JSON_OPTION) -> None:
206
+ """Search notes by title/content."""
207
+ notes = _run(ctx, lambda c: c.search_notes(query))
208
+ _print(
209
+ _use_json(ctx, as_json),
210
+ [n.model_dump(mode="json") for n in notes],
211
+ "Notes",
212
+ columns=NOTE_TABLE_COLUMNS,
213
+ )
214
+
215
+
216
+ @notes_app.command("create")
217
+ def notes_create(
218
+ ctx: typer.Context,
219
+ title: str,
220
+ content: str,
221
+ course_id: int | None = typer.Option(None, help="Course to attach this note to."),
222
+ as_json: bool = JSON_OPTION,
223
+ ) -> None:
224
+ """Create a note."""
225
+ note = _run(
226
+ ctx, lambda c: c.create_note(Note(title=title, content=content, course_id=course_id))
227
+ )
228
+ _confirm(_use_json(ctx, as_json), note.model_dump(mode="json"), f"Created note {note.id}.")
229
+
230
+
231
+ @notes_app.command("edit")
232
+ def notes_edit(
233
+ ctx: typer.Context,
234
+ note_id: int,
235
+ title: str,
236
+ content: str,
237
+ course_id: int | None = typer.Option(None),
238
+ as_json: bool = JSON_OPTION,
239
+ ) -> None:
240
+ """Edit a note. Replaces title/content/course_id entirely (not a partial patch)."""
241
+ note = _run(
242
+ ctx,
243
+ lambda c: c.update_note(note_id, Note(title=title, content=content, course_id=course_id)),
244
+ )
245
+ _confirm(_use_json(ctx, as_json), note.model_dump(mode="json"), f"Updated note {note.id}.")
246
+
247
+
248
+ @notes_app.command("delete")
249
+ def notes_delete(ctx: typer.Context, note_id: int, as_json: bool = JSON_OPTION) -> None:
250
+ """Delete a note."""
251
+ _run(ctx, lambda c: c.delete_note(note_id))
252
+ _confirm(_use_json(ctx, as_json), {"deleted": note_id}, f"Deleted note {note_id}.")
253
+
254
+
255
+ # -- Sessions -----------------------------------------------------------------------
256
+
257
+
258
+ @sessions_app.command("list")
259
+ def sessions_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
260
+ """List sessions. Unbounded - use `sessions history` for a long-term window."""
261
+ sessions = _run(ctx, lambda c: c.list_sessions())
262
+ _print(
263
+ _use_json(ctx, as_json),
264
+ [s.model_dump(mode="json") for s in sessions],
265
+ "Sessions",
266
+ columns=SESSION_TABLE_COLUMNS,
267
+ )
268
+
269
+
270
+ @sessions_app.command("history")
271
+ def sessions_history(
272
+ ctx: typer.Context,
273
+ days: int | None = typer.Option(None),
274
+ only_completed: bool | None = typer.Option(None, "--only-completed/--all"),
275
+ as_json: bool = JSON_OPTION,
276
+ ) -> None:
277
+ """Long-term session history, default 1 year of completed sessions."""
278
+ sessions = _run(ctx, lambda c: c.session_history(days=days, only_completed=only_completed))
279
+ _print(
280
+ _use_json(ctx, as_json),
281
+ [s.model_dump(mode="json") for s in sessions],
282
+ "Session history",
283
+ columns=SESSION_TABLE_COLUMNS,
284
+ )
285
+
286
+
287
+ @sessions_app.command("create")
288
+ def sessions_create(
289
+ ctx: typer.Context,
290
+ course_id: int = typer.Argument(..., help="Course id (see `studylife courses list`)."),
291
+ start: str = typer.Argument(..., help="ISO 8601 start time, e.g. 2026-08-30T14:00:00"),
292
+ end: str = typer.Argument(..., help="ISO 8601 end time, e.g. 2026-08-30T15:00:00"),
293
+ topic: str | None = typer.Option(None),
294
+ notes: str | None = typer.Option(None),
295
+ completed: bool = typer.Option(False),
296
+ as_json: bool = JSON_OPTION,
297
+ ) -> None:
298
+ """Create a study session."""
299
+ session = _run(
300
+ ctx,
301
+ lambda c: c.create_session(
302
+ Session(
303
+ course_id=course_id,
304
+ start_time=start,
305
+ end_time=end,
306
+ topic=topic,
307
+ notes=notes,
308
+ is_completed=completed,
309
+ )
310
+ ),
311
+ )
312
+ _confirm(
313
+ _use_json(ctx, as_json), session.model_dump(mode="json"), f"Created session {session.id}."
314
+ )
315
+
316
+
317
+ @sessions_app.command("edit")
318
+ def sessions_edit(
319
+ ctx: typer.Context,
320
+ session_id: int,
321
+ course_id: int = typer.Argument(...),
322
+ start: str = typer.Argument(...),
323
+ end: str = typer.Argument(...),
324
+ topic: str | None = typer.Option(None),
325
+ notes: str | None = typer.Option(None),
326
+ completed: bool = typer.Option(False),
327
+ as_json: bool = JSON_OPTION,
328
+ ) -> None:
329
+ """Edit a session. Replaces every field entirely (not a partial patch)."""
330
+ session = _run(
331
+ ctx,
332
+ lambda c: c.update_session(
333
+ session_id,
334
+ Session(
335
+ course_id=course_id,
336
+ start_time=start,
337
+ end_time=end,
338
+ topic=topic,
339
+ notes=notes,
340
+ is_completed=completed,
341
+ ),
342
+ ),
343
+ )
344
+ _confirm(
345
+ _use_json(ctx, as_json), session.model_dump(mode="json"), f"Updated session {session.id}."
346
+ )
347
+
348
+
349
+ @sessions_app.command("delete")
350
+ def sessions_delete(ctx: typer.Context, session_id: int, as_json: bool = JSON_OPTION) -> None:
351
+ """Delete a session."""
352
+ _run(ctx, lambda c: c.delete_session(session_id))
353
+ _confirm(_use_json(ctx, as_json), {"deleted": session_id}, f"Deleted session {session_id}.")
354
+
355
+
356
+ # -- Course goals -------------------------------------------------------------------
357
+
358
+
359
+ @goals_app.command("list")
360
+ def goals_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
361
+ """List course goals."""
362
+ goals = _run(ctx, lambda c: c.list_course_goals())
363
+ _print(
364
+ _use_json(ctx, as_json),
365
+ [g.model_dump(mode="json") for g in goals],
366
+ "Course goals",
367
+ columns=COURSE_GOAL_TABLE_COLUMNS,
368
+ )
369
+
370
+
371
+ @goals_app.command("set")
372
+ def goals_set(
373
+ ctx: typer.Context,
374
+ course_id: int,
375
+ target_date: str | None = typer.Option(None, help="ISO 8601 date, e.g. 2026-12-31."),
376
+ completion_note: str | None = typer.Option(None),
377
+ completed_at: str | None = typer.Option(None, help="ISO 8601 date, once completed."),
378
+ grade: float | None = typer.Option(None, help="German grading, 1.0 (best) to 5.0 (failed)."),
379
+ completed_topics: str = typer.Option("", help="Comma-separated topic names."),
380
+ tag: str | None = typer.Option(None),
381
+ as_json: bool = JSON_OPTION,
382
+ ) -> None:
383
+ """Set (create or fully replace) the goal for a course. Every field not passed is cleared,
384
+ not left as-is - this mirrors the server's own full-replace PUT semantics."""
385
+ goal = _run(
386
+ ctx,
387
+ lambda c: c.save_course_goal(
388
+ course_id,
389
+ CourseGoal(
390
+ course_id=course_id,
391
+ target_date=target_date,
392
+ completion_note=completion_note,
393
+ completed_at=completed_at,
394
+ grade=grade,
395
+ completed_topics=completed_topics,
396
+ tag=tag,
397
+ ),
398
+ ),
399
+ )
400
+ _confirm(
401
+ _use_json(ctx, as_json),
402
+ goal.model_dump(mode="json"),
403
+ f"Saved goal for course {goal.course_id}.",
404
+ )
405
+
406
+
407
+ @goals_app.command("delete")
408
+ def goals_delete(ctx: typer.Context, course_id: int, as_json: bool = JSON_OPTION) -> None:
409
+ """Delete a course's goal."""
410
+ _run(ctx, lambda c: c.delete_course_goal(course_id))
411
+ _confirm(
412
+ _use_json(ctx, as_json), {"deleted": course_id}, f"Deleted goal for course {course_id}."
413
+ )
414
+
415
+
416
+ # -- Timer ----------------------------------------------------------------------------
417
+
418
+
419
+ @app.command()
420
+ def timer(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
421
+ """Show the current timer state."""
422
+ state = _run(ctx, lambda c: c.get_timer_state())
423
+ _print(_use_json(ctx, as_json), [state.model_dump(mode="json")], "Timer state")
424
+
425
+
426
+ # -- Courses / study programs --------------------------------------------------------
427
+
428
+
429
+ @courses_app.command("list")
430
+ def courses_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
431
+ """List courses in the active study program."""
432
+ courses = _run(ctx, lambda c: c.list_courses())
433
+ _print(
434
+ _use_json(ctx, as_json),
435
+ [c.model_dump(mode="json") for c in courses],
436
+ "Courses",
437
+ columns=COURSE_TABLE_COLUMNS,
438
+ )
439
+
440
+
441
+ @programs_app.command("list")
442
+ def programs_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
443
+ """List study programs (built-in and custom)."""
444
+ programs = _run(ctx, lambda c: c.list_study_programs())
445
+ _print(_use_json(ctx, as_json), [p.model_dump(mode="json") for p in programs], "Study programs")
446
+
447
+
448
+ @programs_app.command("get")
449
+ def programs_get(ctx: typer.Context, program_id: int, as_json: bool = JSON_OPTION) -> None:
450
+ """Get a custom study program's detail (course groups/ECTS quotas). Only applies to custom
451
+ programs - the built-in program has no id and no detail endpoint."""
452
+ program = _run(ctx, lambda c: c.get_study_program(program_id))
453
+ _print(_use_json(ctx, as_json), [program.model_dump(mode="json")], "Study program")
454
+
455
+
456
+ # -- Webhooks ---------------------------------------------------------------------------
457
+
458
+
459
+ @webhooks_app.command("list")
460
+ def webhooks_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
461
+ """List registered webhooks."""
462
+ webhooks = _run(ctx, lambda c: c.list_webhooks())
463
+ _print(_use_json(ctx, as_json), [w.model_dump(mode="json") for w in webhooks], "Webhooks")
464
+
465
+
466
+ @webhooks_app.command("create")
467
+ def webhooks_create(
468
+ ctx: typer.Context, target_url: str, events: list[str], as_json: bool = JSON_OPTION
469
+ ) -> None:
470
+ """Register a webhook. events is one or more event type names, e.g. session.completed."""
471
+ webhook = _run(ctx, lambda c: c.create_webhook(target_url, events))
472
+ _confirm(
473
+ _use_json(ctx, as_json), webhook.model_dump(mode="json"), f"Created webhook {webhook.id}."
474
+ )
475
+
476
+
477
+ @webhooks_app.command("delete")
478
+ def webhooks_delete(ctx: typer.Context, webhook_id: str, as_json: bool = JSON_OPTION) -> None:
479
+ """Delete a webhook."""
480
+ _run(ctx, lambda c: c.delete_webhook(webhook_id))
481
+ _confirm(_use_json(ctx, as_json), {"deleted": webhook_id}, f"Deleted webhook {webhook_id}.")
482
+
483
+
484
+ # -- Verb-first aliases --------------------------------------------------------------
485
+ #
486
+ # Every command above reads as "resource verb" (`studylife notes list`). Some people reach for
487
+ # "verb resource" instead (`studylife list notes`) - both are registered here for exactly the
488
+ # same underlying functions (a Typer @x.command() decorator hands back the plain function
489
+ # unchanged, so re-registering it under a second Typer app is just a second entry pointing at
490
+ # the same code, not a copy of it - no logic is duplicated, and a fix to one applies to both).
491
+ # Search/history/timer already read naturally as a bare verb (only one resource each supports
492
+ # them), so those get a single top-level command instead of a one-item dispatch group.
493
+ list_app = typer.Typer(
494
+ no_args_is_help=True, help="List any resource (alias for `<resource> list`)."
495
+ )
496
+ create_app = typer.Typer(
497
+ no_args_is_help=True, help="Create a resource (alias for `<resource> create`)."
498
+ )
499
+ edit_app = typer.Typer(no_args_is_help=True, help="Edit a resource (alias for `<resource> edit`).")
500
+ delete_app = typer.Typer(
501
+ no_args_is_help=True, help="Delete a resource (alias for `<resource> delete`)."
502
+ )
503
+ get_app = typer.Typer(
504
+ no_args_is_help=True, help="Get a single resource (alias for `<resource> get`)."
505
+ )
506
+ set_app = typer.Typer(no_args_is_help=True, help="Set a resource (alias for `<resource> set`).")
507
+
508
+ list_app.command("notes")(notes_list)
509
+ list_app.command("sessions")(sessions_list)
510
+ list_app.command("goals")(goals_list)
511
+ list_app.command("courses")(courses_list)
512
+ list_app.command("programs")(programs_list)
513
+ list_app.command("webhooks")(webhooks_list)
514
+ app.add_typer(list_app, name="list")
515
+
516
+ create_app.command("notes")(notes_create)
517
+ create_app.command("sessions")(sessions_create)
518
+ create_app.command("webhooks")(webhooks_create)
519
+ app.add_typer(create_app, name="create")
520
+
521
+ edit_app.command("notes")(notes_edit)
522
+ edit_app.command("sessions")(sessions_edit)
523
+ app.add_typer(edit_app, name="edit")
524
+
525
+ delete_app.command("notes")(notes_delete)
526
+ delete_app.command("sessions")(sessions_delete)
527
+ delete_app.command("goals")(goals_delete)
528
+ delete_app.command("webhooks")(webhooks_delete)
529
+ app.add_typer(delete_app, name="delete")
530
+
531
+ get_app.command("programs")(programs_get)
532
+ app.add_typer(get_app, name="get")
533
+
534
+ set_app.command("goals")(goals_set)
535
+ app.add_typer(set_app, name="set")
536
+
537
+ app.command("search")(notes_search)
538
+ app.command("history")(sessions_history)
539
+
540
+
541
+ def run() -> None:
542
+ app()
543
+
544
+
545
+ if __name__ == "__main__":
546
+ sys.exit(run())