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 +20 -0
- kirox/_version.py +3 -0
- kirox/cli/__init__.py +387 -0
- kirox/core/__init__.py +1 -0
- kirox/core/auth.py +358 -0
- kirox/core/client.py +262 -0
- kirox/core/errors.py +27 -0
- kirox/core/eventstream.py +254 -0
- kirox/core/models.py +163 -0
- kirox/mcp/__init__.py +1 -0
- kirox/mcp/server.py +172 -0
- kirox/py.typed +0 -0
- kirox/service/__init__.py +1 -0
- kirox/service/_http_adapter.py +170 -0
- kirox/service/daemon.py +234 -0
- kirox/service/process_identity.py +299 -0
- kirox/service/scheduler.py +136 -0
- kirox/service/server.py +639 -0
- kirox/service/state.py +194 -0
- kirox/service/tray.py +113 -0
- kirox/utils/__init__.py +6 -0
- kirox/utils/config.py +52 -0
- kirox/utils/logging.py +30 -0
- kirox-1.1.0.dist-info/METADATA +188 -0
- kirox-1.1.0.dist-info/RECORD +28 -0
- kirox-1.1.0.dist-info/WHEEL +4 -0
- kirox-1.1.0.dist-info/entry_points.txt +3 -0
- kirox-1.1.0.dist-info/licenses/LICENSE +21 -0
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
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."""
|