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,3 @@
1
+ from clockify_unofficial_cli._version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from clockify_unofficial_cli.main import run
2
+
3
+ run()
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("clockify-unofficial-cli")
File without changes
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+ from typing import Final
7
+ from typing import Literal
8
+ from typing import Protocol
9
+
10
+ _VISIBLE_SUFFIX: Final = 4
11
+
12
+
13
+ class CredentialSource(StrEnum):
14
+ ENVIRONMENT = "environment"
15
+ KEYRING = "keyring"
16
+ FILE = "file"
17
+
18
+
19
+ # Clockify only offers API keys (no OAuth2). The tagged encoding leaves room for another
20
+ # credential kind without migrating what is already stored.
21
+ @dataclass(frozen=True, slots=True)
22
+ class ApiKeyCredential:
23
+ api_key: str
24
+ kind: Literal["api_key"] = "api_key"
25
+
26
+ def masked(self) -> str:
27
+ if len(self.api_key) <= _VISIBLE_SUFFIX:
28
+ return "*" * len(self.api_key)
29
+ return "*" * 8 + self.api_key[-_VISIBLE_SUFFIX:]
30
+
31
+
32
+ type Credential = ApiKeyCredential # pylint: disable=gajaguar-module-const-naming
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class ResolvedCredential:
37
+ credential: Credential
38
+ source: CredentialSource
39
+
40
+
41
+ def encode(credential: Credential) -> str:
42
+ return json.dumps({"kind": credential.kind, "api_key": credential.api_key})
43
+
44
+
45
+ def decode(raw: str) -> Credential | None:
46
+ try:
47
+ payload: object = json.loads(raw)
48
+ except ValueError:
49
+ return None
50
+ if not isinstance(payload, dict) or payload.get("kind") != "api_key":
51
+ return None
52
+ api_key = payload.get("api_key")
53
+ if not isinstance(api_key, str) or not api_key:
54
+ return None
55
+ return ApiKeyCredential(api_key=api_key)
56
+
57
+
58
+ class CredentialStore(Protocol):
59
+ @property
60
+ def source(self) -> CredentialSource: ...
61
+
62
+ def get(self, profile: str) -> Credential | None: ...
63
+
64
+
65
+ class WritableCredentialStore(CredentialStore, Protocol):
66
+ def available(self) -> bool: ...
67
+
68
+ def set(self, profile: str, credential: Credential) -> None: ...
69
+
70
+ def delete(self, profile: str) -> bool: ...
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from clockify.config import API_KEY_ENV_VAR
6
+
7
+ from clockify_unofficial_cli.auth.credentials import ApiKeyCredential
8
+ from clockify_unofficial_cli.auth.credentials import CredentialSource
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+
14
+ # The environment key is profile-agnostic: it overrides whatever profile is selected, for CI.
15
+ class EnvCredentialStore:
16
+ def __init__(self, environ: Mapping[str, str]) -> None:
17
+ self._environ = environ
18
+
19
+ @property
20
+ def source(self) -> CredentialSource:
21
+ return CredentialSource.ENVIRONMENT
22
+
23
+ def get(self, profile: str) -> ApiKeyCredential | None:
24
+ del profile
25
+ api_key = self._environ.get(API_KEY_ENV_VAR)
26
+ return ApiKeyCredential(api_key=api_key) if api_key else None
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from clockify_unofficial_cli.auth.credentials import CredentialSource
6
+ from clockify_unofficial_cli.auth.credentials import decode
7
+ from clockify_unofficial_cli.auth.credentials import encode
8
+ from clockify_unofficial_cli.config.store import read_toml
9
+ from clockify_unofficial_cli.config.store import write_private_toml
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from clockify_unofficial_cli.auth.credentials import Credential
15
+
16
+
17
+ # Plaintext fallback for hosts without a keyring backend; only written on explicit opt-in.
18
+ class FileCredentialStore:
19
+ def __init__(self, path: Path) -> None:
20
+ self.path = path
21
+
22
+ @property
23
+ def source(self) -> CredentialSource:
24
+ return CredentialSource.FILE
25
+
26
+ @staticmethod
27
+ def available() -> bool:
28
+ return True
29
+
30
+ def get(self, profile: str) -> Credential | None:
31
+ raw = self._entries().get(profile)
32
+ return decode(raw) if isinstance(raw, str) else None
33
+
34
+ def set(self, profile: str, credential: Credential) -> None:
35
+ entries = self._entries()
36
+ entries[profile] = encode(credential)
37
+ write_private_toml(self.path, {"profiles": entries})
38
+
39
+ def delete(self, profile: str) -> bool:
40
+ entries = self._entries()
41
+ if profile not in entries:
42
+ return False
43
+ del entries[profile]
44
+ write_private_toml(self.path, {"profiles": entries})
45
+ return True
46
+
47
+ def _entries(self) -> dict[str, object]:
48
+ profiles = read_toml(self.path).get("profiles")
49
+ return dict(profiles) if isinstance(profiles, dict) else {}
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from typing import Final
5
+
6
+ import keyring
7
+ from keyring.backends.fail import Keyring as FailKeyring
8
+ from keyring.errors import KeyringError
9
+ from keyring.errors import PasswordDeleteError
10
+
11
+ from clockify_unofficial_cli.auth.credentials import CredentialSource
12
+ from clockify_unofficial_cli.auth.credentials import decode
13
+ from clockify_unofficial_cli.auth.credentials import encode
14
+ from clockify_unofficial_cli.runtime.errors import CliError
15
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
16
+
17
+ if TYPE_CHECKING:
18
+ from clockify_unofficial_cli.auth.credentials import Credential
19
+
20
+ SERVICE_NAME: Final = "clockify-cli"
21
+
22
+
23
+ class KeyringCredentialStore:
24
+ def __init__(self, service: str = SERVICE_NAME) -> None:
25
+ self._service = service
26
+
27
+ @property
28
+ def source(self) -> CredentialSource:
29
+ return CredentialSource.KEYRING
30
+
31
+ @staticmethod
32
+ def available() -> bool:
33
+ return not isinstance(keyring.get_keyring(), FailKeyring)
34
+
35
+ def get(self, profile: str) -> Credential | None:
36
+ if not self.available():
37
+ return None
38
+ try:
39
+ raw = keyring.get_password(self._service, profile)
40
+ except KeyringError:
41
+ return None
42
+ return decode(raw) if raw else None
43
+
44
+ def set(self, profile: str, credential: Credential) -> None:
45
+ try:
46
+ keyring.set_password(self._service, profile, encode(credential))
47
+ except KeyringError as error:
48
+ message = f"Could not write to the system keyring: {error}"
49
+ raise CliError(
50
+ message,
51
+ exit_code=ExitCode.CONFIGURATION,
52
+ hint="Retry with --insecure-storage to use a 0600 file instead.",
53
+ ) from error
54
+
55
+ def delete(self, profile: str) -> bool:
56
+ if not self.available():
57
+ return False
58
+ try:
59
+ keyring.delete_password(self._service, profile)
60
+ except PasswordDeleteError:
61
+ return False
62
+ return True
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from clockify_unofficial_cli.auth.credentials import ResolvedCredential
7
+
8
+ if TYPE_CHECKING:
9
+ from clockify_unofficial_cli.auth.credentials import CredentialStore
10
+ from clockify_unofficial_cli.auth.credentials import WritableCredentialStore
11
+ from clockify_unofficial_cli.auth.env_store import EnvCredentialStore
12
+ from clockify_unofficial_cli.auth.file_store import FileCredentialStore
13
+ from clockify_unofficial_cli.auth.keyring_store import KeyringCredentialStore
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class CredentialStores:
18
+ environment: EnvCredentialStore
19
+ keyring: KeyringCredentialStore
20
+ file: FileCredentialStore
21
+
22
+ # Precedence order: the first store holding a credential for the profile wins.
23
+ def chain(self) -> tuple[CredentialStore, ...]:
24
+ return (self.environment, self.keyring, self.file)
25
+
26
+ def writable(self) -> tuple[WritableCredentialStore, ...]:
27
+ return (self.keyring, self.file)
28
+
29
+ def resolve(self, profile: str) -> ResolvedCredential | None:
30
+ for store in self.chain():
31
+ credential = store.get(profile)
32
+ if credential is not None:
33
+ return ResolvedCredential(credential=credential, source=store.source)
34
+ return None
File without changes
@@ -0,0 +1,153 @@
1
+ import sys
2
+ from typing import TYPE_CHECKING
3
+ from typing import Annotated
4
+ from typing import Final
5
+
6
+ import typer
7
+ from clockify import Region
8
+
9
+ from clockify_unofficial_cli.auth.credentials import ApiKeyCredential
10
+ from clockify_unofficial_cli.config.settings import Profile
11
+ from clockify_unofficial_cli.output.columns import AUTH_STATUS
12
+ from clockify_unofficial_cli.output.renderer import single
13
+ from clockify_unofficial_cli.runtime.client_factory import ClientRequest
14
+ from clockify_unofficial_cli.runtime.context import get_app_context
15
+ from clockify_unofficial_cli.runtime.errors import CliError
16
+ from clockify_unofficial_cli.runtime.errors import handle_errors
17
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
18
+
19
+ if TYPE_CHECKING:
20
+ from clockify import User
21
+
22
+ from clockify_unofficial_cli.auth.credentials import WritableCredentialStore
23
+ from clockify_unofficial_cli.auth.resolver import CredentialStores
24
+ from clockify_unofficial_cli.runtime.context import AppContext
25
+
26
+ APP: Final = typer.Typer(help="Log in, inspect, and remove Clockify credentials.", no_args_is_help=True)
27
+
28
+
29
+ def _read_api_key(*, with_token: bool) -> str:
30
+ # No --api-key flag on purpose: a key passed as an argument ends up in shell history.
31
+ raw = sys.stdin.read() if with_token else typer.prompt("Clockify API key", hide_input=True, err=True)
32
+ api_key = str(raw).strip()
33
+ if not api_key:
34
+ message = "The API key is empty."
35
+ raise CliError(message, exit_code=ExitCode.USAGE)
36
+ return api_key
37
+
38
+
39
+ def _select_store(stores: CredentialStores, *, insecure_storage: bool) -> WritableCredentialStore:
40
+ if insecure_storage:
41
+ return stores.file
42
+ if stores.keyring.available():
43
+ return stores.keyring
44
+ message = "No system keyring backend is available."
45
+ raise CliError(
46
+ message,
47
+ exit_code=ExitCode.CONFIGURATION,
48
+ hint="Retry with --insecure-storage, or set CLOCKIFY_API_KEY in the environment.",
49
+ )
50
+
51
+
52
+ def _save_profile(app_context: AppContext, user: User, region: Region) -> Profile:
53
+ options = app_context.options
54
+ store = app_context.services.settings
55
+ workspace_id = options.workspace_id or user.active_workspace or user.default_workspace
56
+ profile = Profile(region=region, workspace_id=workspace_id, user_id=user.id, email=user.email)
57
+ settings = store.load()
58
+ if not settings.profiles:
59
+ settings = settings.model_copy(update={"default_profile": options.profile_name})
60
+ store.save(settings.with_profile(options.profile_name, profile))
61
+ return profile
62
+
63
+
64
+ @APP.command(help="Validate an API key and store it for the selected profile.")
65
+ @handle_errors
66
+ def login(
67
+ ctx: typer.Context,
68
+ *,
69
+ region: Annotated[
70
+ Region | None,
71
+ typer.Option("--region", help="Clockify data region; defaults to the profile's, else GLOBAL."),
72
+ ] = None,
73
+ with_token: Annotated[bool, typer.Option("--with-token", help="Read the API key from standard input.")] = False,
74
+ insecure_storage: Annotated[
75
+ bool,
76
+ typer.Option("--insecure-storage", help="Store the key in a 0600 plaintext file instead of the keyring."),
77
+ ] = False,
78
+ ) -> None:
79
+ app_context = get_app_context(ctx)
80
+ stores = app_context.services.credentials
81
+ profile_name = app_context.options.profile_name
82
+ credential = ApiKeyCredential(api_key=_read_api_key(with_token=with_token))
83
+ resolved_region = region or app_context.options.profile.region
84
+
85
+ with app_context.services.clients(
86
+ ClientRequest(credential=credential, region=resolved_region, verbose=app_context.options.verbose)
87
+ ) as client:
88
+ user = client.user.me()
89
+
90
+ store = _select_store(stores, insecure_storage=insecure_storage)
91
+ store.set(profile_name, credential)
92
+ for other in stores.writable():
93
+ if other is not store:
94
+ other.delete(profile_name)
95
+ profile = _save_profile(app_context, user, resolved_region)
96
+
97
+ app_context.notify(f"Logged in to profile '{profile_name}' as {user.email} ({store.source}).")
98
+ record = {
99
+ "profile": profile_name,
100
+ "name": user.name,
101
+ "email": user.email,
102
+ "workspaceId": profile.workspace_id,
103
+ "region": profile.region,
104
+ "source": store.source,
105
+ "apiKey": credential.masked(),
106
+ }
107
+ app_context.render(single(record, AUTH_STATUS))
108
+
109
+
110
+ @APP.command(help="Show the active credential and the user it belongs to.")
111
+ @handle_errors
112
+ def status(ctx: typer.Context) -> None:
113
+ app_context = get_app_context(ctx)
114
+ resolved = app_context.credential()
115
+ user = app_context.client().user.me()
116
+ record = {
117
+ "profile": app_context.options.profile_name,
118
+ "name": user.name,
119
+ "email": user.email,
120
+ "workspaceId": app_context.options.workspace_id or user.active_workspace,
121
+ "region": app_context.options.profile.region,
122
+ "source": resolved.source,
123
+ "apiKey": resolved.credential.masked(),
124
+ }
125
+ app_context.render(single(record, AUTH_STATUS))
126
+
127
+
128
+ @APP.command(help="Remove the stored credential and profile.")
129
+ @handle_errors
130
+ def logout(ctx: typer.Context) -> None:
131
+ app_context = get_app_context(ctx)
132
+ services = app_context.services
133
+ profile_name = app_context.options.profile_name
134
+
135
+ removed = [store.source for store in services.credentials.writable() if store.delete(profile_name)]
136
+ settings = services.settings.load()
137
+ had_profile = profile_name in settings.profiles
138
+ if had_profile:
139
+ services.settings.save(settings.without_profile(profile_name))
140
+ if not removed and not had_profile:
141
+ message = f"No stored credentials for profile '{profile_name}'."
142
+ raise CliError(message, exit_code=ExitCode.CONFIGURATION)
143
+
144
+ app_context.notify(f"Logged out of profile '{profile_name}'.")
145
+ if services.credentials.environment.get(profile_name) is not None:
146
+ app_context.notify("CLOCKIFY_API_KEY is still set in the environment and will keep being used.")
147
+
148
+
149
+ @APP.command(help="Print the raw API key of the active credential.")
150
+ @handle_errors
151
+ def token(ctx: typer.Context) -> None:
152
+ resolved = get_app_context(ctx).credential()
153
+ typer.echo(resolved.credential.api_key)
@@ -0,0 +1,116 @@
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 ClientCreate
8
+ from clockify import ClientUpdate
9
+
10
+ from clockify_unofficial_cli.output.columns import CLIENTS
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_client
19
+
20
+ APP: Final = typer.Typer(help="Manage workspace clients.", no_args_is_help=True)
21
+
22
+
23
+ @APP.command(name="list", help="List every client in the active workspace.")
24
+ @handle_errors
25
+ def list_clients(
26
+ ctx: typer.Context,
27
+ *,
28
+ limit: Annotated[
29
+ int | None,
30
+ typer.Option("--limit", help="Stop after N clients; 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
+ clients = list_from_options(workspace.clients.list, workspace.clients.list_page, limit, page, page_size)
44
+ app_context.render(many(clients, CLIENTS))
45
+
46
+
47
+ @APP.command(help="Show a client by ID or exact name.")
48
+ @handle_errors
49
+ def get(ctx: typer.Context, term: Annotated[str, typer.Argument(help="Client ID or exact name.")]) -> None:
50
+ app_context = get_app_context(ctx)
51
+ client_id = resolve_client(app_context, term)
52
+ client = app_context.workspace().clients.get(client_id)
53
+ app_context.render(single(client, CLIENTS))
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class _CreateArgs:
58
+ name: Annotated[str, typer.Option("--name", help="Client display name.")]
59
+ email: Annotated[str | None, typer.Option("--email", help="Contact email.")] = None
60
+ address: Annotated[str | None, typer.Option("--address", help="Postal address.")] = None
61
+ note: Annotated[str | None, typer.Option("--note", help="Free-form note.")] = None
62
+
63
+
64
+ @APP.command(help="Create a new client.")
65
+ @handle_errors
66
+ @options_from(_CreateArgs)
67
+ def create(ctx: typer.Context, args: _CreateArgs) -> None:
68
+ app_context = get_app_context(ctx)
69
+ payload = ClientCreate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
70
+ client = app_context.workspace().clients.create(payload)
71
+ app_context.render(single(client, CLIENTS))
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class _UpdateArgs:
76
+ term: Annotated[str, typer.Argument(help="Client ID or exact name.")]
77
+ name: Annotated[str | None, typer.Option("--name", help="New client display name.")] = None
78
+ email: Annotated[str | None, typer.Option("--email", help="New contact email.")] = None
79
+ address: Annotated[str | None, typer.Option("--address", help="New postal address.")] = None
80
+ note: Annotated[str | None, typer.Option("--note", help="New free-form note.")] = None
81
+ archived: Annotated[
82
+ bool | None,
83
+ typer.Option(
84
+ "--archived/--no-archived",
85
+ help="Archive or restore the client; omit the flag to leave the field untouched.",
86
+ ),
87
+ ] = None
88
+
89
+
90
+ @APP.command(help="Update an existing client.")
91
+ @handle_errors
92
+ @options_from(_UpdateArgs)
93
+ def update(ctx: typer.Context, args: _UpdateArgs) -> None:
94
+ app_context = get_app_context(ctx)
95
+ client_id = resolve_client(app_context, args.term)
96
+ changes = {k: v for k, v in dataclasses.asdict(args).items() if v is not None and k != "term"}
97
+ client = app_context.workspace().clients.update(client_id, ClientUpdate(**changes))
98
+ app_context.render(single(client, CLIENTS))
99
+
100
+
101
+ @APP.command(help="Delete a client.")
102
+ @handle_errors
103
+ def delete(
104
+ ctx: typer.Context,
105
+ term: Annotated[str, typer.Argument(help="Client ID or exact name.")],
106
+ *,
107
+ yes: Annotated[
108
+ bool,
109
+ typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
110
+ ] = False,
111
+ ) -> None:
112
+ app_context = get_app_context(ctx)
113
+ confirm(f"Delete client '{term}'", assume_yes=yes)
114
+ client_id = resolve_client(app_context, term)
115
+ app_context.workspace().clients.delete(client_id)
116
+ app_context.notify(f"Deleted client '{client_id}'.")
@@ -0,0 +1,50 @@
1
+ from typing import Annotated
2
+ from typing import Final
3
+
4
+ import typer
5
+
6
+ from clockify_unofficial_cli.output.columns import PROFILES
7
+ from clockify_unofficial_cli.output.renderer import Dataset
8
+ from clockify_unofficial_cli.runtime.context import get_app_context
9
+ from clockify_unofficial_cli.runtime.errors import CliError
10
+ from clockify_unofficial_cli.runtime.errors import handle_errors
11
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
12
+
13
+ APP: Final = typer.Typer(help="Inspect and edit local CLI configuration.", no_args_is_help=True)
14
+
15
+
16
+ @APP.command(help="Print the settings file location.")
17
+ @handle_errors
18
+ def path(ctx: typer.Context) -> None:
19
+ typer.echo(str(get_app_context(ctx).services.settings.path))
20
+
21
+
22
+ @APP.command(name="list", help="List configured profiles.")
23
+ @handle_errors
24
+ def list_profiles(ctx: typer.Context) -> None:
25
+ app_context = get_app_context(ctx)
26
+ settings = app_context.services.settings.load()
27
+ records = [
28
+ {
29
+ "name": name,
30
+ "default": name == settings.default_profile,
31
+ "region": profile.region,
32
+ "workspaceId": profile.workspace_id,
33
+ "email": profile.email,
34
+ }
35
+ for name, profile in sorted(settings.profiles.items())
36
+ ]
37
+ app_context.render(Dataset(records=records, columns=PROFILES))
38
+
39
+
40
+ @APP.command(help="Set the default profile.")
41
+ @handle_errors
42
+ def use(ctx: typer.Context, name: Annotated[str, typer.Argument(help="Name of an existing profile.")]) -> None:
43
+ app_context = get_app_context(ctx)
44
+ store = app_context.services.settings
45
+ settings = store.load()
46
+ if name not in settings.profiles:
47
+ message = f"Unknown profile '{name}'."
48
+ raise CliError(message, exit_code=ExitCode.CONFIGURATION, hint="Run `clockify config list`.")
49
+ store.save(settings.model_copy(update={"default_profile": name}))
50
+ app_context.notify(f"Default profile set to '{name}'.")