kirox 1.1.0__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.
kirox/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Kirox — Production-ready SDK for AI coding assistants."""
2
+
3
+ from kirox._version import __version__
4
+ from kirox.core.client import AssistantClient
5
+ from kirox.core.errors import APIError, AuthenticationError, KiroxError
6
+ from kirox.core.eventstream import EventStreamDecoder, parse_eventstream
7
+ from kirox.core.models import ModelInfo, StreamEvent, ToolSpec
8
+
9
+ __all__ = [
10
+ "__version__",
11
+ "AssistantClient",
12
+ "EventStreamDecoder",
13
+ "parse_eventstream",
14
+ "ModelInfo",
15
+ "StreamEvent",
16
+ "ToolSpec",
17
+ "KiroxError",
18
+ "APIError",
19
+ "AuthenticationError",
20
+ ]
kirox/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version information."""
2
+
3
+ __version__ = "1.1.0"
kirox/cli/__init__.py ADDED
@@ -0,0 +1,387 @@
1
+ """CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from types import FrameType
14
+ from typing import Any, Optional
15
+
16
+ from kirox._version import __version__
17
+
18
+ UPDATE_CHECK_INTERVAL = 3600
19
+ UPDATE_CACHE_FILE = Path.home() / ".kirox" / ".update_cache"
20
+ STOP_TIMEOUT = 5.0
21
+ STOP_POLL_INTERVAL = 0.1
22
+
23
+
24
+ def _get_latest_version() -> Optional[str]:
25
+ try:
26
+ import httpx
27
+
28
+ response = httpx.get(
29
+ "https://pypi.org/pypi/kirox/json",
30
+ timeout=5,
31
+ follow_redirects=True,
32
+ )
33
+ if response.status_code == 200:
34
+ return response.json()["info"]["version"]
35
+ except Exception:
36
+ pass
37
+ return None
38
+
39
+
40
+ def _should_check_update() -> bool:
41
+ if not UPDATE_CACHE_FILE.exists():
42
+ return True
43
+ try:
44
+ last_check = float(UPDATE_CACHE_FILE.read_text().strip())
45
+ return (time.time() - last_check) > UPDATE_CHECK_INTERVAL
46
+ except Exception:
47
+ return True
48
+
49
+
50
+ def _update_cache() -> None:
51
+ UPDATE_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
52
+ UPDATE_CACHE_FILE.write_text(str(time.time()))
53
+
54
+
55
+ def _check_update(silent: bool = False) -> Optional[str]:
56
+ if not _should_check_update():
57
+ return None
58
+ latest = _get_latest_version()
59
+ _update_cache()
60
+ if latest and latest != __version__:
61
+ if not silent:
62
+ print(f"\n Update available: {__version__} -> {latest}")
63
+ print(" Run: pip install --upgrade kirox\n")
64
+ return latest
65
+ return None
66
+
67
+
68
+ def _do_update() -> None:
69
+ print("Updating kirox...")
70
+ try:
71
+ subprocess.run(
72
+ [sys.executable, "-m", "pip", "install", "--upgrade", "kirox"],
73
+ check=True,
74
+ )
75
+ print("Update complete!")
76
+ except subprocess.CalledProcessError as error:
77
+ print(f"Update failed: {error}")
78
+
79
+
80
+ def cmd_run(args: argparse.Namespace) -> None:
81
+ from kirox.service.daemon import KiroxService
82
+ from kirox.utils.config import load_config
83
+ from kirox.utils.logging import setup_logging
84
+
85
+ config = load_config()
86
+ log_file = Path(config.log_file).expanduser() if config.log_file else None
87
+ setup_logging(
88
+ level="DEBUG" if args.verbose else config.log_level,
89
+ log_file=log_file,
90
+ )
91
+ if not args.no_update:
92
+ threading.Thread(target=_check_update, daemon=True).start()
93
+
94
+ service = KiroxService(config)
95
+ try:
96
+ service.start()
97
+ print(f"Kirox v{__version__} running on {service.url}")
98
+ print("Press Ctrl+C to stop\n")
99
+ if args.no_tray:
100
+ _wait_for_interrupt(service)
101
+ return
102
+
103
+ from kirox.service.tray import TRAY_UNAVAILABLE_MESSAGE, KiroTray
104
+
105
+ if not KiroTray(config, service=service).start():
106
+ print(TRAY_UNAVAILABLE_MESSAGE)
107
+ _wait_for_interrupt(service)
108
+ finally:
109
+ service.stop()
110
+
111
+
112
+ def _wait_for_interrupt(service: Any) -> None:
113
+ previous_handlers: dict[signal.Signals, Any] = {}
114
+
115
+ def handler(signum: int, frame: Optional[FrameType]) -> None:
116
+ del signum, frame
117
+ print("\nStopping...")
118
+ service.stop()
119
+
120
+ if threading.current_thread() is threading.main_thread():
121
+ for signum in (signal.SIGINT, signal.SIGTERM):
122
+ previous_handlers[signum] = signal.getsignal(signum)
123
+ signal.signal(signum, handler)
124
+ try:
125
+ service.wait()
126
+ except KeyboardInterrupt:
127
+ service.stop()
128
+ finally:
129
+ for signum, previous in previous_handlers.items():
130
+ signal.signal(signum, previous)
131
+
132
+
133
+ def cmd_status(args: argparse.Namespace) -> None:
134
+ del args
135
+ import httpx
136
+
137
+ from kirox.service.state import read_state
138
+
139
+ latest = _check_update(silent=True)
140
+ state = read_state()
141
+ print(f"Kirox v{__version__}")
142
+ if latest:
143
+ print(f"Update available: {latest}")
144
+ print()
145
+ if state is None:
146
+ print("Status: STOPPED")
147
+ return
148
+
149
+ try:
150
+ response = httpx.get(f"{state.url}/health", timeout=5)
151
+ if response.status_code != 200:
152
+ print("Status: ERROR")
153
+ return
154
+ print("Status: RUNNING")
155
+ print(f"PID: {state.pid}")
156
+ print(f"URL: {state.url}")
157
+ token_response = httpx.get(f"{state.url}/api/token/status", timeout=5)
158
+ if token_response.status_code == 200:
159
+ data = token_response.json()
160
+ print(f"Auth: {'OK' if data.get('authenticated') else 'NO'}")
161
+ print(f"Profile: {'OK' if data.get('has_profile') else 'NO'}")
162
+ except Exception:
163
+ print("Status: STOPPED")
164
+
165
+
166
+ def _wait_until_stopped(state: Any, timeout: float) -> bool:
167
+ import httpx
168
+
169
+ from kirox.service.process_identity import (
170
+ ProcessIdentityUnavailable,
171
+ capture_process_identity,
172
+ )
173
+ from kirox.service.state import clear_state, read_state
174
+
175
+ deadline = time.monotonic() + max(0.0, timeout)
176
+ while True:
177
+ current = read_state()
178
+ if current is None or current != state:
179
+ return True
180
+ try:
181
+ httpx.get(f"{state.url}/health", timeout=0.5)
182
+ except Exception:
183
+ if state.process_identity is not None:
184
+ try:
185
+ actual_identity = capture_process_identity(state.pid)
186
+ except ProcessLookupError:
187
+ clear_state(state)
188
+ return True
189
+ except (OSError, ProcessIdentityUnavailable):
190
+ pass
191
+ else:
192
+ if actual_identity != state.process_identity:
193
+ clear_state(state)
194
+ return True
195
+ if time.monotonic() >= deadline:
196
+ return False
197
+ time.sleep(STOP_POLL_INTERVAL)
198
+
199
+
200
+ def _force_stop_pid(pid: int, process_identity: Any) -> None:
201
+ from kirox.service.process_identity import terminate_process
202
+
203
+ if type(pid) is not int or pid <= 0:
204
+ raise ValueError("refusing to stop an invalid PID")
205
+ if pid == os.getpid():
206
+ raise RuntimeError("refusing to force-stop the current process")
207
+ terminate_process(pid, process_identity)
208
+
209
+
210
+ def cmd_stop(args: argparse.Namespace) -> int:
211
+ import httpx
212
+
213
+ from kirox.service.server import CONTROL_SHUTDOWN_PATH, CONTROL_TOKEN_HEADER
214
+ from kirox.service.state import clear_state, read_state
215
+
216
+ state = read_state()
217
+ if state is None:
218
+ print("Kirox is not running")
219
+ return 0
220
+
221
+ print("Stopping kirox...")
222
+ graceful_accepted = False
223
+ try:
224
+ response = httpx.post(
225
+ f"{state.url}{CONTROL_SHUTDOWN_PATH}",
226
+ headers={CONTROL_TOKEN_HEADER: state.control_token},
227
+ timeout=5,
228
+ )
229
+ graceful_accepted = 200 <= response.status_code < 300
230
+ except Exception:
231
+ graceful_accepted = True
232
+
233
+ if graceful_accepted and _wait_until_stopped(state, STOP_TIMEOUT):
234
+ print("Stopped")
235
+ return 0
236
+
237
+ if not getattr(args, "force", False):
238
+ print("Graceful stop failed; retry with --force", file=sys.stderr)
239
+ return 1
240
+
241
+ current = read_state()
242
+ if current is None:
243
+ print("Stopped")
244
+ return 0
245
+ if current != state:
246
+ print("Service ownership changed; refusing to force-stop", file=sys.stderr)
247
+ return 1
248
+ if current.process_identity is None:
249
+ print(
250
+ "Force stop failed: service state has no verifiable process identity",
251
+ file=sys.stderr,
252
+ )
253
+ return 1
254
+
255
+ try:
256
+ _force_stop_pid(current.pid, current.process_identity)
257
+ except ProcessLookupError:
258
+ clear_state(current)
259
+ print("Stopped")
260
+ return 0
261
+ except (OSError, RuntimeError, ValueError) as error:
262
+ print(f"Force stop failed: {error}", file=sys.stderr)
263
+ return 1
264
+
265
+ if _wait_until_stopped(current, STOP_TIMEOUT):
266
+ print("Stopped")
267
+ return 0
268
+ print("Force stop timed out", file=sys.stderr)
269
+ return 1
270
+
271
+
272
+ def cmd_update(args: argparse.Namespace) -> None:
273
+ latest = _get_latest_version()
274
+ if latest == __version__:
275
+ print(f"Already up to date (v{__version__})")
276
+ return
277
+ if latest:
278
+ print(f"New version: {__version__} -> {latest}")
279
+ if not args.yes and input("Update now? [y/N] ").strip().lower() != "y":
280
+ print("Cancelled")
281
+ return
282
+ _do_update()
283
+
284
+
285
+ def cmd_models(args: argparse.Namespace) -> None:
286
+ del args
287
+ from kirox.core.client import AssistantClient
288
+
289
+ client = AssistantClient.auto()
290
+ try:
291
+ models = client.list_models()
292
+ print(f"{'Model ID':<25} {'Name':<20} {'Rate':>6} {'Thinking':>8}")
293
+ print("-" * 65)
294
+ for model in models:
295
+ thinking = "yes" if model.supports_thinking else "-"
296
+ print(
297
+ f"{model.model_id:<25} {model.model_name:<20} "
298
+ f"{model.rate_multiplier:>5.1f}x {thinking:>8}"
299
+ )
300
+ finally:
301
+ client.close()
302
+
303
+
304
+ def cmd_chat(args: argparse.Namespace) -> None:
305
+ from kirox.core.client import AssistantClient
306
+
307
+ client = AssistantClient.auto()
308
+ try:
309
+ print(f"Kirox Chat (model: {args.model})\nType 'quit' to exit.\n")
310
+ while True:
311
+ try:
312
+ message = input("You: ").strip()
313
+ except EOFError:
314
+ break
315
+ if not message or message.lower() in ("quit", "exit", "q"):
316
+ break
317
+ print("AI: ", end="", flush=True)
318
+ for event in client.chat(message, model_id=args.model):
319
+ if event.content:
320
+ print(event.content, end="", flush=True)
321
+ print("\n")
322
+ except KeyboardInterrupt:
323
+ print("\nBye!")
324
+ finally:
325
+ client.close()
326
+
327
+
328
+ def cmd_ask(args: argparse.Namespace) -> None:
329
+ from kirox.core.client import AssistantClient
330
+
331
+ client = AssistantClient.auto()
332
+ try:
333
+ message = args.message or sys.stdin.read()
334
+ print(client.chat_simple(message, model_id=args.model))
335
+ finally:
336
+ client.close()
337
+
338
+
339
+ def create_parser() -> argparse.ArgumentParser:
340
+ parser = argparse.ArgumentParser(prog="kirox", description="Kirox — AI coding assistant SDK")
341
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
342
+ subparsers = parser.add_subparsers(dest="command")
343
+ run_parser = subparsers.add_parser("run", help="Start service + tray (default)")
344
+ run_parser.add_argument("--no-tray", action="store_true")
345
+ run_parser.add_argument("--no-update", action="store_true")
346
+ run_parser.add_argument("-v", "--verbose", action="store_true")
347
+ subparsers.add_parser("status", help="Check status")
348
+ stop_parser = subparsers.add_parser("stop", help="Stop service")
349
+ stop_parser.add_argument("--force", action="store_true")
350
+ subparsers.add_parser("models", help="List models")
351
+ update_parser = subparsers.add_parser("update", help="Update to latest")
352
+ update_parser.add_argument("-y", "--yes", action="store_true")
353
+ chat_parser = subparsers.add_parser("chat", help="Interactive chat")
354
+ chat_parser.add_argument("-m", "--model", default="auto")
355
+ ask_parser = subparsers.add_parser("ask", help="One-shot question")
356
+ ask_parser.add_argument("message", nargs="?")
357
+ ask_parser.add_argument("-m", "--model", default="auto")
358
+ return parser
359
+
360
+
361
+ def main(argv: Optional[list[str]] = None) -> Optional[int]:
362
+ args = create_parser().parse_args(argv)
363
+ if not args.command:
364
+ args.command = "run"
365
+ args.no_tray = False
366
+ args.no_update = False
367
+ args.verbose = False
368
+ commands = {
369
+ "run": cmd_run,
370
+ "status": cmd_status,
371
+ "stop": cmd_stop,
372
+ "models": cmd_models,
373
+ "chat": cmd_chat,
374
+ "ask": cmd_ask,
375
+ "update": cmd_update,
376
+ }
377
+ try:
378
+ return commands[args.command](args)
379
+ except KeyboardInterrupt:
380
+ return 130
381
+ except Exception as error:
382
+ print(f"Error: {error}", file=sys.stderr)
383
+ return 1
384
+
385
+
386
+ if __name__ == "__main__":
387
+ sys.exit(main())
kirox/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core module."""