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
kaiten_cli/completion.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Safe installation and inspection of Click shell completion scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from click.shell_completion import get_completion_class
|
|
19
|
+
from platformdirs import user_data_path
|
|
20
|
+
|
|
21
|
+
from kaiten_cli.errors import ConfigError, ValidationError
|
|
22
|
+
from kaiten_cli.i18n import tr
|
|
23
|
+
from kaiten_cli.runtime.fs_security import (
|
|
24
|
+
PRIVATE_DIRECTORY_MODE,
|
|
25
|
+
PRIVATE_FILE_MODE,
|
|
26
|
+
secure_directory,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SUPPORTED_SHELLS = ("bash", "zsh")
|
|
30
|
+
COMPLETION_VARIABLE = "_KAITEN_COMPLETE"
|
|
31
|
+
PROGRAM_NAME = "kaiten"
|
|
32
|
+
MANAGED_BLOCK_BEGIN = "# >>> kaiten-cli completion >>>"
|
|
33
|
+
MANAGED_BLOCK_END = "# <<< kaiten-cli completion <<<"
|
|
34
|
+
LEGACY_ZSH_BLOCK = """# Kaiten CLI completion
|
|
35
|
+
if (( $+commands[kaiten] )); then
|
|
36
|
+
eval \"$(_KAITEN_COMPLETE=zsh_source kaiten)\"
|
|
37
|
+
fi
|
|
38
|
+
"""
|
|
39
|
+
_BASH_VERSION_RE = re.compile(r"version\s+(\d+)\.(\d+)", re.IGNORECASE)
|
|
40
|
+
FileSignature = tuple[int, int, int, int]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def completion_data_dir() -> Path:
|
|
44
|
+
"""Return the private directory used for generated completion scripts."""
|
|
45
|
+
|
|
46
|
+
return user_data_path("kaiten-cli") / "completions"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def completion_script_path(shell: str) -> Path:
|
|
50
|
+
suffix = "bash" if shell == "bash" else "zsh"
|
|
51
|
+
return completion_data_dir() / f"kaiten-complete.{suffix}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_shell(shell: str | None = None) -> str:
|
|
55
|
+
"""Normalize an explicit shell or detect it from the conventional SHELL variable."""
|
|
56
|
+
|
|
57
|
+
candidate = shell
|
|
58
|
+
if candidate is None:
|
|
59
|
+
candidate = os.environ.get("SHELL")
|
|
60
|
+
normalized = Path(candidate).name.lower() if candidate else ""
|
|
61
|
+
if normalized not in SUPPORTED_SHELLS:
|
|
62
|
+
supported = ", ".join(SUPPORTED_SHELLS)
|
|
63
|
+
if candidate:
|
|
64
|
+
raise ValidationError(
|
|
65
|
+
tr(
|
|
66
|
+
"Unsupported shell: {value_0}. Expected one of: {value_1}. Use --shell to select explicitly.",
|
|
67
|
+
value_0=candidate,
|
|
68
|
+
value_1=supported,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
raise ValidationError(
|
|
72
|
+
tr(
|
|
73
|
+
"Unable to detect shell from SHELL. Use --shell with one of: {value_0}.",
|
|
74
|
+
value_0=supported,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return normalized
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def default_config_path(shell: str) -> Path:
|
|
81
|
+
return Path.home() / (".bashrc" if shell == "bash" else ".zshrc")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def generate_completion_source(command: click.Command, shell: str) -> str:
|
|
85
|
+
"""Generate a static completion registration script using Click's public API."""
|
|
86
|
+
|
|
87
|
+
normalized = detect_shell(shell)
|
|
88
|
+
completion_class = get_completion_class(normalized)
|
|
89
|
+
if completion_class is None: # pragma: no cover - guarded by SUPPORTED_SHELLS
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
tr("Click does not provide completion support for {value_0}.", value_0=normalized)
|
|
92
|
+
)
|
|
93
|
+
source = completion_class(command, {}, PROGRAM_NAME, COMPLETION_VARIABLE).source()
|
|
94
|
+
return source if source.endswith("\n") else source + "\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _shell_executable(shell: str) -> str | None:
|
|
98
|
+
configured = os.environ.get("SHELL")
|
|
99
|
+
if configured and Path(configured).name.lower() == shell and Path(configured).is_file():
|
|
100
|
+
return configured
|
|
101
|
+
return shutil.which(shell)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _bash_version(executable: str) -> tuple[int, int] | None:
|
|
105
|
+
try:
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
[executable, "--version"],
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=2,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
114
|
+
return None
|
|
115
|
+
match = _BASH_VERSION_RE.search(result.stdout)
|
|
116
|
+
if match is None:
|
|
117
|
+
return None
|
|
118
|
+
return int(match.group(1)), int(match.group(2))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _shell_support(shell: str) -> tuple[bool, str | None, str | None]:
|
|
122
|
+
executable = _shell_executable(shell)
|
|
123
|
+
if executable is None:
|
|
124
|
+
return (
|
|
125
|
+
False,
|
|
126
|
+
None,
|
|
127
|
+
tr("Shell executable is not available on PATH: {value_0}.", value_0=shell),
|
|
128
|
+
)
|
|
129
|
+
if shell == "bash":
|
|
130
|
+
version = _bash_version(executable)
|
|
131
|
+
if version is None:
|
|
132
|
+
return (
|
|
133
|
+
False,
|
|
134
|
+
executable,
|
|
135
|
+
tr("Unable to determine Bash version; Click requires Bash 4.4+."),
|
|
136
|
+
)
|
|
137
|
+
if version < (4, 4):
|
|
138
|
+
return (
|
|
139
|
+
False,
|
|
140
|
+
executable,
|
|
141
|
+
tr(
|
|
142
|
+
"Bash {value_0}.{value_1} is unsupported; Click requires Bash 4.4+.",
|
|
143
|
+
value_0=version[0],
|
|
144
|
+
value_1=version[1],
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
return True, executable, None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _command_path() -> str | None:
|
|
151
|
+
return shutil.which(PROGRAM_NAME)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_edit_path(path: Path, *, require_owned: bool = False) -> Path:
|
|
155
|
+
path = path.expanduser()
|
|
156
|
+
if path.is_symlink():
|
|
157
|
+
try:
|
|
158
|
+
target = path.resolve(strict=True)
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
raise ConfigError(
|
|
161
|
+
tr(
|
|
162
|
+
"Unable to resolve shell config symlink {value_0}: {value_1}",
|
|
163
|
+
value_0=path,
|
|
164
|
+
value_1=exc,
|
|
165
|
+
)
|
|
166
|
+
) from exc
|
|
167
|
+
if not target.is_file():
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
tr("Shell config symlink target is not a regular file: {value_0}", value_0=target)
|
|
170
|
+
)
|
|
171
|
+
path = target
|
|
172
|
+
if path.exists() and not path.is_file():
|
|
173
|
+
raise ConfigError(tr("Shell config is not a regular file: {value_0}", value_0=path))
|
|
174
|
+
getuid = getattr(os, "getuid", None)
|
|
175
|
+
if require_owned and path.exists() and getuid is not None and path.stat().st_uid != getuid():
|
|
176
|
+
raise ConfigError(
|
|
177
|
+
tr(
|
|
178
|
+
"Refusing to replace shell config not owned by the current user: {value_0}",
|
|
179
|
+
value_0=path,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _read_text(path: Path) -> str:
|
|
186
|
+
if not path.exists():
|
|
187
|
+
return ""
|
|
188
|
+
try:
|
|
189
|
+
return path.read_text(encoding="utf-8")
|
|
190
|
+
except (OSError, UnicodeError) as exc:
|
|
191
|
+
raise ConfigError(
|
|
192
|
+
tr("Unable to read shell config at {value_0}: {value_1}", value_0=path, value_1=exc)
|
|
193
|
+
) from exc
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _existing_mode(path: Path, default: int) -> int:
|
|
197
|
+
try:
|
|
198
|
+
return stat.S_IMODE(path.stat().st_mode) if path.exists() else default
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
raise ConfigError(
|
|
201
|
+
tr("Unable to inspect file mode at {value_0}: {value_1}", value_0=path, value_1=exc)
|
|
202
|
+
) from exc
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _file_signature(path: Path) -> FileSignature | None:
|
|
206
|
+
try:
|
|
207
|
+
if not path.exists():
|
|
208
|
+
return None
|
|
209
|
+
file_stat = path.stat()
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise ConfigError(
|
|
212
|
+
tr("Unable to inspect file at {value_0}: {value_1}", value_0=path, value_1=exc)
|
|
213
|
+
) from exc
|
|
214
|
+
return (file_stat.st_dev, file_stat.st_ino, file_stat.st_mtime_ns, file_stat.st_size)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _read_edit_state(path: Path) -> tuple[str, FileSignature | None, int]:
|
|
218
|
+
before = _file_signature(path)
|
|
219
|
+
text = _read_text(path)
|
|
220
|
+
after = _file_signature(path)
|
|
221
|
+
if before != after:
|
|
222
|
+
raise ConfigError(
|
|
223
|
+
tr("Shell config changed while it was being read: {value_0}", value_0=path)
|
|
224
|
+
)
|
|
225
|
+
return text, after, _existing_mode(path, PRIVATE_FILE_MODE)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _write_text_atomic(
|
|
229
|
+
path: Path,
|
|
230
|
+
text: str,
|
|
231
|
+
*,
|
|
232
|
+
mode: int,
|
|
233
|
+
expected_signature: FileSignature | None = None,
|
|
234
|
+
verify_signature: bool = False,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Atomically replace one regular file while preserving the requested POSIX mode."""
|
|
237
|
+
|
|
238
|
+
temporary_path: Path | None = None
|
|
239
|
+
descriptor: int | None = None
|
|
240
|
+
try:
|
|
241
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=PRIVATE_DIRECTORY_MODE)
|
|
242
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
243
|
+
temporary_path = Path(temporary_name)
|
|
244
|
+
fchmod = getattr(os, "fchmod", None)
|
|
245
|
+
if fchmod is not None:
|
|
246
|
+
fchmod(descriptor, mode)
|
|
247
|
+
else: # pragma: no cover - Windows is outside the supported shell scope
|
|
248
|
+
temporary_path.chmod(mode)
|
|
249
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
250
|
+
descriptor = None
|
|
251
|
+
stream.write(text)
|
|
252
|
+
stream.flush()
|
|
253
|
+
os.fsync(stream.fileno())
|
|
254
|
+
if verify_signature and _file_signature(path) != expected_signature:
|
|
255
|
+
raise ConfigError(
|
|
256
|
+
tr(
|
|
257
|
+
"File changed before atomic replacement; no changes applied: {value_0}",
|
|
258
|
+
value_0=path,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
temporary_path.replace(path)
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
raise ConfigError(
|
|
264
|
+
tr("Unable to write file at {value_0}: {value_1}", value_0=path, value_1=exc)
|
|
265
|
+
) from exc
|
|
266
|
+
finally:
|
|
267
|
+
if descriptor is not None:
|
|
268
|
+
os.close(descriptor)
|
|
269
|
+
if temporary_path is not None:
|
|
270
|
+
with contextlib.suppress(OSError):
|
|
271
|
+
temporary_path.unlink(missing_ok=True)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _managed_block(script_path: Path) -> str:
|
|
275
|
+
quoted_path = shlex.quote(str(script_path))
|
|
276
|
+
return "\n".join(
|
|
277
|
+
[
|
|
278
|
+
MANAGED_BLOCK_BEGIN,
|
|
279
|
+
f"if [ -r {quoted_path} ]; then",
|
|
280
|
+
f" . {quoted_path}",
|
|
281
|
+
"fi",
|
|
282
|
+
MANAGED_BLOCK_END,
|
|
283
|
+
]
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _remove_managed_blocks(text: str) -> tuple[str, int]:
|
|
288
|
+
removed = 0
|
|
289
|
+
while True:
|
|
290
|
+
start = text.find(MANAGED_BLOCK_BEGIN)
|
|
291
|
+
if start < 0:
|
|
292
|
+
break
|
|
293
|
+
end = text.find(MANAGED_BLOCK_END, start + len(MANAGED_BLOCK_BEGIN))
|
|
294
|
+
if end < 0:
|
|
295
|
+
raise ConfigError(
|
|
296
|
+
tr(
|
|
297
|
+
"Managed completion block is incomplete: missing {value_0!r}.",
|
|
298
|
+
value_0=MANAGED_BLOCK_END,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
end += len(MANAGED_BLOCK_END)
|
|
302
|
+
if end < len(text) and text[end] == "\n":
|
|
303
|
+
end += 1
|
|
304
|
+
if start >= 2 and text[start - 2 : start] == "\n\n":
|
|
305
|
+
start -= 1
|
|
306
|
+
text = text[:start] + text[end:]
|
|
307
|
+
removed += 1
|
|
308
|
+
return text, removed
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _desired_config(text: str, script_path: Path, shell: str) -> tuple[str, bool]:
|
|
312
|
+
without_managed, _ = _remove_managed_blocks(text)
|
|
313
|
+
legacy_migrated = False
|
|
314
|
+
if shell == "zsh" and LEGACY_ZSH_BLOCK in without_managed:
|
|
315
|
+
without_managed = without_managed.replace(LEGACY_ZSH_BLOCK, "", 1)
|
|
316
|
+
legacy_migrated = True
|
|
317
|
+
base = without_managed.rstrip("\n")
|
|
318
|
+
block = _managed_block(script_path)
|
|
319
|
+
desired = f"{base}\n\n{block}\n" if base else f"{block}\n"
|
|
320
|
+
return desired, legacy_migrated
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _unmanaged_registration(text: str, shell: str) -> bool:
|
|
324
|
+
needle = f"{COMPLETION_VARIABLE}={shell}_source {PROGRAM_NAME}"
|
|
325
|
+
managed_text, _ = _remove_managed_blocks(text)
|
|
326
|
+
if shell == "zsh":
|
|
327
|
+
managed_text = managed_text.replace(LEGACY_ZSH_BLOCK, "")
|
|
328
|
+
return needle in managed_text
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _bash_startup_warning(config_path: Path) -> str | None:
|
|
332
|
+
if sys.platform != "darwin" or config_path != default_config_path("bash"):
|
|
333
|
+
return None
|
|
334
|
+
home = Path.home()
|
|
335
|
+
profiles = [home / ".bash_profile", home / ".bash_login", home / ".profile"]
|
|
336
|
+
active_profile = next((path for path in profiles if path.exists()), None)
|
|
337
|
+
if active_profile is None:
|
|
338
|
+
return tr(
|
|
339
|
+
"macOS login Bash may not load ~/.bashrc. Use --config ~/.bash_profile or make ~/.bash_profile source ~/.bashrc."
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
profile_text = active_profile.read_text(encoding="utf-8")
|
|
343
|
+
except (OSError, UnicodeError):
|
|
344
|
+
return tr("Unable to confirm that {value_0} loads ~/.bashrc.", value_0=active_profile)
|
|
345
|
+
if ".bashrc" not in profile_text:
|
|
346
|
+
return tr(
|
|
347
|
+
"{value_0} does not appear to load ~/.bashrc; login Bash may not enable completion. Use --config with the active profile or source ~/.bashrc from it.",
|
|
348
|
+
value_0=active_profile,
|
|
349
|
+
)
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def completion_status(
|
|
354
|
+
command: click.Command,
|
|
355
|
+
*,
|
|
356
|
+
shell: str | None = None,
|
|
357
|
+
config_path: Path | None = None,
|
|
358
|
+
) -> dict[str, Any]:
|
|
359
|
+
normalized = detect_shell(shell)
|
|
360
|
+
requested_config = (config_path or default_config_path(normalized)).expanduser()
|
|
361
|
+
edit_path = _resolve_edit_path(requested_config)
|
|
362
|
+
script_path = completion_script_path(normalized)
|
|
363
|
+
source = generate_completion_source(command, normalized)
|
|
364
|
+
config_text = _read_text(edit_path)
|
|
365
|
+
script_is_symlink = script_path.is_symlink()
|
|
366
|
+
script_is_regular = script_path.is_file() and not script_is_symlink
|
|
367
|
+
script_parent_is_symlink = script_path.parent.is_symlink()
|
|
368
|
+
script_parent_is_directory = script_path.parent.is_dir() and not script_parent_is_symlink
|
|
369
|
+
script_parent_mode = (
|
|
370
|
+
_existing_mode(script_path.parent, PRIVATE_DIRECTORY_MODE)
|
|
371
|
+
if script_parent_is_directory
|
|
372
|
+
else None
|
|
373
|
+
)
|
|
374
|
+
script_parent_secure = (
|
|
375
|
+
script_parent_is_directory and script_parent_mode == PRIVATE_DIRECTORY_MODE
|
|
376
|
+
)
|
|
377
|
+
script_text = _read_text(script_path) if script_is_regular else ""
|
|
378
|
+
script_mode = _existing_mode(script_path, PRIVATE_FILE_MODE) if script_is_regular else None
|
|
379
|
+
script_secure = script_is_regular and script_mode == PRIVATE_FILE_MODE
|
|
380
|
+
supported, shell_path, support_warning = _shell_support(normalized)
|
|
381
|
+
command_path = _command_path()
|
|
382
|
+
block = _managed_block(script_path)
|
|
383
|
+
warnings = [warning for warning in (support_warning,) if warning]
|
|
384
|
+
bash_warning = _bash_startup_warning(requested_config)
|
|
385
|
+
if bash_warning:
|
|
386
|
+
warnings.append(bash_warning)
|
|
387
|
+
legacy_registration = normalized == "zsh" and LEGACY_ZSH_BLOCK in config_text
|
|
388
|
+
if legacy_registration:
|
|
389
|
+
warnings.append(
|
|
390
|
+
tr(
|
|
391
|
+
"A legacy dynamic Kaiten completion block was found; run completion install to migrate it."
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
unmanaged = _unmanaged_registration(config_text, normalized)
|
|
395
|
+
if unmanaged:
|
|
396
|
+
warnings.append(
|
|
397
|
+
tr("An unmanaged Kaiten completion registration was found and was left unchanged.")
|
|
398
|
+
)
|
|
399
|
+
if script_is_symlink:
|
|
400
|
+
warnings.append(tr("The completion script path is a symlink and will not be trusted."))
|
|
401
|
+
if script_path.parent.exists() and not script_parent_secure:
|
|
402
|
+
warnings.append(tr("The completion script directory is not a private 0700 directory."))
|
|
403
|
+
managed = block in config_text
|
|
404
|
+
script_current = script_text == source
|
|
405
|
+
configured = bool(
|
|
406
|
+
command_path
|
|
407
|
+
and supported
|
|
408
|
+
and managed
|
|
409
|
+
and script_current
|
|
410
|
+
and script_secure
|
|
411
|
+
and script_parent_secure
|
|
412
|
+
)
|
|
413
|
+
return {
|
|
414
|
+
"shell": normalized,
|
|
415
|
+
"supported": supported,
|
|
416
|
+
"shell_executable": shell_path,
|
|
417
|
+
"command_available": command_path is not None,
|
|
418
|
+
"command_path": command_path,
|
|
419
|
+
"config_path": str(requested_config),
|
|
420
|
+
"config_target_path": str(edit_path),
|
|
421
|
+
"script_path": str(script_path),
|
|
422
|
+
"script_exists": script_is_regular,
|
|
423
|
+
"script_parent_mode": script_parent_mode,
|
|
424
|
+
"script_parent_secure": script_parent_secure,
|
|
425
|
+
"script_mode": script_mode,
|
|
426
|
+
"script_secure": script_secure,
|
|
427
|
+
"script_current": script_current,
|
|
428
|
+
"managed_block": managed,
|
|
429
|
+
"legacy_registration": legacy_registration,
|
|
430
|
+
"unmanaged_registration": unmanaged,
|
|
431
|
+
"configured": configured,
|
|
432
|
+
"warnings": warnings,
|
|
433
|
+
"restart_command": f"exec {shlex.quote(normalized)}",
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def install_completion(
|
|
438
|
+
command: click.Command,
|
|
439
|
+
*,
|
|
440
|
+
shell: str | None = None,
|
|
441
|
+
config_path: Path | None = None,
|
|
442
|
+
dry_run: bool = False,
|
|
443
|
+
) -> dict[str, Any]:
|
|
444
|
+
normalized = detect_shell(shell)
|
|
445
|
+
supported, _, warning = _shell_support(normalized)
|
|
446
|
+
if not supported:
|
|
447
|
+
raise ValidationError(warning or tr("Shell is not supported: {shell}.", shell=normalized))
|
|
448
|
+
if _command_path() is None:
|
|
449
|
+
raise ConfigError(
|
|
450
|
+
tr(
|
|
451
|
+
"The installed 'kaiten' entry point is not available on PATH. Finish uv/pipx PATH setup before installing completion."
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
requested_config = (config_path or default_config_path(normalized)).expanduser()
|
|
456
|
+
edit_path = _resolve_edit_path(requested_config, require_owned=True)
|
|
457
|
+
script_path = completion_script_path(normalized)
|
|
458
|
+
source = generate_completion_source(command, normalized)
|
|
459
|
+
current_config, config_signature, config_mode = _read_edit_state(edit_path)
|
|
460
|
+
desired_config, legacy_migrated = _desired_config(current_config, script_path, normalized)
|
|
461
|
+
script_parent_secure = (
|
|
462
|
+
script_path.parent.is_dir()
|
|
463
|
+
and not script_path.parent.is_symlink()
|
|
464
|
+
and _existing_mode(script_path.parent, PRIVATE_DIRECTORY_MODE) == PRIVATE_DIRECTORY_MODE
|
|
465
|
+
)
|
|
466
|
+
directory_changed = not script_parent_secure
|
|
467
|
+
if not dry_run:
|
|
468
|
+
secure_directory(script_path.parent)
|
|
469
|
+
if script_path.is_symlink():
|
|
470
|
+
raise ConfigError(
|
|
471
|
+
tr("Refusing to replace symlinked completion script: {value_0}", value_0=script_path)
|
|
472
|
+
)
|
|
473
|
+
if script_path.exists() and not script_path.is_file():
|
|
474
|
+
raise ConfigError(
|
|
475
|
+
tr("Completion script is not a regular file: {value_0}", value_0=script_path)
|
|
476
|
+
)
|
|
477
|
+
current_script = _read_text(script_path)
|
|
478
|
+
current_script_mode = _existing_mode(script_path, PRIVATE_FILE_MODE)
|
|
479
|
+
script_changed = current_script != source or current_script_mode != PRIVATE_FILE_MODE
|
|
480
|
+
config_changed = current_config != desired_config
|
|
481
|
+
|
|
482
|
+
if not dry_run:
|
|
483
|
+
if script_changed:
|
|
484
|
+
_write_text_atomic(script_path, source, mode=PRIVATE_FILE_MODE)
|
|
485
|
+
if config_changed:
|
|
486
|
+
_write_text_atomic(
|
|
487
|
+
edit_path,
|
|
488
|
+
desired_config,
|
|
489
|
+
mode=config_mode,
|
|
490
|
+
expected_signature=config_signature,
|
|
491
|
+
verify_signature=True,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
status = completion_status(
|
|
495
|
+
command,
|
|
496
|
+
shell=normalized,
|
|
497
|
+
config_path=requested_config,
|
|
498
|
+
)
|
|
499
|
+
status.update(
|
|
500
|
+
{
|
|
501
|
+
"dry_run": dry_run,
|
|
502
|
+
"script_changed": script_changed,
|
|
503
|
+
"directory_changed": directory_changed,
|
|
504
|
+
"config_changed": config_changed,
|
|
505
|
+
"legacy_migrated": legacy_migrated,
|
|
506
|
+
"would_configure": True,
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
if not dry_run and not status["configured"]:
|
|
510
|
+
raise ConfigError(
|
|
511
|
+
tr(
|
|
512
|
+
"Completion files were written but the resulting configuration did not pass the static verification check."
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
return status
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def uninstall_completion(
|
|
519
|
+
command: click.Command,
|
|
520
|
+
*,
|
|
521
|
+
shell: str | None = None,
|
|
522
|
+
config_path: Path | None = None,
|
|
523
|
+
dry_run: bool = False,
|
|
524
|
+
) -> dict[str, Any]:
|
|
525
|
+
normalized = detect_shell(shell)
|
|
526
|
+
requested_config = (config_path or default_config_path(normalized)).expanduser()
|
|
527
|
+
edit_path = _resolve_edit_path(requested_config, require_owned=True)
|
|
528
|
+
script_path = completion_script_path(normalized)
|
|
529
|
+
current_config, config_signature, config_mode = _read_edit_state(edit_path)
|
|
530
|
+
desired_config, removed_blocks = _remove_managed_blocks(current_config)
|
|
531
|
+
config_changed = desired_config != current_config
|
|
532
|
+
script_exists = script_path.exists()
|
|
533
|
+
if script_path.is_symlink():
|
|
534
|
+
raise ConfigError(
|
|
535
|
+
tr("Refusing to remove symlinked completion script: {value_0}", value_0=script_path)
|
|
536
|
+
)
|
|
537
|
+
if script_path.exists() and not script_path.is_file():
|
|
538
|
+
raise ConfigError(
|
|
539
|
+
tr("Completion script is not a regular file: {value_0}", value_0=script_path)
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if not dry_run:
|
|
543
|
+
if config_changed:
|
|
544
|
+
_write_text_atomic(
|
|
545
|
+
edit_path,
|
|
546
|
+
desired_config,
|
|
547
|
+
mode=config_mode,
|
|
548
|
+
expected_signature=config_signature,
|
|
549
|
+
verify_signature=True,
|
|
550
|
+
)
|
|
551
|
+
with contextlib.suppress(FileNotFoundError):
|
|
552
|
+
script_path.unlink()
|
|
553
|
+
with contextlib.suppress(OSError):
|
|
554
|
+
script_path.parent.rmdir()
|
|
555
|
+
|
|
556
|
+
status = completion_status(
|
|
557
|
+
command,
|
|
558
|
+
shell=normalized,
|
|
559
|
+
config_path=requested_config,
|
|
560
|
+
)
|
|
561
|
+
status.update(
|
|
562
|
+
{
|
|
563
|
+
"dry_run": dry_run,
|
|
564
|
+
"config_changed": config_changed,
|
|
565
|
+
"script_changed": script_exists,
|
|
566
|
+
"removed_blocks": removed_blocks,
|
|
567
|
+
"would_remove": config_changed or script_exists,
|
|
568
|
+
}
|
|
569
|
+
)
|
|
570
|
+
return status
|
kaiten_cli/discovery.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Discovery wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kaiten_cli.i18n import localize_description
|
|
6
|
+
from kaiten_cli.registry import cache_guidance_for, describe, examples_for, search
|
|
7
|
+
from kaiten_cli.registry.live_contracts import has_special_live_contract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def search_tools(query: str, limit: int = 5) -> list[dict]:
|
|
11
|
+
return [
|
|
12
|
+
localize_description(
|
|
13
|
+
{
|
|
14
|
+
"canonical_name": tool.canonical_name,
|
|
15
|
+
"mcp_alias": tool.mcp_alias,
|
|
16
|
+
"description": tool.description,
|
|
17
|
+
"method": tool.operation.method,
|
|
18
|
+
"mutation": tool.is_mutation,
|
|
19
|
+
"read_only_allowed": tool.read_only_allowed,
|
|
20
|
+
"remote_side_effects": tool.remote_side_effects,
|
|
21
|
+
"heavy": tool.response_policy.heavy,
|
|
22
|
+
"execution_mode": tool.execution_mode,
|
|
23
|
+
"cache_policy": tool.cache_policy,
|
|
24
|
+
"cache_guidance": cache_guidance_for(tool),
|
|
25
|
+
"has_special_live_contract": has_special_live_contract(tool.canonical_name),
|
|
26
|
+
"bulk_alternative": tool.bulk_alternative,
|
|
27
|
+
"usage_notes": list(tool.usage_notes),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
for tool in search(query, limit=limit)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def describe_tool(identifier: str) -> dict:
|
|
35
|
+
return localize_description(describe(identifier))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tool_examples(identifier: str) -> list[str]:
|
|
39
|
+
return examples_for(identifier)
|
kaiten_cli/errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Application error types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class CliError(Exception):
|
|
11
|
+
"""Base CLI error."""
|
|
12
|
+
|
|
13
|
+
message: str
|
|
14
|
+
exit_code: int
|
|
15
|
+
error_type: str
|
|
16
|
+
details: dict[str, Any] | None = None
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, Any]:
|
|
19
|
+
payload: dict[str, Any] = {"type": self.error_type, "message": self.message}
|
|
20
|
+
if self.details:
|
|
21
|
+
payload["details"] = self.details
|
|
22
|
+
return payload
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return self.message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(CliError):
|
|
29
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None):
|
|
30
|
+
super().__init__(
|
|
31
|
+
message=message,
|
|
32
|
+
exit_code=2,
|
|
33
|
+
error_type="validation_error",
|
|
34
|
+
details=details,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigError(CliError):
|
|
39
|
+
def __init__(self, message: str):
|
|
40
|
+
super().__init__(message=message, exit_code=3, error_type="config_error")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ApiError(CliError):
|
|
44
|
+
def __init__(self, status_code: int, message: str, body: Any = None):
|
|
45
|
+
super().__init__(message=message, exit_code=4, error_type="api_error")
|
|
46
|
+
self.status_code = status_code
|
|
47
|
+
self.body = body
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, Any]:
|
|
50
|
+
payload = super().to_dict()
|
|
51
|
+
payload["status_code"] = self.status_code
|
|
52
|
+
payload["body"] = self.body
|
|
53
|
+
return payload
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TransportError(CliError):
|
|
57
|
+
def __init__(self, message: str):
|
|
58
|
+
super().__init__(message=message, exit_code=5, error_type="transport_error")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MutationBlockedError(CliError):
|
|
62
|
+
def __init__(self, message: str):
|
|
63
|
+
super().__init__(message=message, exit_code=6, error_type="mutation_blocked")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InternalError(CliError):
|
|
67
|
+
def __init__(self, message: str):
|
|
68
|
+
super().__init__(message=message, exit_code=70, error_type="internal_error")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BatchExecutionError(CliError):
|
|
72
|
+
def __init__(self, message: str, data: Any):
|
|
73
|
+
super().__init__(message=message, exit_code=5, error_type="batch_execution_error")
|
|
74
|
+
self.data = data
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
payload = super().to_dict()
|
|
78
|
+
payload["data"] = self.data
|
|
79
|
+
return payload
|