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.
- clockify_unofficial_cli/__init__.py +3 -0
- clockify_unofficial_cli/__main__.py +3 -0
- clockify_unofficial_cli/_version.py +3 -0
- clockify_unofficial_cli/auth/__init__.py +0 -0
- clockify_unofficial_cli/auth/credentials.py +70 -0
- clockify_unofficial_cli/auth/env_store.py +26 -0
- clockify_unofficial_cli/auth/file_store.py +49 -0
- clockify_unofficial_cli/auth/keyring_store.py +62 -0
- clockify_unofficial_cli/auth/resolver.py +34 -0
- clockify_unofficial_cli/commands/__init__.py +0 -0
- clockify_unofficial_cli/commands/auth.py +153 -0
- clockify_unofficial_cli/commands/client.py +116 -0
- clockify_unofficial_cli/commands/config.py +50 -0
- clockify_unofficial_cli/commands/custom_field.py +130 -0
- clockify_unofficial_cli/commands/entry.py +275 -0
- clockify_unofficial_cli/commands/group.py +89 -0
- clockify_unofficial_cli/commands/project.py +174 -0
- clockify_unofficial_cli/commands/tag.py +107 -0
- clockify_unofficial_cli/commands/task.py +194 -0
- clockify_unofficial_cli/commands/timer_shortcuts.py +130 -0
- clockify_unofficial_cli/commands/user.py +45 -0
- clockify_unofficial_cli/commands/workspace.py +68 -0
- clockify_unofficial_cli/config/__init__.py +0 -0
- clockify_unofficial_cli/config/paths.py +25 -0
- clockify_unofficial_cli/config/settings.py +75 -0
- clockify_unofficial_cli/config/store.py +56 -0
- clockify_unofficial_cli/main.py +131 -0
- clockify_unofficial_cli/output/__init__.py +0 -0
- clockify_unofficial_cli/output/columns.py +88 -0
- clockify_unofficial_cli/output/formats.py +82 -0
- clockify_unofficial_cli/output/registry.py +29 -0
- clockify_unofficial_cli/output/renderer.py +68 -0
- clockify_unofficial_cli/runtime/__init__.py +0 -0
- clockify_unofficial_cli/runtime/client_factory.py +42 -0
- clockify_unofficial_cli/runtime/context.py +99 -0
- clockify_unofficial_cli/runtime/errors.py +89 -0
- clockify_unofficial_cli/runtime/exit_codes.py +15 -0
- clockify_unofficial_cli/runtime/params.py +39 -0
- clockify_unofficial_cli/runtime/prompts.py +24 -0
- clockify_unofficial_cli/services/__init__.py +0 -0
- clockify_unofficial_cli/services/listing.py +58 -0
- clockify_unofficial_cli/services/parsing.py +118 -0
- clockify_unofficial_cli/services/resolve.py +128 -0
- clockify_unofficial_cli/services/timer.py +126 -0
- clockify_unofficial_cli-0.2.0.dist-info/METADATA +278 -0
- clockify_unofficial_cli-0.2.0.dist-info/RECORD +49 -0
- clockify_unofficial_cli-0.2.0.dist-info/WHEEL +4 -0
- clockify_unofficial_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|