clockify-unofficial-cli 0.2.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.
Files changed (49) hide show
  1. clockify_unofficial_cli/__init__.py +3 -0
  2. clockify_unofficial_cli/__main__.py +3 -0
  3. clockify_unofficial_cli/_version.py +3 -0
  4. clockify_unofficial_cli/auth/__init__.py +0 -0
  5. clockify_unofficial_cli/auth/credentials.py +70 -0
  6. clockify_unofficial_cli/auth/env_store.py +26 -0
  7. clockify_unofficial_cli/auth/file_store.py +49 -0
  8. clockify_unofficial_cli/auth/keyring_store.py +62 -0
  9. clockify_unofficial_cli/auth/resolver.py +34 -0
  10. clockify_unofficial_cli/commands/__init__.py +0 -0
  11. clockify_unofficial_cli/commands/auth.py +153 -0
  12. clockify_unofficial_cli/commands/client.py +116 -0
  13. clockify_unofficial_cli/commands/config.py +50 -0
  14. clockify_unofficial_cli/commands/custom_field.py +130 -0
  15. clockify_unofficial_cli/commands/entry.py +275 -0
  16. clockify_unofficial_cli/commands/group.py +89 -0
  17. clockify_unofficial_cli/commands/project.py +174 -0
  18. clockify_unofficial_cli/commands/tag.py +107 -0
  19. clockify_unofficial_cli/commands/task.py +194 -0
  20. clockify_unofficial_cli/commands/timer_shortcuts.py +130 -0
  21. clockify_unofficial_cli/commands/user.py +45 -0
  22. clockify_unofficial_cli/commands/workspace.py +68 -0
  23. clockify_unofficial_cli/config/__init__.py +0 -0
  24. clockify_unofficial_cli/config/paths.py +25 -0
  25. clockify_unofficial_cli/config/settings.py +75 -0
  26. clockify_unofficial_cli/config/store.py +56 -0
  27. clockify_unofficial_cli/main.py +131 -0
  28. clockify_unofficial_cli/output/__init__.py +0 -0
  29. clockify_unofficial_cli/output/columns.py +88 -0
  30. clockify_unofficial_cli/output/formats.py +82 -0
  31. clockify_unofficial_cli/output/registry.py +29 -0
  32. clockify_unofficial_cli/output/renderer.py +68 -0
  33. clockify_unofficial_cli/runtime/__init__.py +0 -0
  34. clockify_unofficial_cli/runtime/client_factory.py +42 -0
  35. clockify_unofficial_cli/runtime/context.py +99 -0
  36. clockify_unofficial_cli/runtime/errors.py +89 -0
  37. clockify_unofficial_cli/runtime/exit_codes.py +15 -0
  38. clockify_unofficial_cli/runtime/params.py +39 -0
  39. clockify_unofficial_cli/runtime/prompts.py +24 -0
  40. clockify_unofficial_cli/services/__init__.py +0 -0
  41. clockify_unofficial_cli/services/listing.py +58 -0
  42. clockify_unofficial_cli/services/parsing.py +118 -0
  43. clockify_unofficial_cli/services/resolve.py +128 -0
  44. clockify_unofficial_cli/services/timer.py +126 -0
  45. clockify_unofficial_cli-0.2.0.dist-info/METADATA +278 -0
  46. clockify_unofficial_cli-0.2.0.dist-info/RECORD +49 -0
  47. clockify_unofficial_cli-0.2.0.dist-info/WHEEL +4 -0
  48. clockify_unofficial_cli-0.2.0.dist-info/entry_points.txt +2 -0
  49. clockify_unofficial_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,107 @@
