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,130 @@
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 CustomFieldCreate
8
+ from clockify import CustomFieldUpdate
9
+
10
+ from clockify_unofficial_cli.output.columns import CUSTOM_FIELDS
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.params import options_from
16
+ from clockify_unofficial_cli.runtime.prompts import confirm
17
+ from clockify_unofficial_cli.services.listing import list_from_options
18
+ from clockify_unofficial_cli.services.resolve import resolve_custom_field
19
+
20
+ APP: Final = typer.Typer(help="Manage workspace custom fields.", no_args_is_help=True)
21
+
22
+
23
+ @APP.command(name="list", help="List every custom field in the active workspace.")
24
+ @handle_errors
25
+ def list_custom_fields(
26
+ ctx: typer.Context,
27
+ *,
28
+ limit: Annotated[
29
+ int | None,
30
+ typer.Option("--limit", help="Stop after N fields; incompatible with --page/--page-size."),
31
+ ] = None,
32
+ page: Annotated[
33
+ int | None,
34
+ typer.Option("--page", help="1-based page number when paging through results."),
35
+ ] = None,
36
+ page_size: Annotated[
37
+ int | None,
38
+ typer.Option("--page-size", help="Page size when paging through results."),
39
+ ] = None,
40
+ ) -> None:
41
+ app_context = get_app_context(ctx)
42
+ workspace = app_context.workspace()
43
+ fields = list_from_options(workspace.custom_fields.list, workspace.custom_fields.list_page, limit, page, page_size)
44
+ app_context.render(many(fields, CUSTOM_FIELDS))
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class _CreateArgs:
49
+ name: Annotated[str, typer.Option("--name", help="Field display name.")]
50
+ field_type: Annotated[str, typer.Option("--type", help="Clockify field type, e.g. TXT or NUMBER.")]
51
+ entity_type: Annotated[
52
+ str | None,
53
+ typer.Option("--entity", help="Entity the field belongs to, e.g. TIMEENTRY."),
54
+ ] = None
55
+ allowed_values: Annotated[
56
+ list[str] | None,
57
+ typer.Option("--allowed-value", help="Allowed value for dropdown fields; pass multiple times."),
58
+ ] = None
59
+ workspace_default_value: Annotated[
60
+ str | None,
61
+ typer.Option("--default-value", help="Default value applied to new entities."),
62
+ ] = None
63
+ placeholder: Annotated[str | None, typer.Option("--placeholder", help="Placeholder shown in the input.")] = None
64
+ description: Annotated[str | None, typer.Option("--description", help="Free-form description.")] = None
65
+ status: Annotated[str | None, typer.Option("--status", help="VISIBLE, INVISIBLE or INACTIVE.")] = None
66
+ only_admin_can_edit: Annotated[
67
+ bool | None,
68
+ typer.Option("--admin-only/--open", help="Only admins may edit the field."),
69
+ ] = None
70
+
71
+
72
+ @APP.command(help="Create a new custom field.")
73
+ @handle_errors
74
+ @options_from(_CreateArgs)
75
+ def create(ctx: typer.Context, args: _CreateArgs) -> None:
76
+ app_context = get_app_context(ctx)
77
+ fields = {("type" if k == "field_type" else k): v for k, v in dataclasses.asdict(args).items() if v is not None}
78
+ field = app_context.workspace().custom_fields.create(CustomFieldCreate(**fields))
79
+ app_context.render(single(field, CUSTOM_FIELDS))
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class _UpdateArgs:
84
+ term: Annotated[str, typer.Argument(help="Custom field ID or exact name.")]
85
+ name: Annotated[str | None, typer.Option("--name", help="New field display name.")] = None
86
+ allowed_values: Annotated[
87
+ list[str] | None,
88
+ typer.Option("--allowed-value", help="New dropdown values; pass multiple times."),
89
+ ] = None
90
+ workspace_default_value: Annotated[str | None, typer.Option("--default-value", help="New default value.")] = None
91
+ placeholder: Annotated[str | None, typer.Option("--placeholder", help="New placeholder.")] = None
92
+ description: Annotated[str | None, typer.Option("--description", help="New description.")] = None
93
+ status: Annotated[str | None, typer.Option("--status", help="VISIBLE, INVISIBLE or INACTIVE.")] = None
94
+ required: Annotated[
95
+ bool | None,
96
+ typer.Option("--required/--optional", help="Toggle whether the field is required."),
97
+ ] = None
98
+ only_admin_can_edit: Annotated[
99
+ bool | None,
100
+ typer.Option("--admin-only/--open", help="Restrict edits to admins."),
101
+ ] = None
102
+
103
+
104
+ @APP.command(help="Update an existing custom field.")
105
+ @handle_errors
106
+ @options_from(_UpdateArgs)
107
+ def update(ctx: typer.Context, args: _UpdateArgs) -> None:
108
+ app_context = get_app_context(ctx)
109
+ field_id = resolve_custom_field(app_context, args.term)
110
+ changes = {k: v for k, v in dataclasses.asdict(args).items() if v is not None and k != "term"}
111
+ field = app_context.workspace().custom_fields.update(field_id, CustomFieldUpdate(**changes))
112
+ app_context.render(single(field, CUSTOM_FIELDS))
113
+
114
+
115
+ @APP.command(help="Delete a custom field.")
116
+ @handle_errors
117
+ def delete(
118
+ ctx: typer.Context,
119
+ term: Annotated[str, typer.Argument(help="Custom field ID or exact name.")],
120
+ *,
121
+ yes: Annotated[
122
+ bool,
123
+ typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
124
+ ] = False,
125
+ ) -> None:
126
+ app_context = get_app_context(ctx)
127
+ confirm(f"Delete custom field '{term}'", assume_yes=yes)
128
+ field_id = resolve_custom_field(app_context, term)
129
+ app_context.workspace().custom_fields.delete(field_id)
130
+ app_context.notify(f"Deleted custom field '{field_id}'.")
@@ -0,0 +1,275 @@
1
+ import dataclasses
2
+ import datetime
3
+ from dataclasses import dataclass
4
+ from typing import Annotated
5
+ from typing import Final
6
+
7
+ import typer
8
+ from clockify import ProjectId
9
+ from clockify import TagId
10
+ from clockify import TaskId
11
+ from clockify import TimeEntryCreate
12
+ from clockify import TimeEntryFilter
13
+ from clockify import TimeEntryUpdate
14
+
15
+ from clockify_unofficial_cli.output.columns import TIME_ENTRIES
16
+ from clockify_unofficial_cli.output.renderer import many
17
+ from clockify_unofficial_cli.output.renderer import single
18
+ from clockify_unofficial_cli.runtime.context import AppContext
19
+ from clockify_unofficial_cli.runtime.context import get_app_context
20
+ from clockify_unofficial_cli.runtime.errors import CliError
21
+ from clockify_unofficial_cli.runtime.errors import handle_errors
22
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
23
+ from clockify_unofficial_cli.runtime.params import options_from
24
+ from clockify_unofficial_cli.runtime.prompts import confirm
25
+ from clockify_unofficial_cli.services.listing import list_from_options
26
+ from clockify_unofficial_cli.services.resolve import resolve_project
27
+ from clockify_unofficial_cli.services.resolve import resolve_tag
28
+ from clockify_unofficial_cli.services.resolve import resolve_task
29
+
30
+ APP: Final = typer.Typer(help="Manage time entries.", no_args_is_help=True)
31
+
32
+
33
+ def _optional_project_id(app_context: AppContext, term: str | None) -> ProjectId | None:
34
+ return ProjectId(resolve_project(app_context, term)) if term else None
35
+
36
+
37
+ def _optional_task_id(app_context: AppContext, term: str | None, project_id: ProjectId | None) -> TaskId | None:
38
+ if not term or project_id is None:
39
+ return None
40
+ return TaskId(resolve_task(app_context, project_id, term))
41
+
42
+
43
+ def _optional_tag_ids(app_context: AppContext, terms: tuple[str, ...] | None) -> list[TagId] | None:
44
+ if not terms:
45
+ return None
46
+ return [TagId(resolve_tag(app_context, term)) for term in terms]
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class _ListArgs:
51
+ description: str | None
52
+ start: datetime.datetime | None
53
+ end: datetime.datetime | None
54
+ project_id: ProjectId | None
55
+ task_id: TaskId | None
56
+ tag_ids: list[TagId] | None
57
+ limit: int | None
58
+ page: int | None
59
+ page_size: int | None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class _ListOptions:
64
+ description: Annotated[
65
+ str | None,
66
+ typer.Option("--description", help="Substring filter on the entry description."),
67
+ ] = None
68
+ from_: Annotated[
69
+ datetime.datetime | None,
70
+ typer.Option("--from", help="Lower bound on start, e.g. 2026-09-17T09:00 or 'yesterday 09:00'."),
71
+ ] = None
72
+ to: Annotated[datetime.datetime | None, typer.Option("--to", help="Upper bound on start.")] = None
73
+ project: Annotated[
74
+ str | None,
75
+ typer.Option("--project", "-P", help="Filter by project ID or exact name."),
76
+ ] = None
77
+ task: Annotated[
78
+ str | None,
79
+ typer.Option("--task", "-t", help="Filter by task ID or exact name; requires --project."),
80
+ ] = None
81
+ tag: Annotated[
82
+ list[str] | None,
83
+ typer.Option("--tag", help="Filter by tag ID or exact name; pass multiple times."),
84
+ ] = None
85
+ limit: Annotated[
86
+ int | None,
87
+ typer.Option("--limit", help="Stop after N entries; incompatible with --page/--page-size."),
88
+ ] = None
89
+ page: Annotated[
90
+ int | None,
91
+ typer.Option("--page", help="1-based page number when paging through results."),
92
+ ] = None
93
+ page_size: Annotated[
94
+ int | None,
95
+ typer.Option("--page-size", help="Page size when paging through results."),
96
+ ] = None
97
+
98
+
99
+ @APP.command(name="list", help="List time entries for the current user.")
100
+ @handle_errors
101
+ @options_from(_ListOptions)
102
+ def list_entries(ctx: typer.Context, options: _ListOptions) -> None:
103
+ app_context = get_app_context(ctx)
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
+ workspace = app_context.workspace()
108
+ project_id = _optional_project_id(app_context, options.project)
109
+ args = _ListArgs(
110
+ description=options.description,
111
+ start=options.from_,
112
+ end=options.to,
113
+ project_id=project_id,
114
+ task_id=_optional_task_id(app_context, options.task, project_id),
115
+ tag_ids=_optional_tag_ids(app_context, tuple(options.tag) if options.tag else None),
116
+ limit=options.limit,
117
+ page=options.page,
118
+ page_size=options.page_size,
119
+ )
120
+ entry_filter = TimeEntryFilter(
121
+ description=args.description,
122
+ start=args.start,
123
+ end=args.end,
124
+ project=args.project_id,
125
+ task=args.task_id,
126
+ tag_ids=args.tag_ids,
127
+ )
128
+ user_id = app_context.user_id()
129
+ entries = list_from_options(
130
+ lambda: workspace.time_entries.list(user_id, entry_filter=entry_filter),
131
+ lambda **kwargs: workspace.time_entries.list_page(user_id, entry_filter=entry_filter, **kwargs),
132
+ args.limit,
133
+ args.page,
134
+ args.page_size,
135
+ )
136
+ app_context.render(many(entries, TIME_ENTRIES))
137
+
138
+
139
+ @APP.command(help="Show a time entry by ID.")
140
+ @handle_errors
141
+ def get(
142
+ ctx: typer.Context,
143
+ entry_id: Annotated[str, typer.Argument(help="Time entry ID.")],
144
+ ) -> None:
145
+ app_context = get_app_context(ctx)
146
+ entry = app_context.workspace().time_entries.get(entry_id)
147
+ app_context.render(single(entry, TIME_ENTRIES))
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class _CreateArgs:
152
+ description: str | None
153
+ project_id: ProjectId | None
154
+ task_id: TaskId | None
155
+ tag_ids: list[TagId] | None
156
+ billable: bool
157
+ start: datetime.datetime | None
158
+ end: datetime.datetime | None
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class _CreateOptions:
163
+ description: Annotated[str | None, typer.Option("--description", help="Entry description.")] = None
164
+ project: Annotated[str | None, typer.Option("--project", "-P", help="Project ID or exact name.")] = None
165
+ task: Annotated[
166
+ str | None,
167
+ typer.Option("--task", "-t", help="Task ID or exact name; requires --project."),
168
+ ] = None
169
+ tag: Annotated[
170
+ list[str] | None,
171
+ typer.Option("--tag", help="Tag ID or exact name; pass multiple times."),
172
+ ] = None
173
+ billable: Annotated[
174
+ bool,
175
+ typer.Option("--billable/--no-billable", help="Mark the entry as billable."),
176
+ ] = False
177
+ from_: Annotated[
178
+ datetime.datetime | None,
179
+ typer.Option("--from", help="Start instant; e.g. 2026-09-17T09:00 or 'yesterday 09:00'."),
180
+ ] = None
181
+ to: Annotated[datetime.datetime | None, typer.Option("--to", help="End instant.")] = None
182
+
183
+
184
+ @APP.command(help="Create a finished time entry (use `start` for a running entry).")
185
+ @handle_errors
186
+ @options_from(_CreateOptions)
187
+ def create(ctx: typer.Context, options: _CreateOptions) -> None:
188
+ app_context = get_app_context(ctx)
189
+ if options.from_ is None or options.to is None:
190
+ message = "`entry create` requires both --from and --to; use `log` for open ranges."
191
+ raise CliError(message, exit_code=ExitCode.USAGE)
192
+ project_id = _optional_project_id(app_context, options.project)
193
+ args = _CreateArgs(
194
+ description=options.description,
195
+ project_id=project_id,
196
+ task_id=_optional_task_id(app_context, options.task, project_id),
197
+ tag_ids=_optional_tag_ids(app_context, tuple(options.tag) if options.tag else None),
198
+ billable=options.billable,
199
+ start=options.from_,
200
+ end=options.to,
201
+ )
202
+ payload = TimeEntryCreate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
203
+ entry = app_context.workspace().time_entries.create(payload)
204
+ app_context.render(single(entry, TIME_ENTRIES))
205
+
206
+
207
+ @dataclass(frozen=True, slots=True)
208
+ class _UpdateArgs:
209
+ description: str | None
210
+ project_id: ProjectId | None
211
+ task_id: TaskId | None
212
+ tag_ids: list[TagId] | None
213
+ billable: bool | None
214
+ start: datetime.datetime
215
+ end: datetime.datetime | None
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class _UpdateOptions:
220
+ entry_id: Annotated[str, typer.Argument(help="Time entry ID.")]
221
+ description: Annotated[str | None, typer.Option("--description", help="New description.")] = None
222
+ project: Annotated[str | None, typer.Option("--project", "-P", help="New project ID or exact name.")] = None
223
+ task: Annotated[
224
+ str | None,
225
+ typer.Option("--task", "-t", help="New task ID or exact name; requires --project."),
226
+ ] = None
227
+ tag: Annotated[
228
+ list[str] | None,
229
+ typer.Option("--tag", help="New tag IDs/names; pass multiple times."),
230
+ ] = None
231
+ billable: Annotated[bool | None, typer.Option("--billable/--no-billable", help="Toggle billable.")] = None
232
+ from_: Annotated[
233
+ datetime.datetime | None,
234
+ typer.Option("--from", help="New start instant; required because Clockify PUT replaces the entry."),
235
+ ] = None
236
+ to: Annotated[datetime.datetime | None, typer.Option("--to", help="New end instant.")] = None
237
+
238
+
239
+ @APP.command(help="Update an existing time entry (start is required by the SDK).")
240
+ @handle_errors
241
+ @options_from(_UpdateOptions)
242
+ def update(ctx: typer.Context, options: _UpdateOptions) -> None:
243
+ app_context = get_app_context(ctx)
244
+ workspace = app_context.workspace()
245
+ existing = workspace.time_entries.get(options.entry_id)
246
+ project_id = _optional_project_id(app_context, options.project)
247
+ args = _UpdateArgs(
248
+ description=options.description,
249
+ project_id=project_id,
250
+ task_id=_optional_task_id(app_context, options.task, project_id),
251
+ tag_ids=_optional_tag_ids(app_context, tuple(options.tag) if options.tag else None),
252
+ billable=options.billable,
253
+ start=options.from_ or existing.time_interval.start,
254
+ end=options.to,
255
+ )
256
+ payload = TimeEntryUpdate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
257
+ entry = workspace.time_entries.update(options.entry_id, payload)
258
+ app_context.render(single(entry, TIME_ENTRIES))
259
+
260
+
261
+ @APP.command(help="Delete a time entry.")
262
+ @handle_errors
263
+ def delete(
264
+ ctx: typer.Context,
265
+ entry_id: Annotated[str, typer.Argument(help="Time entry ID.")],
266
+ *,
267
+ yes: Annotated[
268
+ bool,
269
+ typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
270
+ ] = False,
271
+ ) -> None:
272
+ app_context = get_app_context(ctx)
273
+ confirm(f"Delete time entry '{entry_id}'", assume_yes=yes)
274
+ app_context.workspace().time_entries.delete(entry_id)
275
+ app_context.notify(f"Deleted time entry '{entry_id}'.")
@@ -0,0 +1,89 @@
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 UserGroupCreate
8
+ from clockify import UserGroupUpdate
9
+
10
+ from clockify_unofficial_cli.output.columns import GROUPS
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_group
18
+
19
+ APP: Final = typer.Typer(help="Manage workspace user groups.", no_args_is_help=True)
20
+
21
+
22
+ @APP.command(name="list", help="List every user group in the active workspace.")
23
+ @handle_errors
24
+ def list_groups(
25
+ ctx: typer.Context,
26
+ *,
27
+ limit: Annotated[
28
+ int | None,
29
+ typer.Option("--limit", help="Stop after N groups; 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
+ groups = list_from_options(workspace.user_groups.list, workspace.user_groups.list_page, limit, page, page_size)
43
+ app_context.render(many(groups, GROUPS))
44
+
45
+
46
+ @APP.command(help="Create a new user group.")
47
+ @handle_errors
48
+ def create(ctx: typer.Context, *, name: Annotated[str, typer.Option("--name", help="Group name.")]) -> None:
49
+ app_context = get_app_context(ctx)
50
+ group = app_context.workspace().user_groups.create(UserGroupCreate(name=name))
51
+ app_context.render(single(group, GROUPS))
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class _UpdateArgs:
56
+ name: str
57
+
58
+
59
+ @APP.command(help="Update an existing user group.")
60
+ @handle_errors
61
+ def update(
62
+ ctx: typer.Context,
63
+ term: Annotated[str, typer.Argument(help="Group ID or exact name.")],
64
+ *,
65
+ name: Annotated[str, typer.Option("--name", help="New group name.")],
66
+ ) -> None:
67
+ args = _UpdateArgs(name=name)
68
+ app_context = get_app_context(ctx)
69
+ group_id = resolve_group(app_context, term)
70
+ group = app_context.workspace().user_groups.update(group_id, UserGroupUpdate(**dataclasses.asdict(args)))
71
+ app_context.render(single(group, GROUPS))
72
+
73
+
74
+ @APP.command(help="Delete a user group.")
75
+ @handle_errors
76
+ def delete(
77
+ ctx: typer.Context,
78
+ term: Annotated[str, typer.Argument(help="Group ID or exact name.")],
79
+ *,
80
+ yes: Annotated[
81
+ bool,
82
+ typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
83
+ ] = False,
84
+ ) -> None:
85
+ app_context = get_app_context(ctx)
86
+ confirm(f"Delete user group '{term}'", assume_yes=yes)
87
+ group_id = resolve_group(app_context, term)
88
+ app_context.workspace().user_groups.delete(group_id)
89
+ app_context.notify(f"Deleted user group '{group_id}'.")
@@ -0,0 +1,174 @@
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 ClientId
8
+ from clockify import ProjectCreate
9
+ from clockify import ProjectUpdate
10
+
11
+ from clockify_unofficial_cli.output.columns import PROJECTS_LIST
12
+ from clockify_unofficial_cli.output.renderer import many
13
+ from clockify_unofficial_cli.output.renderer import single
14
+ from clockify_unofficial_cli.runtime.context import get_app_context
15
+ from clockify_unofficial_cli.runtime.errors import handle_errors
16
+ from clockify_unofficial_cli.runtime.params import options_from
17
+ from clockify_unofficial_cli.runtime.prompts import confirm
18
+ from clockify_unofficial_cli.services.listing import list_from_options
19
+ from clockify_unofficial_cli.services.resolve import resolve_client
20
+ from clockify_unofficial_cli.services.resolve import resolve_project
21
+
22
+ APP: Final = typer.Typer(help="Manage workspace projects.", no_args_is_help=True)
23
+
24
+
25
+ @APP.command(name="list", help="List every project in the active workspace.")
26
+ @handle_errors
27
+ def list_projects(
28
+ ctx: typer.Context,
29
+ *,
30
+ limit: Annotated[
31
+ int | None,
32
+ typer.Option("--limit", help="Stop after N projects; incompatible with --page/--page-size."),
33
+ ] = None,
34
+ page: Annotated[
35
+ int | None,
36
+ typer.Option("--page", help="1-based page number when paging through results."),
37
+ ] = None,
38
+ page_size: Annotated[
39
+ int | None,
40
+ typer.Option("--page-size", help="Page size when paging through results."),
41
+ ] = None,
42
+ ) -> None:
43
+ app_context = get_app_context(ctx)
44
+ workspace = app_context.workspace()
45
+ projects = list_from_options(workspace.projects.list, workspace.projects.list_page, limit, page, page_size)
46
+ app_context.render(many(projects, PROJECTS_LIST))
47
+
48
+
49
+ @APP.command(help="Show a project by ID or exact name.")
50
+ @handle_errors
51
+ def get(ctx: typer.Context, term: Annotated[str, typer.Argument(help="Project ID or exact name.")]) -> None:
52
+ app_context = get_app_context(ctx)
53
+ project_id = resolve_project(app_context, term)
54
+ project = app_context.workspace().projects.get(project_id)
55
+ app_context.render(single(project, PROJECTS_LIST))
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class _CreateArgs:
60
+ name: str
61
+ client_id: ClientId | None
62
+ is_public: bool | None
63
+ billable: bool | None
64
+ color: str | None
65
+ note: str | None
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class _CreateOptions:
70
+ name: Annotated[str, typer.Option("--name", help="Project display name.")]
71
+ client: Annotated[
72
+ str | None,
73
+ typer.Option("--client", help="Client ID or exact name; pass --no-client to leave it blank."),
74
+ ] = None
75
+ public: Annotated[
76
+ bool | None,
77
+ typer.Option(
78
+ "--public/--private",
79
+ help="Whether the project is visible to every workspace member; omit to leave the default.",
80
+ ),
81
+ ] = None
82
+ billable: Annotated[
83
+ bool | None,
84
+ typer.Option("--billable/--no-billable", help="Default billable flag for time entries."),
85
+ ] = None
86
+ color: Annotated[str | None, typer.Option("--color", help="Hex colour, e.g. #0f62fe.")] = None
87
+ note: Annotated[str | None, typer.Option("--note", help="Free-form note.")] = None
88
+
89
+
90
+ @APP.command(help="Create a new project.")
91
+ @handle_errors
92
+ @options_from(_CreateOptions)
93
+ def create(ctx: typer.Context, options: _CreateOptions) -> None:
94
+ app_context = get_app_context(ctx)
95
+ client_id: ClientId | None = ClientId(resolve_client(app_context, options.client)) if options.client else None
96
+ args = _CreateArgs(
97
+ name=options.name,
98
+ client_id=client_id,
99
+ is_public=options.public,
100
+ billable=options.billable,
101
+ color=options.color,
102
+ note=options.note,
103
+ )
104
+ payload = ProjectCreate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
105
+ project = app_context.workspace().projects.create(payload)
106
+ app_context.render(single(project, PROJECTS_LIST))
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class _UpdateArgs:
111
+ name: str | None
112
+ client_id: ClientId | None
113
+ is_public: bool | None
114
+ billable: bool | None
115
+ color: str | None
116
+ note: str | None
117
+ archived: bool | None
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class _UpdateOptions:
122
+ term: Annotated[str, typer.Argument(help="Project ID or exact name.")]
123
+ name: Annotated[str | None, typer.Option("--name", help="New project display name.")] = None
124
+ client: Annotated[str | None, typer.Option("--client", help="New client ID or exact name.")] = None
125
+ public: Annotated[bool | None, typer.Option("--public/--private", help="Toggle the project's visibility.")] = None
126
+ billable: Annotated[
127
+ bool | None,
128
+ typer.Option("--billable/--no-billable", help="Default billable flag for new entries."),
129
+ ] = None
130
+ color: Annotated[str | None, typer.Option("--color", help="Hex colour, e.g. #0f62fe.")] = None
131
+ note: Annotated[str | None, typer.Option("--note", help="New free-form note.")] = None
132
+ archived: Annotated[
133
+ bool | None,
134
+ typer.Option("--archived/--no-archived", help="Archive or restore the project."),
135
+ ] = None
136
+
137
+
138
+ @APP.command(help="Update an existing project.")
139
+ @handle_errors
140
+ @options_from(_UpdateOptions)
141
+ def update(ctx: typer.Context, options: _UpdateOptions) -> None:
142
+ app_context = get_app_context(ctx)
143
+ project_id = resolve_project(app_context, options.term)
144
+ client_id: ClientId | None = ClientId(resolve_client(app_context, options.client)) if options.client else None
145
+ args = _UpdateArgs(
146
+ name=options.name,
147
+ client_id=client_id,
148
+ is_public=options.public,
149
+ billable=options.billable,
150
+ color=options.color,
151
+ note=options.note,
152
+ archived=options.archived,
153
+ )
154
+ payload = ProjectUpdate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
155
+ project = app_context.workspace().projects.update(project_id, payload)
156
+ app_context.render(single(project, PROJECTS_LIST))
157
+
158
+
159
+ @APP.command(help="Delete a project.")
160
+ @handle_errors
161
+ def delete(
162
+ ctx: typer.Context,
163
+ term: Annotated[str, typer.Argument(help="Project ID or exact name.")],
164
+ *,
165
+ yes: Annotated[
166
+ bool,
167
+ typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
168
+ ] = False,
169
+ ) -> None:
170
+ app_context = get_app_context(ctx)
171
+ confirm(f"Delete project '{term}'", assume_yes=yes)
172
+ project_id = resolve_project(app_context, term)
173
+ app_context.workspace().projects.delete(project_id)
174
+ app_context.notify(f"Deleted project '{project_id}'.")