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
kaiten_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """kaiten-cli package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.3.3"
kaiten_cli/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Module entrypoint for `python -m kaiten_cli`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kaiten_cli.app import main
6
+
7
+
8
+ if __name__ == "__main__": # pragma: no cover
9
+ raise SystemExit(main())
@@ -0,0 +1,577 @@
1
+ """HTTP gateway that exposes kaiten-cli as a chat agent endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import hmac
8
+ import ipaddress
9
+ import json
10
+ import logging
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import tempfile
15
+ import threading
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass
18
+ from http import HTTPStatus
19
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
+ from pathlib import Path
21
+ from typing import Any, Callable
22
+
23
+ from kaiten_cli import __version__
24
+ from kaiten_cli.app import _agent_help_payload
25
+
26
+ DEFAULT_HOST = "127.0.0.1"
27
+ DEFAULT_PORT = 8787
28
+ DEFAULT_TIMEOUT_SECONDS = 180
29
+ DEFAULT_REASONING_EFFORT = "low"
30
+ DEFAULT_CODEX_BIN = "codex"
31
+ MAX_REQUEST_BODY_BYTES = 1024 * 1024
32
+ MAX_CONCURRENT_CODEX_EXECUTIONS = 2
33
+ REQUEST_READ_TIMEOUT_SECONDS = 10.0
34
+ SKILL_NAMES = ("kaiten-cli-heavy-data", "kaiten-cli-metrics")
35
+
36
+ LOGGER = logging.getLogger(__name__)
37
+
38
+ # Keep child credentials and process settings deliberately narrow. In particular,
39
+ # KAITEN_LIVE and arbitrary secrets from the gateway environment must not reach Codex.
40
+ CODEX_ENV_ALLOWLIST = frozenset(
41
+ {
42
+ "ALL_PROXY",
43
+ "APPDATA",
44
+ "CODEX_HOME",
45
+ "COMSPEC",
46
+ "HOME",
47
+ "HTTPS_PROXY",
48
+ "HTTP_PROXY",
49
+ "KAITEN_CLI_CONFIG_PATH",
50
+ "KAITEN_CLI_LOCALE",
51
+ "KAITEN_DOMAIN",
52
+ "KAITEN_TOKEN",
53
+ "LANG",
54
+ "LC_ALL",
55
+ "LC_CTYPE",
56
+ "LOCALAPPDATA",
57
+ "LOGNAME",
58
+ "NO_PROXY",
59
+ "OPENAI_API_KEY",
60
+ "OPENAI_BASE_URL",
61
+ "OPENAI_ORG_ID",
62
+ "OPENAI_PROJECT_ID",
63
+ "PATH",
64
+ "PATHEXT",
65
+ "REQUESTS_CA_BUNDLE",
66
+ "SHELL",
67
+ "SSL_CERT_DIR",
68
+ "SSL_CERT_FILE",
69
+ "SYSTEMROOT",
70
+ "TEMP",
71
+ "TMP",
72
+ "TMPDIR",
73
+ "USER",
74
+ "USERPROFILE",
75
+ "XDG_CACHE_HOME",
76
+ "XDG_CONFIG_HOME",
77
+ "XDG_DATA_HOME",
78
+ "all_proxy",
79
+ "http_proxy",
80
+ "https_proxy",
81
+ "no_proxy",
82
+ }
83
+ )
84
+ SENSITIVE_CHILD_ENV_KEYS = frozenset(
85
+ {
86
+ "ALL_PROXY",
87
+ "HTTPS_PROXY",
88
+ "HTTP_PROXY",
89
+ "KAITEN_TOKEN",
90
+ "OPENAI_API_KEY",
91
+ "OPENAI_ORG_ID",
92
+ "OPENAI_PROJECT_ID",
93
+ "all_proxy",
94
+ "http_proxy",
95
+ "https_proxy",
96
+ }
97
+ )
98
+
99
+ SYSTEM_PROMPT = """You are Kaiten Agent, an assistant embedded into Kaiten.
100
+ Use kaiten-cli as the primary tool surface for Kaiten data.
101
+ Start with discovery commands before heavy reads:
102
+ - kaiten agent-help
103
+ - kaiten search-tools "<query>"
104
+ - kaiten describe <tool>
105
+ - kaiten examples <tool>
106
+
107
+ Prefer machine-readable, narrow, and bulk workflows:
108
+ - use --json for tool output
109
+ - use --compact and --fields to reduce payload
110
+ - prefer cards list-all, batch commands, snapshots, and query commands over per-card loops
111
+ - inspect cache/runtime stats before widening expensive reads
112
+ - reuse local scripts that are available in the configured workdir or extra directories
113
+
114
+ This gateway is always read-only. Never mutate Kaiten; explain that writes must be run
115
+ outside this gateway when a user asks for a write operation.
116
+ Answer in the user's language unless they request otherwise.
117
+ Select the CLI locale from the current conversation language: ru for Russian, en
118
+ for English or other languages. Pass --locale ru or --locale en BEFORE the command
119
+ on every kaiten invocation, including discovery and JSON calls. Change it when
120
+ the user requests a language change; explicit --locale overrides the environment.
121
+ """
122
+
123
+ SKILL_FALLBACKS = {
124
+ "kaiten-cli-heavy-data": (
125
+ "Use bulk commands, snapshots, compact fields, and cache-aware workflows. "
126
+ "Avoid one CLI process or API request per card when a batch command exists."
127
+ ),
128
+ "kaiten-cli-metrics": (
129
+ "For metrics, start from topology, bulk card fields, snapshots/query metrics, "
130
+ "space activity, chart tools, and card-location-history batch-get only when needed."
131
+ ),
132
+ }
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class AgentGatewayConfig:
137
+ host: str = DEFAULT_HOST
138
+ port: int = DEFAULT_PORT
139
+ codex_bin: str = DEFAULT_CODEX_BIN
140
+ codex_model: str | None = None
141
+ reasoning_effort: str = DEFAULT_REASONING_EFFORT
142
+ workdir: Path = Path.cwd()
143
+ extra_dirs: tuple[Path, ...] = ()
144
+ skip_git_repo_check: bool = False
145
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
146
+ bearer_token: str | None = None
147
+ repo_root: Path = Path.cwd()
148
+
149
+
150
+ def default_repo_root() -> Path:
151
+ return Path(__file__).resolve().parents[2]
152
+
153
+
154
+ def _json_dumps(value: Any) -> str:
155
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
156
+
157
+
158
+ def _safe_read_text(path: Path) -> str | None:
159
+ try:
160
+ return path.read_text(encoding="utf-8")
161
+ except OSError:
162
+ return None
163
+
164
+
165
+ def load_skill_texts(repo_root: Path) -> dict[str, str]:
166
+ skills: dict[str, str] = {}
167
+ for skill_name in SKILL_NAMES:
168
+ skill_path = repo_root / "skills" / skill_name / "SKILL.md"
169
+ skills[skill_name] = _safe_read_text(skill_path) or SKILL_FALLBACKS[skill_name]
170
+ return skills
171
+
172
+
173
+ def build_prompt(payload: dict[str, Any], *, repo_root: Path | None = None) -> str:
174
+ repo = repo_root or default_repo_root()
175
+ messages = payload.get("messages") if isinstance(payload.get("messages"), list) else []
176
+ context = payload.get("context") if isinstance(payload.get("context"), dict) else {}
177
+ endpoint_instructions = str(payload.get("instructions") or "").strip()
178
+
179
+ parts = [
180
+ SYSTEM_PROMPT.strip(),
181
+ "",
182
+ "Endpoint instructions from Kaiten:",
183
+ endpoint_instructions or "(none)",
184
+ "",
185
+ "kaiten agent-help JSON:",
186
+ _json_dumps(_agent_help_payload()),
187
+ "",
188
+ "Bundled skills:",
189
+ _json_dumps(load_skill_texts(repo)),
190
+ "",
191
+ "Kaiten runtime context:",
192
+ _json_dumps(context),
193
+ "",
194
+ "Conversation messages:",
195
+ _json_dumps(messages),
196
+ "",
197
+ "Return a concise final answer only. Mention exact commands when they materially help.",
198
+ ]
199
+ return "\n".join(parts)
200
+
201
+
202
+ def build_codex_command(config: AgentGatewayConfig, last_message_path: Path) -> list[str]:
203
+ command = [
204
+ config.codex_bin,
205
+ "exec",
206
+ "--sandbox",
207
+ "read-only",
208
+ "--ephemeral",
209
+ "--ignore-user-config",
210
+ "--ignore-rules",
211
+ "--cd",
212
+ str(config.workdir),
213
+ "--output-last-message",
214
+ str(last_message_path),
215
+ ]
216
+
217
+ for extra_dir in config.extra_dirs:
218
+ command.extend(["--add-dir", str(extra_dir)])
219
+
220
+ if config.skip_git_repo_check:
221
+ command.append("--skip-git-repo-check")
222
+
223
+ if config.codex_model:
224
+ command.extend(["--model", config.codex_model])
225
+
226
+ if config.reasoning_effort:
227
+ command.extend(["-c", f'model_reasoning_effort="{config.reasoning_effort}"'])
228
+
229
+ command.append("-")
230
+ return command
231
+
232
+
233
+ def _codex_environment(environ: Mapping[str, str] | None = None) -> dict[str, str]:
234
+ source = os.environ if environ is None else environ
235
+ child_env = {key: source[key] for key in CODEX_ENV_ALLOWLIST if key in source}
236
+ child_env["KAITEN_CLI_READ_ONLY"] = "1"
237
+ child_env["KAITEN_CLI_STORAGE_READ_ONLY"] = "1"
238
+ return child_env
239
+
240
+
241
+ def _redact_child_secrets(text: str, child_env: Mapping[str, str]) -> str:
242
+ redacted = text
243
+ for key in SENSITIVE_CHILD_ENV_KEYS:
244
+ value = child_env.get(key)
245
+ if value:
246
+ redacted = redacted.replace(value, "[REDACTED]")
247
+ return redacted
248
+
249
+
250
+ def _run_process_group(
251
+ command: list[str],
252
+ *,
253
+ input: str,
254
+ text: bool,
255
+ capture_output: bool,
256
+ env: Mapping[str, str],
257
+ timeout: int,
258
+ check: bool,
259
+ ) -> subprocess.CompletedProcess[str]:
260
+ """Run Codex in its own process group so timeouts terminate normal descendants."""
261
+
262
+ popen_kwargs: dict[str, Any] = {
263
+ "stdin": subprocess.PIPE,
264
+ "stdout": subprocess.PIPE if capture_output else None,
265
+ "stderr": subprocess.PIPE if capture_output else None,
266
+ "text": text,
267
+ "env": dict(env),
268
+ }
269
+ if os.name == "posix":
270
+ popen_kwargs["start_new_session"] = True
271
+ elif os.name == "nt": # pragma: no cover - Windows-specific containment
272
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
273
+
274
+ process = subprocess.Popen(command, **popen_kwargs)
275
+ try:
276
+ stdout, stderr = process.communicate(input=input, timeout=timeout)
277
+ except subprocess.TimeoutExpired as error:
278
+ if os.name == "posix":
279
+ with contextlib.suppress(ProcessLookupError):
280
+ os.killpg(process.pid, signal.SIGKILL)
281
+ elif os.name == "nt": # pragma: no cover - Windows-specific containment
282
+ subprocess.run(
283
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
284
+ capture_output=True,
285
+ check=False,
286
+ )
287
+ else: # pragma: no cover - defensive platform fallback
288
+ process.kill()
289
+ process.communicate()
290
+ raise subprocess.TimeoutExpired(
291
+ command,
292
+ timeout,
293
+ output=error.output,
294
+ stderr=error.stderr,
295
+ ) from error
296
+
297
+ completed = subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
298
+ if check and completed.returncode:
299
+ raise subprocess.CalledProcessError(
300
+ completed.returncode,
301
+ command,
302
+ output=stdout,
303
+ stderr=stderr,
304
+ )
305
+ return completed
306
+
307
+
308
+ def run_codex(
309
+ prompt: str,
310
+ config: AgentGatewayConfig,
311
+ *,
312
+ runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
313
+ ) -> str:
314
+ child_env = _codex_environment()
315
+ effective_runner = runner or _run_process_group
316
+ with tempfile.TemporaryDirectory(prefix="kaiten-agent-") as tmpdir:
317
+ last_message_path = Path(tmpdir) / "last-message.txt"
318
+ result = effective_runner(
319
+ build_codex_command(config, last_message_path),
320
+ input=prompt,
321
+ text=True,
322
+ capture_output=True,
323
+ env=child_env,
324
+ timeout=config.timeout_seconds,
325
+ check=False,
326
+ )
327
+
328
+ if result.returncode != 0:
329
+ detail = (result.stderr or result.stdout or "").strip()
330
+ safe_detail = _redact_child_secrets(detail[-4000:], child_env)
331
+ raise RuntimeError(safe_detail or f"codex exited with {result.returncode}")
332
+
333
+ output = _safe_read_text(last_message_path)
334
+ if output and output.strip():
335
+ return _redact_child_secrets(output.strip(), child_env)
336
+
337
+ return _redact_child_secrets((result.stdout or "").strip(), child_env)
338
+
339
+
340
+ def chat_response(content: str) -> dict[str, Any]:
341
+ return {
342
+ "message": {
343
+ "role": "assistant",
344
+ "content": content,
345
+ },
346
+ "tool_runs": [
347
+ {
348
+ "type": "codex_exec",
349
+ "sandbox": "read-only",
350
+ }
351
+ ],
352
+ "artifacts": [],
353
+ "usage": {
354
+ "provider": "codex_exec",
355
+ },
356
+ }
357
+
358
+
359
+ class RequestBodyTooLarge(ValueError):
360
+ """Raised when an HTTP request exceeds the gateway's fixed body limit."""
361
+
362
+
363
+ def _read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
364
+ if handler.headers.get("Transfer-Encoding"):
365
+ raise ValueError("Transfer-Encoding request bodies are not supported")
366
+
367
+ try:
368
+ content_length = int(handler.headers.get("Content-Length") or 0)
369
+ except ValueError as error:
370
+ raise ValueError("Content-Length must be an integer") from error
371
+
372
+ if content_length < 0:
373
+ raise ValueError("Content-Length must not be negative")
374
+ if content_length > MAX_REQUEST_BODY_BYTES:
375
+ raise RequestBodyTooLarge(f"Request body exceeds the {MAX_REQUEST_BODY_BYTES}-byte limit")
376
+ if handler.headers.get_content_type() != "application/json":
377
+ raise ValueError("Content-Type must be application/json")
378
+ if content_length <= 0:
379
+ raise ValueError("JSON request body is required")
380
+
381
+ raw = handler.rfile.read(content_length)
382
+ data = json.loads(raw.decode("utf-8"))
383
+ if not isinstance(data, dict):
384
+ raise ValueError("JSON body must be an object")
385
+ return data
386
+
387
+
388
+ def _send_json(
389
+ handler: BaseHTTPRequestHandler, status: HTTPStatus, payload: dict[str, Any]
390
+ ) -> None:
391
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
392
+ handler.send_response(status.value)
393
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
394
+ handler.send_header("Content-Length", str(len(body)))
395
+ handler.end_headers()
396
+ handler.wfile.write(body)
397
+
398
+
399
+ def _authorized(handler: BaseHTTPRequestHandler, config: AgentGatewayConfig) -> bool:
400
+ if not config.bearer_token:
401
+ return True
402
+ supplied = handler.headers.get("Authorization") or ""
403
+ expected = f"Bearer {config.bearer_token}"
404
+ return hmac.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8"))
405
+
406
+
407
+ def _is_loopback_host(host: str) -> bool:
408
+ normalized = host.strip().lower()
409
+ if normalized == "localhost":
410
+ return True
411
+ if normalized.startswith("[") and normalized.endswith("]"):
412
+ normalized = normalized[1:-1]
413
+ try:
414
+ return ipaddress.ip_address(normalized).is_loopback
415
+ except ValueError:
416
+ return False
417
+
418
+
419
+ def validate_gateway_config(config: AgentGatewayConfig) -> None:
420
+ if not _is_loopback_host(config.host) and not config.bearer_token:
421
+ raise ValueError("A bearer token is required when binding to a non-loopback host")
422
+
423
+
424
+ def make_handler(config: AgentGatewayConfig) -> type[BaseHTTPRequestHandler]:
425
+ validate_gateway_config(config)
426
+ codex_slots = threading.BoundedSemaphore(MAX_CONCURRENT_CODEX_EXECUTIONS)
427
+
428
+ class AgentGatewayHandler(BaseHTTPRequestHandler):
429
+ server_version = "kaiten-agent-gateway"
430
+
431
+ def setup(self) -> None:
432
+ super().setup()
433
+ self.connection.settimeout(REQUEST_READ_TIMEOUT_SECONDS)
434
+
435
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
436
+ return
437
+
438
+ def _guard_auth(self) -> bool:
439
+ if _authorized(self, config):
440
+ return True
441
+ _send_json(self, HTTPStatus.UNAUTHORIZED, {"message": "Unauthorized"})
442
+ return False
443
+
444
+ def do_GET(self) -> None: # noqa: N802
445
+ if not self._guard_auth():
446
+ return
447
+ if self.path != "/health":
448
+ _send_json(self, HTTPStatus.NOT_FOUND, {"message": "Not found"})
449
+ return
450
+ _send_json(
451
+ self,
452
+ HTTPStatus.OK,
453
+ {
454
+ "ok": True,
455
+ "name": "kaiten-agent-gateway",
456
+ "version": __version__,
457
+ "capabilities": ["health", "chat", "kaiten-cli", "codex-exec"],
458
+ },
459
+ )
460
+
461
+ def do_POST(self) -> None: # noqa: N802
462
+ if not self._guard_auth():
463
+ return
464
+ if self.path != "/v1/chat":
465
+ _send_json(self, HTTPStatus.NOT_FOUND, {"message": "Not found"})
466
+ return
467
+
468
+ try:
469
+ payload = _read_json(self)
470
+ prompt = build_prompt(payload, repo_root=config.repo_root)
471
+ except RequestBodyTooLarge as error:
472
+ self.close_connection = True
473
+ _send_json(self, HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"message": str(error)})
474
+ return
475
+ except TimeoutError:
476
+ self.close_connection = True
477
+ _send_json(self, HTTPStatus.REQUEST_TIMEOUT, {"message": "Request body timed out"})
478
+ return
479
+ except json.JSONDecodeError as error:
480
+ _send_json(self, HTTPStatus.BAD_REQUEST, {"message": f"Invalid JSON: {error}"})
481
+ return
482
+ except ValueError as error:
483
+ _send_json(self, HTTPStatus.BAD_REQUEST, {"message": str(error)})
484
+ return
485
+
486
+ if not codex_slots.acquire(blocking=False):
487
+ _send_json(
488
+ self,
489
+ HTTPStatus.TOO_MANY_REQUESTS,
490
+ {"message": "Too many concurrent agent requests"},
491
+ )
492
+ return
493
+
494
+ try:
495
+ content = run_codex(prompt, config)
496
+ except subprocess.TimeoutExpired:
497
+ LOGGER.warning("Codex execution timed out after %s seconds", config.timeout_seconds)
498
+ _send_json(self, HTTPStatus.GATEWAY_TIMEOUT, {"message": "Agent request timed out"})
499
+ return
500
+ except Exception as error: # pragma: no cover - behavior is tested via the handler
501
+ LOGGER.error("Codex execution failed (%s)", type(error).__name__)
502
+ _send_json(self, HTTPStatus.BAD_GATEWAY, {"message": "Agent execution failed"})
503
+ return
504
+ finally:
505
+ codex_slots.release()
506
+
507
+ if not content:
508
+ _send_json(
509
+ self, HTTPStatus.BAD_GATEWAY, {"message": "Codex returned an empty response"}
510
+ )
511
+ return
512
+
513
+ _send_json(self, HTTPStatus.OK, chat_response(content))
514
+
515
+ return AgentGatewayHandler
516
+
517
+
518
+ def config_from_args(args: argparse.Namespace) -> AgentGatewayConfig:
519
+ repo_root = Path(
520
+ args.repo_root or os.environ.get("KAITEN_AGENT_REPO_ROOT") or default_repo_root()
521
+ )
522
+ workdir = Path(args.workdir or os.environ.get("KAITEN_AGENT_WORKDIR") or repo_root)
523
+ extra_dir_values = list(args.add_dir or [])
524
+ env_extra_dirs = os.environ.get("KAITEN_AGENT_EXTRA_DIRS")
525
+ if env_extra_dirs:
526
+ extra_dir_values.extend(env_extra_dirs.split(os.pathsep))
527
+
528
+ return AgentGatewayConfig(
529
+ host=args.host,
530
+ port=args.port,
531
+ codex_bin=args.codex_bin or os.environ.get("KAITEN_AGENT_CODEX_BIN") or DEFAULT_CODEX_BIN,
532
+ codex_model=args.codex_model or os.environ.get("KAITEN_AGENT_CODEX_MODEL") or None,
533
+ reasoning_effort=(
534
+ args.reasoning_effort
535
+ or os.environ.get("KAITEN_AGENT_CODEX_REASONING_EFFORT")
536
+ or DEFAULT_REASONING_EFFORT
537
+ ),
538
+ workdir=workdir.resolve(),
539
+ extra_dirs=tuple(Path(value).expanduser().resolve() for value in extra_dir_values if value),
540
+ skip_git_repo_check=args.skip_git_repo_check,
541
+ timeout_seconds=int(
542
+ args.timeout_seconds
543
+ or os.environ.get("KAITEN_AGENT_TIMEOUT_SECONDS")
544
+ or DEFAULT_TIMEOUT_SECONDS
545
+ ),
546
+ bearer_token=args.bearer_token or os.environ.get("KAITEN_AGENT_BEARER_TOKEN") or None,
547
+ repo_root=repo_root.resolve(),
548
+ )
549
+
550
+
551
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
552
+ parser = argparse.ArgumentParser(description="Run a local Kaiten Agent HTTP gateway.")
553
+ parser.add_argument("--host", default=os.environ.get("KAITEN_AGENT_HOST", DEFAULT_HOST))
554
+ parser.add_argument(
555
+ "--port", type=int, default=int(os.environ.get("KAITEN_AGENT_PORT", DEFAULT_PORT))
556
+ )
557
+ parser.add_argument("--codex-bin", default=None)
558
+ parser.add_argument("--codex-model", default=None)
559
+ parser.add_argument("--reasoning-effort", default=None)
560
+ parser.add_argument("--workdir", default=None)
561
+ parser.add_argument("--add-dir", action="append", default=None)
562
+ parser.add_argument("--skip-git-repo-check", action="store_true")
563
+ parser.add_argument("--repo-root", default=None)
564
+ parser.add_argument("--timeout-seconds", type=int, default=None)
565
+ parser.add_argument("--bearer-token", default=None)
566
+ return parser.parse_args(argv)
567
+
568
+
569
+ def main(argv: list[str] | None = None) -> None:
570
+ config = config_from_args(parse_args(argv))
571
+ server = ThreadingHTTPServer((config.host, config.port), make_handler(config))
572
+ print(f"kaiten-agent-gateway listening on http://{config.host}:{config.port}", flush=True)
573
+ server.serve_forever()
574
+
575
+
576
+ if __name__ == "__main__":
577
+ main()