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
|
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}'.")
|