1
+ import dataclasses
2
+ from dataclasses import dataclass
3
+ from typing import Annotated
4
+ from typing import Final
5
+
6
+ import typer
7
+ from clockify import TagCreate
8
+ from clockify import TagUpdate
9
+
10
+ from clockify_unofficial_cli.output.columns import TAGS
11
+ from clockify_unofficial_cli.output.renderer import many
12
+ from clockify_unofficial_cli.output.renderer import single
13
+ from clockify_unofficial_cli.runtime.context import get_app_context
14
+ from clockify_unofficial_cli.runtime.errors import handle_errors
15
+ from clockify_unofficial_cli.runtime.prompts import confirm
16
+ from clockify_unofficial_cli.services.listing import list_from_options
17
+ from clockify_unofficial_cli.services.resolve import resolve_tag
18
+
19
+ APP: Final = typer.Typer(help="Manage workspace tags.", no_args_is_help=True)
20
+
21
+
22
+ @APP.command(name="list", help="List every tag in the active workspace.")
23
+ @handle_errors
24
+ def list_tags(
25
+ ctx: typer.Context,
26
+ *,
27
+ limit: Annotated[
28
+ int | None,
29
+ typer.Option("--limit", help="Stop after N tags; incompatible with --page/--page-size."),
30
+ ] = None,
31
+ page: Annotated[
32
+ int | None,
33
+ typer.Option("--page", help="1-based page number when paging through results."),
34
+ ] = None,
35
+ page_size: Annotated[
36
+ int | None,
37
+ typer.Option("--page-size", help="Page size when paging through results."),
38
+ ] = None,
39
+ ) -> None:
40
+ app_context = get_app_context(ctx)
41
+ workspace = app_context.workspace()
42
+ tags = list_from_options(workspace.tags.list, workspace.tags.list_page, limit, page, page_size)
43
+ app_context.render(many(tags, TAGS))
44
+
45
+
46
+ @APP.command(help="Show a tag by ID or exact name.")
47
+ @handle_errors
48
+ def get(ctx: typer.Context, term: Annotated[str, typer.Argument(help="Tag ID or exact name.")]) -> None:
49
+ app_context = get_app_context(ctx)
50
+ tag_id = resolve_tag(app_context, term)
51
+ tag = app_context.workspace().tags.get(tag_id)
52
+ app_context.render(single(tag, TAGS))
53
+
54
+
55
+ @APP.command(help="Create a new tag.")
56
+ @handle_errors
57
+ def create(ctx: typer.Context, *, name: Annotated[str, typer.Option("--name", help="Tag name.")]) -> None:
58
+ app_context = get_app_context(ctx)
59
+ tag = app_context.workspace().tags.create(TagCreate(name=name))
60
+ app_context.render(single(tag, TAGS))
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class _UpdateArgs:
65
+ name: str | None
66
+ archived: bool | None
67
+
68
+
69
+ @APP.command(help="Update an existing tag.")
70
+ @handle_errors
71
+ def update(
72
+ ctx: typer.Context,
73
+ term: Annotated[str, typer.Argument(help="Tag ID or exact name.")],
74
+ *,
75
+ name: Annotated[
76
+ str | None,
77
+ typer.Option("--name", help="New tag name."),
78
+ ] = None,
79
+ archived: Annotated[
80
+ bool | None,
81
+ typer.Option("--archived/--no-archived", help="Archive or restore the tag."),
82
+ ] = None,
83
+ ) -> None:
84
+ args = _UpdateArgs(name=name, archived=archived)
85
+ app_context = get_app_context(ctx)
86
+ tag_id = resolve_tag(app_context, term)
87
+ payload = TagUpdate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
88
+ tag = app_context.workspace().tags.update(tag_id, payload)
89
+ app_context.render(single(tag, TAGS))
90
+
91
+
92
+ @APP.command(help="Delete a tag.")
93
+ @handle_errors
94
+ def delete(
95
+ ctx: typer.Context,
96
+ term: Annotated[str, typer.Argument(help="Tag ID or exact name.")],
97
+ *,
98
+ yes: Annotated[
99
+ bool,
100
+ typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
101
+ ] = False,
102
+ ) -> None:
103
+ app_context = get_app_context(ctx)
104
+ confirm(f"Delete tag '{term}'", assume_yes=yes)
105
+ tag_id = resolve_tag(app_context, term)
106
+ app_context.workspace().tags.delete(tag_id)
107
+ app_context.notify(f"Deleted tag '{tag_id}'.")
@@ -0,0 +1,194 @@
1
+ import dataclasses
2
+ from dataclasses import dataclass
3
+ from typing import Annotated
4
+ from typing import Final
5
+
6
+ import typer
7
+ from clockify import ProjectId
8
+ from clockify import TaskCreate
9
+ from clockify import TaskStatus
10
+ from clockify import TaskUpdate
11
+ from clockify import UserId
12
+
13
+ from clockify_unofficial_cli.output.columns import TASKS
14
+ from clockify_unofficial_cli.output.renderer import many
15
+ from clockify_unofficial_cli.output.renderer import single
16
+ from clockify_unofficial_cli.runtime.context import get_app_context
17
+ from clockify_unofficial_cli.runtime.errors import handle_errors
18
+ from clockify_unofficial_cli.runtime.params import options_from
19
+ from clockify_unofficial_cli.runtime.prompts import confirm
20
+ from clockify_unofficial_cli.services.listing import list_from_options
21
+ from clockify_unofficial_cli.services.resolve import resolve_project
22
+ from clockify_unofficial_cli.services.resolve import resolve_task
23
+
24
+ APP: Final = typer.Typer(help="Manage tasks inside a project.", no_args_is_help=True)
25
+
26
+
27
+ def _project_id(app_context: object, term: str) -> ProjectId:
28
+ resolved = resolve_project(app_context, term) # type: ignore[arg-type]
29
+ return ProjectId(resolved)
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class _ListArgs:
34
+ project: str
35
+ limit: int | None
36
+ page: int | None
37
+ page_size: int | None
38
+
39
+
40
+ @APP.command(name="list", help="List tasks for the named project.")
41
+ @handle_errors
42
+ def list_tasks(
43
+ ctx: typer.Context,
44
+ *,
45
+ project: Annotated[str, typer.Option("--project", "-P", help="Project ID or exact name.")],
46
+ limit: Annotated[
47
+ int | None,
48
+ typer.Option("--limit", help="Stop after N tasks; incompatible with --page/--page-size."),
49
+ ] = None,
50
+ page: Annotated[
51
+ int | None,
52
+ typer.Option("--page", help="1-based page number when paging through results."),
53
+ ] = None,
54
+ page_size: Annotated[
55
+ int | None,
56
+ typer.Option("--page-size", help="Page size when paging through results."),
57
+ ] = None,
58
+ ) -> None:
59
+ args = _ListArgs(project=project, limit=limit, page=page, page_size=page_size)
60
+ app_context = get_app_context(ctx)
61
+ workspace = app_context.workspace()
62
+ project_id = _project_id(app_context, args.project)
63
+ tasks = list_from_options(
64
+ lambda: workspace.tasks.list(project_id),
65
+ lambda **kwargs: workspace.tasks.list_page(project_id, **kwargs),
66
+ args.limit,
67
+ args.page,
68
+ args.page_size,
69
+ )
70
+ app_context.render(many(tasks, TASKS))
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class _TaskProjectArgs:
75
+ project: str
76
+
77
+
78
+ @APP.command(help="Show a task by ID or name within the named project.")
79
+ @handle_errors
80
+ def get(
81
+ ctx: typer.Context,
82
+ term: Annotated[str, typer.Argument(help="Task ID or exact name.")],
83
+ *,
84
+ project: Annotated[str, typer.Option("--project", "-P", help="Project ID or exact name.")],
85
+ ) -> None:
86
+ args = _TaskProjectArgs(project=project)
87
+ app_context = get_app_context(ctx)
88
+ project_id = _project_id(app_context, args.project)
89
+ task_id = resolve_task(app_context, project_id, term)
90
+ task = app_context.workspace().tasks.get(project_id, task_id)
91
+ app_context.render(single(task, TASKS))
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class _CreateArgs:
96
+ project: str
97
+ name: str
98
+ assignee_ids: list[UserId] | None
99
+ estimate: str | None
100
+ status: TaskStatus | None
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class _CreateOptions:
105
+ project: Annotated[str, typer.Option("--project", "-P", help="Project ID or exact name.")]
106
+ name: Annotated[str, typer.Option("--name", help="Task display name.")]
107
+ assignee: Annotated[str | None, typer.Option("--assignee", help="Assignee user ID.")] = None
108
+ estimate: Annotated[
109
+ str | None,
110
+ typer.Option("--estimate", help="ISO-8601 duration estimate, e.g. PT2H30M."),
111
+ ] = None
112
+ status: Annotated[
113
+ TaskStatus | None,
114
+ typer.Option("--status", case_sensitive=False, help="ACTIVE or DONE."),
115
+ ] = None
116
+
117
+
118
+ @APP.command(help="Create a new task under the named project.")
119
+ @handle_errors
120
+ @options_from(_CreateOptions)
121
+ def create(ctx: typer.Context, options: _CreateOptions) -> None:
122
+ args = _CreateArgs(
123
+ project=options.project,
124
+ name=options.name,
125
+ assignee_ids=[UserId(options.assignee)] if options.assignee else None,
126
+ estimate=options.estimate,
127
+ status=options.status,
128
+ )
129
+ app_context = get_app_context(ctx)
130
+ project_id = _project_id(app_context, args.project)
131
+ payload = TaskCreate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None and k != "project"})
132
+ task = app_context.workspace().tasks.create(project_id, payload)
133
+ app_context.render(single(task, TASKS))
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class _UpdateArgs:
138
+ project: str
139
+ name: str | None
140
+ assignee_ids: list[UserId] | None
141
+ estimate: str | None
142
+ status: TaskStatus | None
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class _UpdateOptions:
147
+ term: Annotated[str, typer.Argument(help="Task ID or exact name.")]
148
+ project: Annotated[str, typer.Option("--project", "-P", help="Project ID or exact name.")]
149
+ name: Annotated[str | None, typer.Option("--name", help="New task display name.")] = None
150
+ assignee: Annotated[str | None, typer.Option("--assignee", help="New assignee user ID.")] = None
151
+ estimate: Annotated[str | None, typer.Option("--estimate", help="New ISO-8601 duration estimate.")] = None
152
+ status: Annotated[
153
+ TaskStatus | None,
154
+ typer.Option("--status", case_sensitive=False, help="ACTIVE or DONE."),
155
+ ] = None
156
+
157
+
158
+ @APP.command(help="Update an existing task.")
159
+ @handle_errors
160
+ @options_from(_UpdateOptions)
161
+ def update(ctx: typer.Context, options: _UpdateOptions) -> None:
162
+ args = _UpdateArgs(
163
+ project=options.project,
164
+ name=options.name,
165
+ assignee_ids=[UserId(options.assignee)] if options.assignee else None,
166
+ estimate=options.estimate,
167
+ status=options.status,
168
+ )
169
+ app_context = get_app_context(ctx)
170
+ project_id = _project_id(app_context, args.project)
171
+ task_id = resolve_task(app_context, project_id, options.term)
172
+ payload = TaskUpdate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None and k != "project"})
173
+ task = app_context.workspace().tasks.update(project_id, task_id, payload)
174
+ app_context.render(single(task, TASKS))
175
+
176
+
177
+ @APP.command(help="Delete a task.")
178
+ @handle_errors
179
+ def delete(
180
+ ctx: typer.Context,
181
+ term: Annotated[str, typer.Argument(help="Task ID or exact name.")],
182
+ *,
183
+ project: Annotated[str, typer.Option("--project", "-P", help="Project ID or exact name.")],
184
+ yes: Annotated[
185
+ bool,
186
+ typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
187
+ ] = False,
188
+ ) -> None:
189
+ app_context = get_app_context(ctx)
190
+ confirm(f"Delete task '{term}'", assume_yes=yes)
191
+ project_id = _project_id(app_context, project)
192
+ task_id = resolve_task(app_context, project_id, term)
193
+ app_context.workspace().tasks.delete(project_id, task_id)
194
+ app_context.notify(f"Deleted task '{task_id}'.")
@@ -0,0 +1,130 @@
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+ from typing import Final
4
+
5
+ import typer
6
+ from clockify import ProjectId
7
+ from clockify import TagId
8
+ from clockify import TaskId
9
+
10
+ from clockify_unofficial_cli.output.columns import TIME_ENTRIES
11
+ from clockify_unofficial_cli.output.renderer import single
12
+ from clockify_unofficial_cli.output.renderer import to_record
13
+ from clockify_unofficial_cli.runtime.context import get_app_context
14
+ from clockify_unofficial_cli.runtime.errors import CliError
15
+ from clockify_unofficial_cli.runtime.errors import handle_errors
16
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
17
+ from clockify_unofficial_cli.runtime.params import options_from
18
+ from clockify_unofficial_cli.services.resolve import resolve_project
19
+ from clockify_unofficial_cli.services.resolve import resolve_tag
20
+ from clockify_unofficial_cli.services.resolve import resolve_task
21
+ from clockify_unofficial_cli.services.timer import LogRequest
22
+ from clockify_unofficial_cli.services.timer import TimerRequest
23
+ from clockify_unofficial_cli.services.timer import log_entry
24
+ from clockify_unofficial_cli.services.timer import parse_optional_duration
25
+ from clockify_unofficial_cli.services.timer import parse_optional_instant
26
+ from clockify_unofficial_cli.services.timer import running_entry
27
+ from clockify_unofficial_cli.services.timer import start_timer
28
+ from clockify_unofficial_cli.services.timer import stop_timer
29
+
30
+ APP: Final = typer.Typer(help="Timer shortcuts (start/stop/status/log).", no_args_is_help=True)
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class _StartOptions:
35
+ description: Annotated[str | None, typer.Argument(help="Entry description.")] = None
36
+ project: Annotated[str | None, typer.Option("--project", "-P", help="Project ID or exact name.")] = None
37
+ task: Annotated[str | None, typer.Option("--task", "-t", help="Task ID or exact name; requires --project.")] = None
38
+ tag: Annotated[list[str] | None, typer.Option("--tag", help="Tag ID or exact name; pass multiple times.")] = None
39
+ billable: Annotated[bool, typer.Option("--billable/--no-billable", help="Mark the entry as billable.")] = False
40
+ from_: Annotated[str | None, typer.Option("--from", help="Start instant; defaults to now.")] = None
41
+
42
+
43
+ @APP.command(name="start", help="Start a new running timer; defaults to `now` when --from is omitted.")
44
+ @handle_errors
45
+ @options_from(_StartOptions)
46
+ def start_cmd(ctx: typer.Context, options: _StartOptions) -> None:
47
+ if options.task and not options.project:
48
+ message = "--task requires --project because tasks are scoped to projects."
49
+ raise CliError(message, exit_code=ExitCode.USAGE)
50
+ app_context = get_app_context(ctx)
51
+ project_id = ProjectId(resolve_project(app_context, options.project)) if options.project else None
52
+ task_id = TaskId(resolve_task(app_context, project_id, options.task)) if options.task and project_id else None
53
+ request = TimerRequest(
54
+ description=options.description,
55
+ project_id=project_id,
56
+ task_id=task_id,
57
+ tag_ids=tuple(TagId(resolve_tag(app_context, term)) for term in (options.tag or ())),
58
+ billable=options.billable,
59
+ start=parse_optional_instant(options.from_),
60
+ end=None,
61
+ duration=None,
62
+ )
63
+ entry = start_timer(app_context, request)
64
+ app_context.render(single(_entry_as_record(entry), TIME_ENTRIES))
65
+
66
+
67
+ @APP.command(name="stop", help="Stop the currently running timer.")
68
+ @handle_errors
69
+ def stop_cmd(ctx: typer.Context) -> None:
70
+ app_context = get_app_context(ctx)
71
+ entry = stop_timer(app_context, end=None)
72
+ app_context.render(single(_entry_as_record(entry), TIME_ENTRIES))
73
+
74
+
75
+ @APP.command(
76
+ name="status", help="Show the currently running timer; exits OK with a stderr notice when none is active."
77
+ )
78
+ @handle_errors
79
+ def status_cmd(ctx: typer.Context) -> None:
80
+ app_context = get_app_context(ctx)
81
+ entry = running_entry(app_context)
82
+ if entry is None:
83
+ app_context.notify("No timer running.")
84
+ raise typer.Exit(ExitCode.OK)
85
+ app_context.render(single(_entry_as_record(entry), TIME_ENTRIES))
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class _LogOptions:
90
+ description: Annotated[str | None, typer.Option("--description", help="Entry description.")] = None
91
+ project: Annotated[str | None, typer.Option("--project", "-P", help="Project ID or exact name.")] = None
92
+ task: Annotated[str | None, typer.Option("--task", "-t", help="Task ID or exact name; requires --project.")] = None
93
+ tag: Annotated[list[str] | None, typer.Option("--tag", help="Tag ID or exact name; pass multiple times.")] = None
94
+ billable: Annotated[bool, typer.Option("--billable/--no-billable", help="Mark the entry as billable.")] = False
95
+ from_: Annotated[str | None, typer.Option("--from", help="Start instant.")] = None
96
+ to: Annotated[str | None, typer.Option("--to", help="End instant.")] = None
97
+ duration: Annotated[str | None, typer.Option("--duration", help="Duration, e.g. 1h30m.")] = None
98
+
99
+
100
+ @APP.command(name="log", help="Log a finished entry; --duration with one of --from/--to, or both --from and --to.")
101
+ @handle_errors
102
+ @options_from(_LogOptions)
103
+ def log_cmd(ctx: typer.Context, options: _LogOptions) -> None:
104
+ if options.task and not options.project:
105
+ message = "--task requires --project because tasks are scoped to projects."
106
+ raise CliError(message, exit_code=ExitCode.USAGE)
107
+ if options.from_ is None and options.to is None and options.duration is None:
108
+ message = "`log` needs --from/--to or --duration."
109
+ raise CliError(message, exit_code=ExitCode.USAGE)
110
+ app_context = get_app_context(ctx)
111
+ project_id = ProjectId(resolve_project(app_context, options.project)) if options.project else None
112
+ task_id = TaskId(resolve_task(app_context, project_id, options.task)) if options.task and project_id else None
113
+ request = LogRequest(
114
+ description=options.description,
115
+ project_id=project_id,
116
+ task_id=task_id,
117
+ tag_ids=tuple(TagId(resolve_tag(app_context, term)) for term in (options.tag or ())),
118
+ billable=options.billable,
119
+ start=parse_optional_instant(options.from_),
120
+ end=parse_optional_instant(options.to),
121
+ duration=parse_optional_duration(options.duration),
122
+ )
123
+ entry = log_entry(app_context, request=request)
124
+ app_context.render(single(_entry_as_record(entry), TIME_ENTRIES))
125
+
126
+
127
+ # Wrap the SDK time-entry object into a dict mapping so the renderer's to_record
128
+ # coercion accepts it without mypy complaining about `object` not being a BaseModel.
129
+ def _entry_as_record(entry: object) -> dict[str, object]:
130
+ return dict(to_record(entry))
@@ -0,0 +1,45 @@
1
+ from typing import Annotated
2
+ from typing import Final
3
+
4
+ import typer
5
+
6
+ from clockify_unofficial_cli.output.columns import USERS
7
+ from clockify_unofficial_cli.output.renderer import many
8
+ from clockify_unofficial_cli.output.renderer import single
9
+ from clockify_unofficial_cli.runtime.context import get_app_context
10
+ from clockify_unofficial_cli.runtime.errors import handle_errors
11
+ from clockify_unofficial_cli.services.listing import list_from_options
12
+
13
+ APP: Final = typer.Typer(help="Show the authenticated user and workspace members.", no_args_is_help=True)
14
+
15
+
16
+ @APP.command(help="Show the API key owner.")
17
+ @handle_errors
18
+ def me(ctx: typer.Context) -> None:
19
+ app_context = get_app_context(ctx)
20
+ user = app_context.client().user.me()
21
+ app_context.render(single(user, USERS))
22
+
23
+
24
+ @APP.command(name="list", help="List every user visible in the active workspace.")
25
+ @handle_errors
26
+ def list_users(
27
+ ctx: typer.Context,
28
+ *,
29
+ limit: Annotated[
30
+ int | None,
31
+ typer.Option("--limit", help="Stop after N users; incompatible with --page/--page-size."),
32
+ ] = None,
33
+ page: Annotated[
34
+ int | None,
35
+ typer.Option("--page", help="1-based page number when paging through results."),
36
+ ] = None,
37
+ page_size: Annotated[
38
+ int | None,
39
+ typer.Option("--page-size", help="Page size when paging through results."),
40
+ ] = None,
41
+ ) -> None:
42
+ app_context = get_app_context(ctx)
43
+ workspace = app_context.workspace()
44
+ users = list_from_options(workspace.users.list, workspace.users.list_page, limit, page, page_size)
45
+ app_context.render(many(users, USERS))
@@ -0,0 +1,68 @@
1
+ from typing import Annotated
2
+ from typing import Final
3
+
4
+ import typer
5
+
6
+ from clockify_unofficial_cli.output.columns import WORKSPACES
7
+ from clockify_unofficial_cli.output.renderer import many
8
+ from clockify_unofficial_cli.output.renderer import single
9
+ from clockify_unofficial_cli.output.renderer import to_record
10
+ from clockify_unofficial_cli.runtime.context import get_app_context
11
+ from clockify_unofficial_cli.runtime.errors import CliError
12
+ from clockify_unofficial_cli.runtime.errors import handle_errors
13
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
14
+ from clockify_unofficial_cli.services.resolve import Candidate
15
+ from clockify_unofficial_cli.services.resolve import resolve
16
+
17
+ APP: Final = typer.Typer(help="Inspect and select the Clockify workspace.", no_args_is_help=True)
18
+
19
+
20
+ @APP.command(name="list", help="List every workspace the API key can see.")
21
+ @handle_errors
22
+ def list_workspaces(ctx: typer.Context) -> None:
23
+ app_context = get_app_context(ctx)
24
+ workspaces = app_context.client().workspaces.list()
25
+ app_context.render(many(workspaces, WORKSPACES))
26
+
27
+
28
+ @APP.command(help="Show a workspace by ID or name; defaults to the active one.")
29
+ @handle_errors
30
+ def get(
31
+ ctx: typer.Context,
32
+ workspace_id: Annotated[
33
+ str | None,
34
+ typer.Argument(help="Workspace ID or exact name; defaults to the resolved workspace."),
35
+ ] = None,
36
+ ) -> None:
37
+ app_context = get_app_context(ctx)
38
+ if workspace_id is None:
39
+ workspace = app_context.workspace()
40
+ app_context.render(single({"id": str(workspace.id), "name": "<active workspace>"}, WORKSPACES))
41
+ return
42
+ workspaces = app_context.client().workspaces.list()
43
+ candidates = [Candidate(id=str(item.id), name=item.name) for item in workspaces]
44
+ resolved_id = resolve(workspace_id, candidates, noun="workspace")
45
+ record: dict[str, object] = dict(next(to_record(item) for item in workspaces if str(item.id) == resolved_id))
46
+ app_context.render(single(record, WORKSPACES))
47
+
48
+
49
+ @APP.command(help="Persist the workspace choice in the active profile.")
50
+ @handle_errors
51
+ def use(
52
+ ctx: typer.Context,
53
+ workspace_id: Annotated[str, typer.Argument(help="Workspace ID or exact name to make the default.")],
54
+ ) -> None:
55
+ app_context = get_app_context(ctx)
56
+ workspaces = app_context.client().workspaces.list()
57
+ candidates = [Candidate(id=str(item.id), name=item.name) for item in workspaces]
58
+ resolved_id = resolve(workspace_id, candidates, noun="workspace")
59
+ store = app_context.services.settings
60
+ settings = store.load()
61
+ options = app_context.options
62
+ if options.profile_name not in settings.profiles:
63
+ message = f"No profile named '{options.profile_name}'; run `clockify auth login`."
64
+ raise CliError(message, exit_code=ExitCode.CONFIGURATION)
65
+ profile = settings.profiles[options.profile_name]
66
+ updated = profile.model_copy(update={"workspace_id": resolved_id})
67
+ store.save(settings.with_profile(options.profile_name, updated))
68
+ app_context.notify(f"Workspace set to '{resolved_id}' for profile '{options.profile_name}'.")
File without changes
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from os import environ
4
+ from pathlib import Path
5
+ from typing import Final
6
+
7
+ from platformdirs import user_config_path
8
+
9
+ APP_NAME: Final = "clockify-cli"
10
+ CONFIG_DIR_ENV_VAR: Final = "CLOCKIFY_CLI_CONFIG_DIR"
11
+
12
+
13
+ def config_dir() -> Path:
14
+ override = environ.get(CONFIG_DIR_ENV_VAR)
15
+ if override:
16
+ return Path(override)
17
+ return user_config_path(APP_NAME)
18
+
19
+
20
+ def settings_file() -> Path:
21
+ return config_dir() / "config.toml"
22
+
23
+
24
+ def credentials_file() -> Path:
25
+ return config_dir() / "credentials.toml"
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import Final
6
+
7
+ from clockify import Region
8
+ from pydantic import BaseModel
9
+ from pydantic import ConfigDict
10
+
11
+ DEFAULT_PROFILE: Final = "default"
12
+
13
+
14
+ class OutputFormat(StrEnum):
15
+ TABLE = "table"
16
+ JSON = "json"
17
+ JSONL = "jsonl"
18
+ CSV = "csv"
19
+ ID = "id"
20
+
21
+
22
+ class Profile(BaseModel):
23
+ model_config = ConfigDict(frozen=True, extra="forbid")
24
+
25
+ region: Region = Region.GLOBAL
26
+ workspace_id: str | None = None
27
+ user_id: str | None = None
28
+ email: str | None = None
29
+
30
+
31
+ class Settings(BaseModel):
32
+ model_config = ConfigDict(frozen=True, extra="forbid")
33
+
34
+ default_profile: str = DEFAULT_PROFILE
35
+ output: OutputFormat | None = None
36
+ profiles: dict[str, Profile] = {}
37
+
38
+ def with_profile(self, name: str, profile: Profile) -> Settings:
39
+ return self.model_copy(update={"profiles": {**self.profiles, name: profile}})
40
+
41
+ def without_profile(self, name: str) -> Settings:
42
+ remaining = {key: value for key, value in self.profiles.items() if key != name}
43
+ return self.model_copy(update={"profiles": remaining})
44
+
45
+
46
+ # Flag and environment values arrive already merged by Typer (flag wins over envvar).
47
+ @dataclass(frozen=True, slots=True)
48
+ class GlobalOptions:
49
+ profile: str | None = None
50
+ workspace: str | None = None
51
+ output: OutputFormat | None = None
52
+ verbose: bool = False
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class ResolvedOptions:
57
+ profile_name: str
58
+ profile: Profile
59
+ workspace_id: str | None
60
+ output: OutputFormat
61
+ verbose: bool
62
+
63
+
64
+ def resolve_options(options: GlobalOptions, settings: Settings, *, is_tty: bool) -> ResolvedOptions:
65
+ profile_name = options.profile or settings.default_profile
66
+ profile = settings.profiles.get(profile_name, Profile())
67
+ # Humans get tables; pipes get machine-readable JSON unless something explicit says otherwise.
68
+ fallback_output = OutputFormat.TABLE if is_tty else OutputFormat.JSON
69
+ return ResolvedOptions(
70
+ profile_name=profile_name,
71
+ profile=profile,
72
+ workspace_id=options.workspace or profile.workspace_id,
73
+ output=options.output or settings.output or fallback_output,
74
+ verbose=options.verbose,
75
+ )