kaiten-cli 0.3.3__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.
- kaiten_cli/__init__.py +5 -0
- kaiten_cli/__main__.py +9 -0
- kaiten_cli/agent_gateway.py +577 -0
- kaiten_cli/app.py +1636 -0
- kaiten_cli/completion.py +570 -0
- kaiten_cli/discovery.py +39 -0
- kaiten_cli/errors.py +79 -0
- kaiten_cli/i18n.py +92 -0
- kaiten_cli/locales/ru.json +2967 -0
- kaiten_cli/localized_click.py +160 -0
- kaiten_cli/models.py +202 -0
- kaiten_cli/profiles.py +316 -0
- kaiten_cli/registry/__init__.py +260 -0
- kaiten_cli/registry/addons.py +392 -0
- kaiten_cli/registry/audit_and_analytics.py +451 -0
- kaiten_cli/registry/automation_schema.py +44 -0
- kaiten_cli/registry/automation_schemas.json +7417 -0
- kaiten_cli/registry/automations.py +342 -0
- kaiten_cli/registry/base.py +62 -0
- kaiten_cli/registry/blockers.py +320 -0
- kaiten_cli/registry/boards.py +520 -0
- kaiten_cli/registry/card_relations.py +376 -0
- kaiten_cli/registry/card_types.py +363 -0
- kaiten_cli/registry/cards.py +1329 -0
- kaiten_cli/registry/charts.py +539 -0
- kaiten_cli/registry/checklists.py +600 -0
- kaiten_cli/registry/columns.py +558 -0
- kaiten_cli/registry/comments.py +224 -0
- kaiten_cli/registry/custom_directories.py +980 -0
- kaiten_cli/registry/custom_properties.py +1460 -0
- kaiten_cli/registry/dashboards.py +564 -0
- kaiten_cli/registry/documents.py +761 -0
- kaiten_cli/registry/external_links.py +128 -0
- kaiten_cli/registry/files.py +738 -0
- kaiten_cli/registry/github_addon.py +613 -0
- kaiten_cli/registry/iterations.py +397 -0
- kaiten_cli/registry/lanes.py +205 -0
- kaiten_cli/registry/live_contracts.py +361 -0
- kaiten_cli/registry/members.py +336 -0
- kaiten_cli/registry/module_docs.py +108 -0
- kaiten_cli/registry/projects.py +369 -0
- kaiten_cli/registry/query.py +129 -0
- kaiten_cli/registry/restrictions.py +249 -0
- kaiten_cli/registry/roles_and_groups.py +1102 -0
- kaiten_cli/registry/scim.py +263 -0
- kaiten_cli/registry/service_desk.py +1270 -0
- kaiten_cli/registry/snapshot.py +187 -0
- kaiten_cli/registry/spaces.py +272 -0
- kaiten_cli/registry/subscribers.py +168 -0
- kaiten_cli/registry/tags.py +193 -0
- kaiten_cli/registry/time_logs.py +353 -0
- kaiten_cli/registry/tree.py +352 -0
- kaiten_cli/registry/utilities.py +287 -0
- kaiten_cli/registry/webhooks.py +265 -0
- kaiten_cli/runtime/__init__.py +1 -0
- kaiten_cli/runtime/automation_validation.py +115 -0
- kaiten_cli/runtime/behaviors.py +787 -0
- kaiten_cli/runtime/cache.py +733 -0
- kaiten_cli/runtime/client.py +387 -0
- kaiten_cli/runtime/endpoints.py +59 -0
- kaiten_cli/runtime/executor.py +269 -0
- kaiten_cli/runtime/fs_security.py +168 -0
- kaiten_cli/runtime/input.py +310 -0
- kaiten_cli/runtime/output.py +59 -0
- kaiten_cli/runtime/public_validation.py +35 -0
- kaiten_cli/runtime/snapshot_build.py +527 -0
- kaiten_cli/runtime/snapshot_common.py +363 -0
- kaiten_cli/runtime/snapshot_query.py +306 -0
- kaiten_cli/runtime/snapshot_store.py +1192 -0
- kaiten_cli/runtime/snapshots.py +62 -0
- kaiten_cli/runtime/sqlite_errors.py +23 -0
- kaiten_cli/runtime/support/__init__.py +1 -0
- kaiten_cli/runtime/support/addons.py +953 -0
- kaiten_cli/runtime/support/audit.py +70 -0
- kaiten_cli/runtime/support/batch.py +234 -0
- kaiten_cli/runtime/support/card_move_url.py +213 -0
- kaiten_cli/runtime/support/cards.py +174 -0
- kaiten_cli/runtime/support/checklists.py +21 -0
- kaiten_cli/runtime/support/company_users.py +117 -0
- kaiten_cli/runtime/support/dashboards.py +35 -0
- kaiten_cli/runtime/support/documents.py +175 -0
- kaiten_cli/runtime/support/files.py +561 -0
- kaiten_cli/runtime/support/markdown_export.py +525 -0
- kaiten_cli/runtime/support/pagination.py +167 -0
- kaiten_cli/runtime/support/projects.py +66 -0
- kaiten_cli/runtime/support/relations.py +86 -0
- kaiten_cli/runtime/support/restrictions.py +62 -0
- kaiten_cli/runtime/support/spaces.py +20 -0
- kaiten_cli/runtime/support/time_logs.py +61 -0
- kaiten_cli/runtime/support/tree.py +140 -0
- kaiten_cli/runtime/support/tree_sharing.py +447 -0
- kaiten_cli/runtime/trace.py +500 -0
- kaiten_cli/runtime/transforms.py +128 -0
- kaiten_cli/schema_docs.py +22 -0
- kaiten_cli/update_check.py +519 -0
- kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
- kaiten_cli-0.3.3.dist-info/RECORD +99 -0
- kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
- kaiten_cli-0.3.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Click integration: late help translation and context-local message dispatch.
|
|
2
|
+
|
|
3
|
+
Click imports gettext functions into its modules. Bind those functions once to
|
|
4
|
+
our context-local catalog; never install or switch a process-wide gettext locale.
|
|
5
|
+
The messages are translated before Click substitutes user-supplied values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import click._termui_impl
|
|
16
|
+
import click.core
|
|
17
|
+
import click.decorators
|
|
18
|
+
import click.exceptions
|
|
19
|
+
import click.formatting
|
|
20
|
+
import click.parser
|
|
21
|
+
import click.shell_completion
|
|
22
|
+
import click.termui
|
|
23
|
+
import click.types
|
|
24
|
+
import click.utils
|
|
25
|
+
|
|
26
|
+
from kaiten_cli.i18n import LOCALE_ENV, LOCALES, tr, use_locale
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ngettext(singular: str, plural: str, n: int) -> str:
|
|
30
|
+
# Catalog phrases use count-neutral Russian wording (e.g. "Arguments: {n}").
|
|
31
|
+
return tr(singular if n == 1 else plural)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
CLICK_MODULES = (
|
|
35
|
+
click.core,
|
|
36
|
+
click.decorators,
|
|
37
|
+
click.exceptions,
|
|
38
|
+
click.formatting,
|
|
39
|
+
click.termui,
|
|
40
|
+
click.types,
|
|
41
|
+
click.utils,
|
|
42
|
+
click.parser,
|
|
43
|
+
click.shell_completion,
|
|
44
|
+
click._termui_impl,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
for _module in CLICK_MODULES:
|
|
48
|
+
if hasattr(_module, "_"):
|
|
49
|
+
_module._ = tr
|
|
50
|
+
if hasattr(_module, "ngettext"):
|
|
51
|
+
_module.ngettext = _ngettext
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_locale(args: list[str]) -> str:
|
|
55
|
+
"""Inspect root options only; command payloads must never select a locale."""
|
|
56
|
+
selected = os.environ.get(LOCALE_ENV, "en")
|
|
57
|
+
value_options = {
|
|
58
|
+
"--locale",
|
|
59
|
+
"--profile",
|
|
60
|
+
"--from-file",
|
|
61
|
+
"--cache-mode",
|
|
62
|
+
"--cache-ttl-seconds",
|
|
63
|
+
"--trace-file",
|
|
64
|
+
}
|
|
65
|
+
index = 0
|
|
66
|
+
while index < len(args):
|
|
67
|
+
arg = args[index]
|
|
68
|
+
if arg == "--" or not arg.startswith("-"):
|
|
69
|
+
break
|
|
70
|
+
name, separator, value = arg.partition("=")
|
|
71
|
+
if name in value_options:
|
|
72
|
+
if not separator:
|
|
73
|
+
index += 1
|
|
74
|
+
if index >= len(args):
|
|
75
|
+
if name == "--locale":
|
|
76
|
+
raise click.BadParameter("Expected ru or en.", param_hint="--locale")
|
|
77
|
+
break
|
|
78
|
+
value = args[index]
|
|
79
|
+
if name == "--locale":
|
|
80
|
+
selected = value
|
|
81
|
+
index += 1
|
|
82
|
+
if selected not in LOCALES:
|
|
83
|
+
raise click.BadParameter(
|
|
84
|
+
f"Unsupported locale: {selected}. Expected ru or en.", param_hint="--locale"
|
|
85
|
+
)
|
|
86
|
+
return selected
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _translated_command(command: click.Command, *, help_text: str | None = None) -> click.Command:
|
|
90
|
+
translated = copy.copy(command)
|
|
91
|
+
translated.help = (
|
|
92
|
+
help_text if help_text is not None else (tr(command.help) if command.help else command.help)
|
|
93
|
+
)
|
|
94
|
+
translated.epilog = tr(command.epilog) if command.epilog else command.epilog
|
|
95
|
+
translated.params = [copy.copy(param) for param in command.params]
|
|
96
|
+
for parameter in translated.params:
|
|
97
|
+
if isinstance(parameter, click.Option) and parameter.help:
|
|
98
|
+
parameter.help = tr(parameter.help)
|
|
99
|
+
return translated
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LocalizedCommand(click.Command):
|
|
103
|
+
def get_help_option(self, ctx: click.Context) -> click.Option | None:
|
|
104
|
+
# Click caches this auto-created option. Keep its source text canonical
|
|
105
|
+
# even when the first invocation uses Russian.
|
|
106
|
+
with use_locale("en"):
|
|
107
|
+
option = super().get_help_option(ctx)
|
|
108
|
+
if option is None:
|
|
109
|
+
return None
|
|
110
|
+
localized = copy.copy(option)
|
|
111
|
+
localized.help = tr(option.help) if option.help else option.help
|
|
112
|
+
return localized
|
|
113
|
+
|
|
114
|
+
def shell_complete(self, ctx, incomplete):
|
|
115
|
+
results = super().shell_complete(ctx, incomplete)
|
|
116
|
+
for result in results:
|
|
117
|
+
if result.help:
|
|
118
|
+
result.help = tr(result.help)
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
122
|
+
click.Command.format_help(_translated_command(self), ctx, formatter)
|
|
123
|
+
|
|
124
|
+
def get_short_help_str(self, limit: int = 45) -> str:
|
|
125
|
+
command = _translated_command(self)
|
|
126
|
+
if self.short_help:
|
|
127
|
+
command.short_help = tr(self.short_help)
|
|
128
|
+
return click.Command.get_short_help_str(command, limit)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class LocalizedGroup(click.Group, LocalizedCommand):
|
|
132
|
+
command_class = LocalizedCommand
|
|
133
|
+
group_class = type
|
|
134
|
+
|
|
135
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
136
|
+
help_text = self.help_factory() if hasattr(self, "help_factory") else None
|
|
137
|
+
click.Group.format_help(_translated_command(self, help_text=help_text), ctx, formatter)
|
|
138
|
+
|
|
139
|
+
def get_short_help_str(self, limit: int = 45) -> str:
|
|
140
|
+
if hasattr(self, "help_factory"):
|
|
141
|
+
command = _translated_command(self)
|
|
142
|
+
command.short_help = self.help_factory().split("\n\n", 1)[0]
|
|
143
|
+
return click.Command.get_short_help_str(command, limit)
|
|
144
|
+
return super().get_short_help_str(limit)
|
|
145
|
+
|
|
146
|
+
def main(self, args: Any = None, **kwargs: Any) -> Any:
|
|
147
|
+
import sys
|
|
148
|
+
|
|
149
|
+
argv = list(sys.argv[1:] if args is None else args)
|
|
150
|
+
# Context remains active while Click handles standalone errors as well.
|
|
151
|
+
try:
|
|
152
|
+
locale = resolve_locale(argv)
|
|
153
|
+
except click.ClickException as error:
|
|
154
|
+
if not kwargs.get("standalone_mode", True):
|
|
155
|
+
raise
|
|
156
|
+
with use_locale("en"):
|
|
157
|
+
error.show()
|
|
158
|
+
raise SystemExit(error.exit_code)
|
|
159
|
+
with use_locale(locale):
|
|
160
|
+
return super().main(args=argv, **kwargs)
|
kaiten_cli/models.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Shared data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Unset:
|
|
11
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
12
|
+
return "UNSET"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
UNSET = _Unset()
|
|
16
|
+
|
|
17
|
+
CACHE_POLICY_NONE = "none"
|
|
18
|
+
CACHE_POLICY_REQUEST_SCOPE = "request_scope"
|
|
19
|
+
CACHE_POLICY_PERSISTENT_OPT_IN = "persistent_opt_in"
|
|
20
|
+
CACHE_POLICY_PERSISTENT_HEAVY = "persistent_heavy"
|
|
21
|
+
PERSISTENT_CACHE_POLICIES = frozenset(
|
|
22
|
+
{CACHE_POLICY_PERSISTENT_OPT_IN, CACHE_POLICY_PERSISTENT_HEAVY}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
CACHE_MODE_AUTO = "auto"
|
|
26
|
+
CACHE_MODE_OFF = "off"
|
|
27
|
+
CACHE_MODE_READWRITE = "readwrite"
|
|
28
|
+
CACHE_MODE_REFRESH = "refresh"
|
|
29
|
+
|
|
30
|
+
PERSISTENT_CACHE_DISCOVERY_ALLOWLIST = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"spaces.list",
|
|
33
|
+
"boards.list",
|
|
34
|
+
"columns.list",
|
|
35
|
+
"lanes.list",
|
|
36
|
+
"card-types.list",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True, frozen=True)
|
|
42
|
+
class ExampleSpec:
|
|
43
|
+
command: str
|
|
44
|
+
description: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True, frozen=True)
|
|
48
|
+
class OperationSpec:
|
|
49
|
+
method: str
|
|
50
|
+
path_template: str
|
|
51
|
+
path_fields: tuple[str, ...] = ()
|
|
52
|
+
query_fields: tuple[str, ...] = ()
|
|
53
|
+
body_fields: tuple[str, ...] = ()
|
|
54
|
+
api_base_path: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True, frozen=True)
|
|
58
|
+
class ResponsePolicy:
|
|
59
|
+
compact_supported: bool = False
|
|
60
|
+
fields_supported: bool = False
|
|
61
|
+
default_limit: int | None = None
|
|
62
|
+
heavy: bool = False
|
|
63
|
+
result_kind: str = "entity"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
DebugReporter = Callable[[str], None]
|
|
67
|
+
RequestShape = tuple[str, dict[str, Any] | None, dict[str, Any] | None]
|
|
68
|
+
RequestShaper = Callable[
|
|
69
|
+
["ToolSpec", dict[str, Any], str, dict[str, Any] | None, dict[str, Any] | None],
|
|
70
|
+
RequestShape,
|
|
71
|
+
]
|
|
72
|
+
PayloadValidator = Callable[["ToolSpec", dict[str, Any]], None]
|
|
73
|
+
CustomExecutor = Callable[
|
|
74
|
+
[
|
|
75
|
+
Any,
|
|
76
|
+
"ToolSpec",
|
|
77
|
+
dict[str, Any],
|
|
78
|
+
str,
|
|
79
|
+
dict[str, Any] | None,
|
|
80
|
+
dict[str, Any] | None,
|
|
81
|
+
float,
|
|
82
|
+
DebugReporter | None,
|
|
83
|
+
],
|
|
84
|
+
Awaitable[Any],
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True, frozen=True)
|
|
89
|
+
class RuntimeBehavior:
|
|
90
|
+
execution_mode: str = "direct_http"
|
|
91
|
+
request_shaper: RequestShaper | None = None
|
|
92
|
+
payload_validator: PayloadValidator | None = None
|
|
93
|
+
custom_executor: CustomExecutor | None = None
|
|
94
|
+
compact_default: bool | None = None
|
|
95
|
+
cache_policy: str | None = None
|
|
96
|
+
requires_profile: bool = True
|
|
97
|
+
enforce_mutation_guard: bool = True
|
|
98
|
+
remote_side_effects: bool | None = None
|
|
99
|
+
apply_common_transforms: bool = True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True, frozen=True)
|
|
103
|
+
class ToolSpec:
|
|
104
|
+
canonical_name: str
|
|
105
|
+
mcp_alias: str
|
|
106
|
+
namespace: str
|
|
107
|
+
action: str
|
|
108
|
+
description: str
|
|
109
|
+
input_schema: dict[str, Any]
|
|
110
|
+
operation: OperationSpec
|
|
111
|
+
response_policy: ResponsePolicy = field(default_factory=ResponsePolicy)
|
|
112
|
+
runtime_behavior: RuntimeBehavior = field(default_factory=RuntimeBehavior)
|
|
113
|
+
examples: tuple[ExampleSpec, ...] = ()
|
|
114
|
+
usage_notes: tuple[str, ...] = ()
|
|
115
|
+
search_terms: tuple[str, ...] = ()
|
|
116
|
+
bulk_alternative: str | None = None
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def namespace_segments(self) -> tuple[str, ...]:
|
|
120
|
+
if not self.namespace:
|
|
121
|
+
return ()
|
|
122
|
+
return tuple(segment for segment in self.namespace.split(".") if segment)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def command_segments(self) -> tuple[str, ...]:
|
|
126
|
+
return self.namespace_segments + (self.action,)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def execution_mode(self) -> str:
|
|
130
|
+
return self.runtime_behavior.execution_mode
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def cache_policy(self) -> str:
|
|
134
|
+
if self.is_mutation:
|
|
135
|
+
return CACHE_POLICY_NONE
|
|
136
|
+
if self.runtime_behavior.cache_policy is not None:
|
|
137
|
+
return self.runtime_behavior.cache_policy
|
|
138
|
+
if self.operation.method.upper() == "GET" and self.response_policy.heavy:
|
|
139
|
+
return CACHE_POLICY_PERSISTENT_HEAVY
|
|
140
|
+
if (
|
|
141
|
+
self.operation.method.upper() == "GET"
|
|
142
|
+
and self.execution_mode == "direct_http"
|
|
143
|
+
and self.response_policy.result_kind == "entity"
|
|
144
|
+
and self.action == "get"
|
|
145
|
+
):
|
|
146
|
+
return CACHE_POLICY_PERSISTENT_OPT_IN
|
|
147
|
+
if self.canonical_name in PERSISTENT_CACHE_DISCOVERY_ALLOWLIST:
|
|
148
|
+
return CACHE_POLICY_PERSISTENT_OPT_IN
|
|
149
|
+
if self.operation.method.upper() == "GET":
|
|
150
|
+
return CACHE_POLICY_REQUEST_SCOPE
|
|
151
|
+
return CACHE_POLICY_NONE
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_mutation(self) -> bool:
|
|
155
|
+
return self.operation.method.upper() in {"POST", "PUT", "PATCH", "DELETE"}
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def read_only_allowed(self) -> bool:
|
|
159
|
+
return not (self.is_mutation and self.runtime_behavior.enforce_mutation_guard)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def remote_side_effects(self) -> bool:
|
|
163
|
+
if self.runtime_behavior.remote_side_effects is not None:
|
|
164
|
+
return self.runtime_behavior.remote_side_effects
|
|
165
|
+
return self.is_mutation
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(slots=True)
|
|
169
|
+
class GlobalOptions:
|
|
170
|
+
locale: str = "en"
|
|
171
|
+
json_mode: bool = False
|
|
172
|
+
profile_name: str | None = None
|
|
173
|
+
from_file: str | None = None
|
|
174
|
+
stdin_json: bool = False
|
|
175
|
+
verbose: bool = False
|
|
176
|
+
no_color: bool = False
|
|
177
|
+
cache_mode: str | None = None
|
|
178
|
+
cache_ttl_seconds: int | None = None
|
|
179
|
+
trace_file: str | None = None
|
|
180
|
+
read_only: bool = False
|
|
181
|
+
update_check: bool = True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(slots=True)
|
|
185
|
+
class ResolvedProfile:
|
|
186
|
+
name: str | None
|
|
187
|
+
domain: str
|
|
188
|
+
token: str
|
|
189
|
+
source: str = "unknown"
|
|
190
|
+
cache_mode: str = CACHE_MODE_AUTO
|
|
191
|
+
cache_ttl_seconds: int = 60
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_schema_type(schema: dict[str, Any]) -> str:
|
|
195
|
+
schema_type = schema.get("type", "unknown")
|
|
196
|
+
if isinstance(schema_type, list):
|
|
197
|
+
return "|".join(str(item) for item in schema_type)
|
|
198
|
+
return str(schema_type)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def example_commands(examples: Sequence[ExampleSpec]) -> list[str]:
|
|
202
|
+
return [example.command for example in examples]
|
kaiten_cli/profiles.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Profile storage and resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from platformdirs import user_config_path
|
|
13
|
+
|
|
14
|
+
from kaiten_cli.errors import ConfigError
|
|
15
|
+
from kaiten_cli.i18n import tr
|
|
16
|
+
from kaiten_cli.models import (
|
|
17
|
+
CACHE_MODE_AUTO,
|
|
18
|
+
CACHE_MODE_OFF,
|
|
19
|
+
CACHE_MODE_READWRITE,
|
|
20
|
+
CACHE_MODE_REFRESH,
|
|
21
|
+
ResolvedProfile,
|
|
22
|
+
)
|
|
23
|
+
from kaiten_cli.runtime.endpoints import normalize_profile_domain
|
|
24
|
+
from kaiten_cli.runtime.fs_security import PRIVATE_FILE_MODE, secure_directory, secure_existing_file
|
|
25
|
+
|
|
26
|
+
CONFIG_ENV = "KAITEN_CLI_CONFIG_PATH"
|
|
27
|
+
CACHE_MODE_VALUES = {CACHE_MODE_AUTO, CACHE_MODE_OFF, CACHE_MODE_READWRITE, CACHE_MODE_REFRESH}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_path() -> Path:
|
|
31
|
+
override = os.environ.get(CONFIG_ENV)
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override)
|
|
34
|
+
return user_config_path("kaiten-cli") / "config.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _profile_setup_command() -> str:
|
|
38
|
+
return "kaiten profile add main --domain <company-subdomain-or-url> --token <api-token> --set-active"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _config_guidance(*, include_profile_list: bool) -> str:
|
|
42
|
+
lines = [
|
|
43
|
+
tr("Config file: {value_0}", value_0=config_path()),
|
|
44
|
+
tr("Recommended persistent setup:"),
|
|
45
|
+
tr(" {value_0}", value_0=_profile_setup_command()),
|
|
46
|
+
]
|
|
47
|
+
if include_profile_list:
|
|
48
|
+
lines.extend(
|
|
49
|
+
[
|
|
50
|
+
tr("Saved profiles:"),
|
|
51
|
+
" kaiten profile list",
|
|
52
|
+
tr("Activate one:"),
|
|
53
|
+
" kaiten profile use <name>",
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
lines.extend(
|
|
57
|
+
[
|
|
58
|
+
tr("Temporary shell environment:"),
|
|
59
|
+
tr(" export KAITEN_DOMAIN=<company-subdomain-or-url>"),
|
|
60
|
+
tr(" export KAITEN_TOKEN=<api-token>"),
|
|
61
|
+
tr("Check current setup:"),
|
|
62
|
+
" kaiten profile show",
|
|
63
|
+
tr("First read-only call:"),
|
|
64
|
+
" kaiten --json spaces list --compact --fields id,title",
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def missing_credentials_message(*, has_profiles: bool) -> str:
|
|
71
|
+
return "\n".join(
|
|
72
|
+
[
|
|
73
|
+
tr("Missing Kaiten credentials."),
|
|
74
|
+
_config_guidance(include_profile_list=has_profiles),
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def unknown_profile_message(name: str, *, has_profiles: bool) -> str:
|
|
80
|
+
lines = [
|
|
81
|
+
tr("Unknown profile: {value_0}", value_0=name),
|
|
82
|
+
tr("Config file: {value_0}", value_0=config_path()),
|
|
83
|
+
tr("List saved profiles:"),
|
|
84
|
+
" kaiten profile list",
|
|
85
|
+
tr("Create and activate a profile:"),
|
|
86
|
+
tr(" {value_0}", value_0=_profile_setup_command()),
|
|
87
|
+
]
|
|
88
|
+
if has_profiles:
|
|
89
|
+
lines.extend(
|
|
90
|
+
[
|
|
91
|
+
tr("Or activate an existing one:"),
|
|
92
|
+
" kaiten profile use <name>",
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_config() -> dict[str, Any]:
|
|
99
|
+
path = config_path()
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return {"active_profile": None, "profiles": {}}
|
|
102
|
+
try:
|
|
103
|
+
if not os.environ.get(CONFIG_ENV):
|
|
104
|
+
secure_directory(path.parent)
|
|
105
|
+
secure_existing_file(path)
|
|
106
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
107
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
108
|
+
raise ConfigError(
|
|
109
|
+
tr(
|
|
110
|
+
"Unable to read Kaiten CLI config at {value_0}: {value_1}",
|
|
111
|
+
value_0=path,
|
|
112
|
+
value_1=exc,
|
|
113
|
+
)
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
118
|
+
path = config_path()
|
|
119
|
+
temporary_path: Path | None = None
|
|
120
|
+
descriptor: int | None = None
|
|
121
|
+
try:
|
|
122
|
+
secure_directory(path.parent, repair_existing=not bool(os.environ.get(CONFIG_ENV)))
|
|
123
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
124
|
+
temporary_path = Path(temporary_name)
|
|
125
|
+
fchmod = getattr(os, "fchmod", None)
|
|
126
|
+
if fchmod is not None:
|
|
127
|
+
fchmod(descriptor, PRIVATE_FILE_MODE)
|
|
128
|
+
else: # pragma: no cover - Windows fallback
|
|
129
|
+
temporary_path.chmod(PRIVATE_FILE_MODE)
|
|
130
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
131
|
+
descriptor = None
|
|
132
|
+
json.dump(config, stream, ensure_ascii=False, indent=2)
|
|
133
|
+
stream.write("\n")
|
|
134
|
+
stream.flush()
|
|
135
|
+
os.fsync(stream.fileno())
|
|
136
|
+
temporary_path.replace(path)
|
|
137
|
+
secure_existing_file(path)
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise ConfigError(
|
|
140
|
+
tr(
|
|
141
|
+
"Unable to write Kaiten CLI config at {value_0}: {value_1}",
|
|
142
|
+
value_0=path,
|
|
143
|
+
value_1=exc,
|
|
144
|
+
)
|
|
145
|
+
) from exc
|
|
146
|
+
finally:
|
|
147
|
+
if descriptor is not None:
|
|
148
|
+
os.close(descriptor)
|
|
149
|
+
if temporary_path is not None:
|
|
150
|
+
with contextlib.suppress(OSError):
|
|
151
|
+
temporary_path.unlink(missing_ok=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def redact_token(token: str | None) -> str | None:
|
|
155
|
+
if not token:
|
|
156
|
+
return None
|
|
157
|
+
if len(token) <= 4:
|
|
158
|
+
return "*" * len(token)
|
|
159
|
+
return "*" * (len(token) - 4) + token[-4:]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _normalize_cache_mode(value: str | None) -> str:
|
|
163
|
+
if value is None:
|
|
164
|
+
return CACHE_MODE_AUTO
|
|
165
|
+
normalized = str(value).strip().lower()
|
|
166
|
+
if normalized not in CACHE_MODE_VALUES:
|
|
167
|
+
allowed = ", ".join(sorted(CACHE_MODE_VALUES))
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
tr(
|
|
170
|
+
"Invalid cache mode: {value_0}. Expected one of: {value_1}.",
|
|
171
|
+
value_0=value,
|
|
172
|
+
value_1=allowed,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return normalized
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _normalize_cache_ttl_seconds(value: int | str | None) -> int:
|
|
179
|
+
if value is None:
|
|
180
|
+
return 60
|
|
181
|
+
try:
|
|
182
|
+
ttl = int(value)
|
|
183
|
+
except (TypeError, ValueError) as exc:
|
|
184
|
+
raise ConfigError(tr("Invalid cache TTL seconds: {value_0}", value_0=value)) from exc
|
|
185
|
+
if ttl < 1:
|
|
186
|
+
raise ConfigError(tr("Cache TTL seconds must be >= 1."))
|
|
187
|
+
return ttl
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def add_profile(
|
|
191
|
+
name: str,
|
|
192
|
+
*,
|
|
193
|
+
domain: str,
|
|
194
|
+
token: str,
|
|
195
|
+
set_active: bool = False,
|
|
196
|
+
cache_mode: str | None = None,
|
|
197
|
+
cache_ttl_seconds: int | None = None,
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
config = load_config()
|
|
200
|
+
profile = {
|
|
201
|
+
"domain": normalize_profile_domain(domain),
|
|
202
|
+
"token": token,
|
|
203
|
+
}
|
|
204
|
+
if cache_mode is not None:
|
|
205
|
+
profile["cache_mode"] = _normalize_cache_mode(cache_mode)
|
|
206
|
+
if cache_ttl_seconds is not None:
|
|
207
|
+
profile["cache_ttl_seconds"] = _normalize_cache_ttl_seconds(cache_ttl_seconds)
|
|
208
|
+
config.setdefault("profiles", {})[name] = profile
|
|
209
|
+
if set_active or not config.get("active_profile"):
|
|
210
|
+
config["active_profile"] = name
|
|
211
|
+
save_config(config)
|
|
212
|
+
return sanitized_profile(
|
|
213
|
+
name, config["profiles"][name], active=(config.get("active_profile") == name)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def use_profile(name: str) -> dict[str, Any]:
|
|
218
|
+
config = load_config()
|
|
219
|
+
if name not in config.get("profiles", {}):
|
|
220
|
+
raise ConfigError(tr("Unknown profile: {value_0}", value_0=name))
|
|
221
|
+
config["active_profile"] = name
|
|
222
|
+
save_config(config)
|
|
223
|
+
return sanitized_profile(name, config["profiles"][name], active=True)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def remove_profile(name: str) -> dict[str, Any]:
|
|
227
|
+
config = load_config()
|
|
228
|
+
profiles = config.get("profiles", {})
|
|
229
|
+
if name not in profiles:
|
|
230
|
+
raise ConfigError(tr("Unknown profile: {value_0}", value_0=name))
|
|
231
|
+
removed = profiles.pop(name)
|
|
232
|
+
if config.get("active_profile") == name:
|
|
233
|
+
config["active_profile"] = next(iter(profiles), None)
|
|
234
|
+
save_config(config)
|
|
235
|
+
return sanitized_profile(name, removed, active=False)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def list_profiles() -> list[dict[str, Any]]:
|
|
239
|
+
config = load_config()
|
|
240
|
+
active_name = config.get("active_profile")
|
|
241
|
+
return [
|
|
242
|
+
sanitized_profile(name, profile, active=(name == active_name))
|
|
243
|
+
for name, profile in sorted(config.get("profiles", {}).items())
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def show_profile(name: str | None = None) -> dict[str, Any]:
|
|
248
|
+
config = load_config()
|
|
249
|
+
if name is None:
|
|
250
|
+
name = config.get("active_profile")
|
|
251
|
+
if not name:
|
|
252
|
+
return {
|
|
253
|
+
"name": None,
|
|
254
|
+
"active": False,
|
|
255
|
+
"domain": None,
|
|
256
|
+
"token_masked": None,
|
|
257
|
+
"cache_mode": CACHE_MODE_AUTO,
|
|
258
|
+
"cache_ttl_seconds": 60,
|
|
259
|
+
}
|
|
260
|
+
profiles = config.get("profiles", {})
|
|
261
|
+
if name not in profiles:
|
|
262
|
+
raise ConfigError(tr("Unknown profile: {value_0}", value_0=name))
|
|
263
|
+
return sanitized_profile(name, profiles[name], active=(name == config.get("active_profile")))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def sanitized_profile(name: str, raw: dict[str, Any], *, active: bool) -> dict[str, Any]:
|
|
267
|
+
return {
|
|
268
|
+
"name": name,
|
|
269
|
+
"active": active,
|
|
270
|
+
"domain": normalize_profile_domain(str(raw.get("domain", ""))),
|
|
271
|
+
"token_masked": redact_token(raw.get("token")),
|
|
272
|
+
"cache_mode": _normalize_cache_mode(raw.get("cache_mode")),
|
|
273
|
+
"cache_ttl_seconds": _normalize_cache_ttl_seconds(raw.get("cache_ttl_seconds")),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def resolve_profile(
|
|
278
|
+
profile_name: str | None = None,
|
|
279
|
+
*,
|
|
280
|
+
cache_mode_override: str | None = None,
|
|
281
|
+
cache_ttl_seconds_override: int | None = None,
|
|
282
|
+
) -> ResolvedProfile:
|
|
283
|
+
config = load_config()
|
|
284
|
+
profiles = config.get("profiles", {})
|
|
285
|
+
selected_name = profile_name or config.get("active_profile")
|
|
286
|
+
if selected_name and selected_name in profiles:
|
|
287
|
+
selected = profiles[selected_name]
|
|
288
|
+
source = "explicit_profile" if profile_name else "active_profile"
|
|
289
|
+
return ResolvedProfile(
|
|
290
|
+
name=selected_name,
|
|
291
|
+
domain=normalize_profile_domain(str(selected.get("domain", ""))),
|
|
292
|
+
token=str(selected.get("token", "")),
|
|
293
|
+
source=source,
|
|
294
|
+
cache_mode=_normalize_cache_mode(cache_mode_override or selected.get("cache_mode")),
|
|
295
|
+
cache_ttl_seconds=_normalize_cache_ttl_seconds(
|
|
296
|
+
cache_ttl_seconds_override
|
|
297
|
+
if cache_ttl_seconds_override is not None
|
|
298
|
+
else selected.get("cache_ttl_seconds")
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
env_domain = os.environ.get("KAITEN_DOMAIN", "")
|
|
303
|
+
env_token = os.environ.get("KAITEN_TOKEN", "")
|
|
304
|
+
if env_domain and env_token:
|
|
305
|
+
return ResolvedProfile(
|
|
306
|
+
name=None,
|
|
307
|
+
domain=normalize_profile_domain(env_domain),
|
|
308
|
+
token=env_token,
|
|
309
|
+
source="environment",
|
|
310
|
+
cache_mode=_normalize_cache_mode(cache_mode_override),
|
|
311
|
+
cache_ttl_seconds=_normalize_cache_ttl_seconds(cache_ttl_seconds_override),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if selected_name and selected_name not in profiles:
|
|
315
|
+
raise ConfigError(unknown_profile_message(selected_name, has_profiles=bool(profiles)))
|
|
316
|
+
raise ConfigError(missing_credentials_message(has_profiles=bool(profiles)))
|