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.
Files changed (99) hide show
  1. kaiten_cli/__init__.py +5 -0
  2. kaiten_cli/__main__.py +9 -0
  3. kaiten_cli/agent_gateway.py +577 -0
  4. kaiten_cli/app.py +1636 -0
  5. kaiten_cli/completion.py +570 -0
  6. kaiten_cli/discovery.py +39 -0
  7. kaiten_cli/errors.py +79 -0
  8. kaiten_cli/i18n.py +92 -0
  9. kaiten_cli/locales/ru.json +2967 -0
  10. kaiten_cli/localized_click.py +160 -0
  11. kaiten_cli/models.py +202 -0
  12. kaiten_cli/profiles.py +316 -0
  13. kaiten_cli/registry/__init__.py +260 -0
  14. kaiten_cli/registry/addons.py +392 -0
  15. kaiten_cli/registry/audit_and_analytics.py +451 -0
  16. kaiten_cli/registry/automation_schema.py +44 -0
  17. kaiten_cli/registry/automation_schemas.json +7417 -0
  18. kaiten_cli/registry/automations.py +342 -0
  19. kaiten_cli/registry/base.py +62 -0
  20. kaiten_cli/registry/blockers.py +320 -0
  21. kaiten_cli/registry/boards.py +520 -0
  22. kaiten_cli/registry/card_relations.py +376 -0
  23. kaiten_cli/registry/card_types.py +363 -0
  24. kaiten_cli/registry/cards.py +1329 -0
  25. kaiten_cli/registry/charts.py +539 -0
  26. kaiten_cli/registry/checklists.py +600 -0
  27. kaiten_cli/registry/columns.py +558 -0
  28. kaiten_cli/registry/comments.py +224 -0
  29. kaiten_cli/registry/custom_directories.py +980 -0
  30. kaiten_cli/registry/custom_properties.py +1460 -0
  31. kaiten_cli/registry/dashboards.py +564 -0
  32. kaiten_cli/registry/documents.py +761 -0
  33. kaiten_cli/registry/external_links.py +128 -0
  34. kaiten_cli/registry/files.py +738 -0
  35. kaiten_cli/registry/github_addon.py +613 -0
  36. kaiten_cli/registry/iterations.py +397 -0
  37. kaiten_cli/registry/lanes.py +205 -0
  38. kaiten_cli/registry/live_contracts.py +361 -0
  39. kaiten_cli/registry/members.py +336 -0
  40. kaiten_cli/registry/module_docs.py +108 -0
  41. kaiten_cli/registry/projects.py +369 -0
  42. kaiten_cli/registry/query.py +129 -0
  43. kaiten_cli/registry/restrictions.py +249 -0
  44. kaiten_cli/registry/roles_and_groups.py +1102 -0
  45. kaiten_cli/registry/scim.py +263 -0
  46. kaiten_cli/registry/service_desk.py +1270 -0
  47. kaiten_cli/registry/snapshot.py +187 -0
  48. kaiten_cli/registry/spaces.py +272 -0
  49. kaiten_cli/registry/subscribers.py +168 -0
  50. kaiten_cli/registry/tags.py +193 -0
  51. kaiten_cli/registry/time_logs.py +353 -0
  52. kaiten_cli/registry/tree.py +352 -0
  53. kaiten_cli/registry/utilities.py +287 -0
  54. kaiten_cli/registry/webhooks.py +265 -0
  55. kaiten_cli/runtime/__init__.py +1 -0
  56. kaiten_cli/runtime/automation_validation.py +115 -0
  57. kaiten_cli/runtime/behaviors.py +787 -0
  58. kaiten_cli/runtime/cache.py +733 -0
  59. kaiten_cli/runtime/client.py +387 -0
  60. kaiten_cli/runtime/endpoints.py +59 -0
  61. kaiten_cli/runtime/executor.py +269 -0
  62. kaiten_cli/runtime/fs_security.py +168 -0
  63. kaiten_cli/runtime/input.py +310 -0
  64. kaiten_cli/runtime/output.py +59 -0
  65. kaiten_cli/runtime/public_validation.py +35 -0
  66. kaiten_cli/runtime/snapshot_build.py +527 -0
  67. kaiten_cli/runtime/snapshot_common.py +363 -0
  68. kaiten_cli/runtime/snapshot_query.py +306 -0
  69. kaiten_cli/runtime/snapshot_store.py +1192 -0
  70. kaiten_cli/runtime/snapshots.py +62 -0
  71. kaiten_cli/runtime/sqlite_errors.py +23 -0
  72. kaiten_cli/runtime/support/__init__.py +1 -0
  73. kaiten_cli/runtime/support/addons.py +953 -0
  74. kaiten_cli/runtime/support/audit.py +70 -0
  75. kaiten_cli/runtime/support/batch.py +234 -0
  76. kaiten_cli/runtime/support/card_move_url.py +213 -0
  77. kaiten_cli/runtime/support/cards.py +174 -0
  78. kaiten_cli/runtime/support/checklists.py +21 -0
  79. kaiten_cli/runtime/support/company_users.py +117 -0
  80. kaiten_cli/runtime/support/dashboards.py +35 -0
  81. kaiten_cli/runtime/support/documents.py +175 -0
  82. kaiten_cli/runtime/support/files.py +561 -0
  83. kaiten_cli/runtime/support/markdown_export.py +525 -0
  84. kaiten_cli/runtime/support/pagination.py +167 -0
  85. kaiten_cli/runtime/support/projects.py +66 -0
  86. kaiten_cli/runtime/support/relations.py +86 -0
  87. kaiten_cli/runtime/support/restrictions.py +62 -0
  88. kaiten_cli/runtime/support/spaces.py +20 -0
  89. kaiten_cli/runtime/support/time_logs.py +61 -0
  90. kaiten_cli/runtime/support/tree.py +140 -0
  91. kaiten_cli/runtime/support/tree_sharing.py +447 -0
  92. kaiten_cli/runtime/trace.py +500 -0
  93. kaiten_cli/runtime/transforms.py +128 -0
  94. kaiten_cli/schema_docs.py +22 -0
  95. kaiten_cli/update_check.py +519 -0
  96. kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
  97. kaiten_cli-0.3.3.dist-info/RECORD +99 -0
  98. kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
  99. 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)))