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,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
@@ -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