kairo-code 0.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.
- image-service/main.py +178 -0
- infra/chat/app/main.py +84 -0
- kairo/backend/__init__.py +0 -0
- kairo/backend/api/__init__.py +0 -0
- kairo/backend/api/admin/__init__.py +23 -0
- kairo/backend/api/admin/audit.py +54 -0
- kairo/backend/api/admin/content.py +142 -0
- kairo/backend/api/admin/incidents.py +148 -0
- kairo/backend/api/admin/stats.py +125 -0
- kairo/backend/api/admin/system.py +87 -0
- kairo/backend/api/admin/users.py +279 -0
- kairo/backend/api/agents.py +94 -0
- kairo/backend/api/api_keys.py +85 -0
- kairo/backend/api/auth.py +116 -0
- kairo/backend/api/billing.py +41 -0
- kairo/backend/api/chat.py +72 -0
- kairo/backend/api/conversations.py +125 -0
- kairo/backend/api/device_auth.py +100 -0
- kairo/backend/api/files.py +83 -0
- kairo/backend/api/health.py +36 -0
- kairo/backend/api/images.py +80 -0
- kairo/backend/api/openai_compat.py +225 -0
- kairo/backend/api/projects.py +102 -0
- kairo/backend/api/usage.py +32 -0
- kairo/backend/api/webhooks.py +79 -0
- kairo/backend/app.py +297 -0
- kairo/backend/config.py +179 -0
- kairo/backend/core/__init__.py +0 -0
- kairo/backend/core/admin_auth.py +24 -0
- kairo/backend/core/api_key_auth.py +55 -0
- kairo/backend/core/database.py +28 -0
- kairo/backend/core/dependencies.py +70 -0
- kairo/backend/core/logging.py +23 -0
- kairo/backend/core/rate_limit.py +73 -0
- kairo/backend/core/security.py +29 -0
- kairo/backend/models/__init__.py +19 -0
- kairo/backend/models/agent.py +30 -0
- kairo/backend/models/api_key.py +25 -0
- kairo/backend/models/api_usage.py +29 -0
- kairo/backend/models/audit_log.py +26 -0
- kairo/backend/models/conversation.py +48 -0
- kairo/backend/models/device_code.py +30 -0
- kairo/backend/models/feature_flag.py +21 -0
- kairo/backend/models/image_generation.py +24 -0
- kairo/backend/models/incident.py +28 -0
- kairo/backend/models/project.py +28 -0
- kairo/backend/models/uptime_record.py +24 -0
- kairo/backend/models/usage.py +24 -0
- kairo/backend/models/user.py +49 -0
- kairo/backend/schemas/__init__.py +0 -0
- kairo/backend/schemas/admin/__init__.py +0 -0
- kairo/backend/schemas/admin/audit.py +28 -0
- kairo/backend/schemas/admin/content.py +53 -0
- kairo/backend/schemas/admin/stats.py +77 -0
- kairo/backend/schemas/admin/system.py +44 -0
- kairo/backend/schemas/admin/users.py +48 -0
- kairo/backend/schemas/agent.py +42 -0
- kairo/backend/schemas/api_key.py +30 -0
- kairo/backend/schemas/auth.py +57 -0
- kairo/backend/schemas/chat.py +26 -0
- kairo/backend/schemas/conversation.py +39 -0
- kairo/backend/schemas/device_auth.py +40 -0
- kairo/backend/schemas/image.py +15 -0
- kairo/backend/schemas/openai_compat.py +76 -0
- kairo/backend/schemas/project.py +21 -0
- kairo/backend/schemas/status.py +81 -0
- kairo/backend/schemas/usage.py +15 -0
- kairo/backend/services/__init__.py +0 -0
- kairo/backend/services/admin/__init__.py +0 -0
- kairo/backend/services/admin/audit_service.py +78 -0
- kairo/backend/services/admin/content_service.py +119 -0
- kairo/backend/services/admin/incident_service.py +94 -0
- kairo/backend/services/admin/stats_service.py +281 -0
- kairo/backend/services/admin/system_service.py +126 -0
- kairo/backend/services/admin/user_service.py +157 -0
- kairo/backend/services/agent_service.py +107 -0
- kairo/backend/services/api_key_service.py +66 -0
- kairo/backend/services/api_usage_service.py +126 -0
- kairo/backend/services/auth_service.py +101 -0
- kairo/backend/services/chat_service.py +501 -0
- kairo/backend/services/conversation_service.py +264 -0
- kairo/backend/services/device_auth_service.py +193 -0
- kairo/backend/services/email_service.py +55 -0
- kairo/backend/services/image_service.py +181 -0
- kairo/backend/services/llm_service.py +186 -0
- kairo/backend/services/project_service.py +109 -0
- kairo/backend/services/status_service.py +167 -0
- kairo/backend/services/stripe_service.py +78 -0
- kairo/backend/services/usage_service.py +150 -0
- kairo/backend/services/web_search_service.py +96 -0
- kairo/migrations/env.py +60 -0
- kairo/migrations/versions/001_initial.py +55 -0
- kairo/migrations/versions/002_usage_tracking_and_indexes.py +66 -0
- kairo/migrations/versions/003_username_to_email.py +21 -0
- kairo/migrations/versions/004_add_plans_and_verification.py +67 -0
- kairo/migrations/versions/005_add_projects.py +52 -0
- kairo/migrations/versions/006_add_image_generation.py +63 -0
- kairo/migrations/versions/007_add_admin_portal.py +107 -0
- kairo/migrations/versions/008_add_device_code_auth.py +76 -0
- kairo/migrations/versions/009_add_status_page.py +65 -0
- kairo/tools/extract_claude_data.py +465 -0
- kairo/tools/filter_claude_data.py +303 -0
- kairo/tools/generate_curated_data.py +157 -0
- kairo/tools/mix_training_data.py +295 -0
- kairo_code/__init__.py +3 -0
- kairo_code/agents/__init__.py +25 -0
- kairo_code/agents/architect.py +98 -0
- kairo_code/agents/audit.py +100 -0
- kairo_code/agents/base.py +463 -0
- kairo_code/agents/coder.py +155 -0
- kairo_code/agents/database.py +77 -0
- kairo_code/agents/docs.py +88 -0
- kairo_code/agents/explorer.py +62 -0
- kairo_code/agents/guardian.py +80 -0
- kairo_code/agents/planner.py +66 -0
- kairo_code/agents/reviewer.py +91 -0
- kairo_code/agents/security.py +94 -0
- kairo_code/agents/terraform.py +88 -0
- kairo_code/agents/testing.py +97 -0
- kairo_code/agents/uiux.py +88 -0
- kairo_code/auth.py +232 -0
- kairo_code/config.py +172 -0
- kairo_code/conversation.py +173 -0
- kairo_code/heartbeat.py +63 -0
- kairo_code/llm.py +291 -0
- kairo_code/logging_config.py +156 -0
- kairo_code/main.py +818 -0
- kairo_code/router.py +217 -0
- kairo_code/sandbox.py +248 -0
- kairo_code/settings.py +183 -0
- kairo_code/tools/__init__.py +51 -0
- kairo_code/tools/analysis.py +509 -0
- kairo_code/tools/base.py +417 -0
- kairo_code/tools/code.py +58 -0
- kairo_code/tools/definitions.py +617 -0
- kairo_code/tools/files.py +315 -0
- kairo_code/tools/review.py +390 -0
- kairo_code/tools/search.py +185 -0
- kairo_code/ui.py +418 -0
- kairo_code-0.1.0.dist-info/METADATA +13 -0
- kairo_code-0.1.0.dist-info/RECORD +144 -0
- kairo_code-0.1.0.dist-info/WHEEL +5 -0
- kairo_code-0.1.0.dist-info/entry_points.txt +2 -0
- kairo_code-0.1.0.dist-info/top_level.txt +4 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""UI/UX engineer agent — evaluates interfaces for usability and accessibility.
|
|
2
|
+
|
|
3
|
+
Mirrors the ui-ux-engineer Claude Code agent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .base import Agent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UiUxAgent(Agent):
|
|
10
|
+
name = "uiux"
|
|
11
|
+
description = "Evaluates UI/UX for usability, accessibility, and design best practices"
|
|
12
|
+
max_iterations = 15
|
|
13
|
+
require_confirmation = []
|
|
14
|
+
|
|
15
|
+
def get_system_prompt(self) -> str:
|
|
16
|
+
tools_desc = self.tools.format_for_prompt()
|
|
17
|
+
|
|
18
|
+
return f"""You are the UI/UX Engineer Agent — an expert in user interface design, interaction patterns, usability principles, and accessibility standards. You serve as the project's UX guardian.
|
|
19
|
+
|
|
20
|
+
{tools_desc}
|
|
21
|
+
|
|
22
|
+
## Your Expertise
|
|
23
|
+
- **Interaction Design**: Affordances, feedback, progressive disclosure, micro-interactions
|
|
24
|
+
- **Visual Design**: Hierarchy, proximity, alignment, contrast, repetition, white space
|
|
25
|
+
- **Accessibility**: WCAG 2.1/2.2, ARIA patterns, keyboard navigation, screen readers
|
|
26
|
+
- **Responsive Design**: Mobile-first, breakpoints, touch targets, adaptive layouts
|
|
27
|
+
- **Design Systems**: Component consistency, pattern libraries, design tokens
|
|
28
|
+
- **Cognitive Psychology**: Mental models, cognitive load, recognition over recall
|
|
29
|
+
|
|
30
|
+
## Evaluation Framework
|
|
31
|
+
|
|
32
|
+
### 1. Usability & Clarity
|
|
33
|
+
- Is purpose immediately clear? Are labels self-explanatory?
|
|
34
|
+
- Appropriate feedback for user actions?
|
|
35
|
+
- Error messages helpful and actionable?
|
|
36
|
+
- Cognitive load minimized?
|
|
37
|
+
|
|
38
|
+
### 2. Visual Hierarchy & Layout
|
|
39
|
+
- Does hierarchy guide attention appropriately?
|
|
40
|
+
- Consistent, purposeful spacing? Precise alignment?
|
|
41
|
+
- Natural reading patterns (F-pattern, Z-pattern)?
|
|
42
|
+
|
|
43
|
+
### 3. Consistency
|
|
44
|
+
- Similar elements behave similarly?
|
|
45
|
+
- Interaction patterns consistent across the interface?
|
|
46
|
+
- Aligned with project design system?
|
|
47
|
+
|
|
48
|
+
### 4. Accessibility
|
|
49
|
+
- Semantic HTML used appropriately?
|
|
50
|
+
- ARIA roles and attributes correct?
|
|
51
|
+
- Color contrast sufficient (4.5:1 text, 3:1 UI)?
|
|
52
|
+
- Keyboard navigation logical and complete?
|
|
53
|
+
- Focus states visible and meaningful?
|
|
54
|
+
- Form fields properly labeled?
|
|
55
|
+
|
|
56
|
+
### 5. Interaction States
|
|
57
|
+
- All states handled: default, hover, focus, active, disabled?
|
|
58
|
+
- Loading, empty, error, success states present?
|
|
59
|
+
|
|
60
|
+
### 6. Responsiveness
|
|
61
|
+
- Layout adapts across breakpoints?
|
|
62
|
+
- Touch targets >= 44x44px?
|
|
63
|
+
|
|
64
|
+
## TOOL FORMAT
|
|
65
|
+
<tool>tool_name</tool>
|
|
66
|
+
<params>{{"key": "value"}}</params>
|
|
67
|
+
|
|
68
|
+
## Output Format
|
|
69
|
+
|
|
70
|
+
**Summary**: Brief overview of UI/UX quality and main findings.
|
|
71
|
+
|
|
72
|
+
**Critical Issues**: Problems significantly harming usability or accessibility.
|
|
73
|
+
- Issue, user impact, recommended fix with code if applicable.
|
|
74
|
+
|
|
75
|
+
**Improvements**: Opportunities to enhance the experience.
|
|
76
|
+
- Current vs. recommended state, rationale, implementation suggestion.
|
|
77
|
+
|
|
78
|
+
**Strengths**: What's working well.
|
|
79
|
+
|
|
80
|
+
## Reference Heuristics
|
|
81
|
+
- **Nielsen's 10**: Visibility, match with real world, user control, consistency, error prevention, recognition, flexibility, aesthetic design, error recovery, help
|
|
82
|
+
- **Gestalt Principles**: Proximity, similarity, continuity, closure, figure-ground
|
|
83
|
+
- **Fitts's Law**: Important targets large and close
|
|
84
|
+
- **Hick's Law**: Minimize choices to reduce decision time
|
|
85
|
+
- **Miller's Law**: Chunk information (7 plus or minus 2 items)"""
|
|
86
|
+
|
|
87
|
+
def get_tool_examples(self) -> str:
|
|
88
|
+
return ""
|
kairo_code/auth.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Browser-based authentication for Kairo Code CLI.
|
|
2
|
+
|
|
3
|
+
Implements RFC 8628 Device Code flow:
|
|
4
|
+
1. Request a device code from the Kairo backend
|
|
5
|
+
2. Open browser for user approval
|
|
6
|
+
3. Poll for token until approved/denied/expired
|
|
7
|
+
4. Save credentials locally with restrictive permissions
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import yaml
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
CREDENTIALS_DIR = Path.home() / ".kairo_code"
|
|
24
|
+
CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.yaml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Credential storage
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def load_credentials() -> dict[str, Any] | None:
|
|
32
|
+
"""Load saved CLI credentials. Returns dict with api_key, email, plan or None."""
|
|
33
|
+
if not CREDENTIALS_FILE.exists():
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
with open(CREDENTIALS_FILE) as f:
|
|
37
|
+
data = yaml.safe_load(f)
|
|
38
|
+
if data and data.get("api_key"):
|
|
39
|
+
return data
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_credentials(api_key: str, email: str, plan: str) -> None:
|
|
46
|
+
"""Save CLI credentials with restrictive permissions (0600)."""
|
|
47
|
+
CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
data = {"api_key": api_key, "email": email, "plan": plan}
|
|
49
|
+
with open(CREDENTIALS_FILE, "w") as f:
|
|
50
|
+
yaml.dump(data, f, default_flow_style=False)
|
|
51
|
+
CREDENTIALS_FILE.chmod(0o600)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clear_credentials() -> None:
|
|
55
|
+
"""Delete saved credentials."""
|
|
56
|
+
if CREDENTIALS_FILE.exists():
|
|
57
|
+
CREDENTIALS_FILE.unlink()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_api_key() -> str | None:
|
|
61
|
+
"""Get saved API key, or None if not authenticated."""
|
|
62
|
+
creds = load_credentials()
|
|
63
|
+
return creds["api_key"] if creds else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Device code authentication flow
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _derive_base_url(cloud_endpoint: str) -> str:
|
|
71
|
+
"""Strip /v1 suffix to get the base API URL."""
|
|
72
|
+
base_url = cloud_endpoint.rstrip("/")
|
|
73
|
+
if base_url.endswith("/v1"):
|
|
74
|
+
base_url = base_url[:-3]
|
|
75
|
+
return base_url
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _request_device_code(base_url: str) -> dict | None:
|
|
79
|
+
"""POST /api/auth/device/code to obtain a device code."""
|
|
80
|
+
try:
|
|
81
|
+
resp = httpx.post(
|
|
82
|
+
f"{base_url}/api/auth/device/code",
|
|
83
|
+
json={"client_name": "kairo-code"},
|
|
84
|
+
timeout=10,
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
return resp.json()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
console.print(f"[red]Failed to start authentication: {e}[/]")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _display_verification_prompt(verification_uri_complete: str, user_code: str) -> None:
|
|
94
|
+
"""Show the verification URL and user code, then open the browser."""
|
|
95
|
+
console.print()
|
|
96
|
+
console.print(Panel(
|
|
97
|
+
f"[bold]To authenticate, open this URL:[/]\n\n"
|
|
98
|
+
f" [cyan underline]{verification_uri_complete}[/]\n\n"
|
|
99
|
+
f"[bold]Then confirm this code:[/] [bold yellow]{user_code}[/]",
|
|
100
|
+
title="[bold]Kairo Code -- Authentication[/]",
|
|
101
|
+
border_style="cyan",
|
|
102
|
+
padding=(1, 3),
|
|
103
|
+
))
|
|
104
|
+
console.print()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
webbrowser.open(verification_uri_complete)
|
|
108
|
+
console.print("[dim]Browser opened. If it didn't open, copy the URL above.[/]")
|
|
109
|
+
except Exception:
|
|
110
|
+
console.print("[dim]Copy the URL above and open it in your browser.[/]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _poll_for_token(base_url: str, device_code: str, expires_in: int, interval: int) -> dict | None:
|
|
114
|
+
"""Poll /api/auth/device/token until approval, denial, or timeout.
|
|
115
|
+
|
|
116
|
+
Returns the token response dict on success, or None on failure.
|
|
117
|
+
"""
|
|
118
|
+
console.print("[dim]Waiting for approval... (press Ctrl+C to cancel)[/]\n")
|
|
119
|
+
deadline = time.time() + expires_in
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
while time.time() < deadline:
|
|
123
|
+
time.sleep(interval)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
resp = httpx.post(
|
|
127
|
+
f"{base_url}/api/auth/device/token",
|
|
128
|
+
json={"device_code": device_code},
|
|
129
|
+
timeout=10,
|
|
130
|
+
)
|
|
131
|
+
except Exception:
|
|
132
|
+
continue # Network error, retry
|
|
133
|
+
|
|
134
|
+
if resp.status_code == 200:
|
|
135
|
+
return resp.json()
|
|
136
|
+
|
|
137
|
+
if resp.status_code == 400:
|
|
138
|
+
error_code = _extract_error_code(resp)
|
|
139
|
+
|
|
140
|
+
if error_code == "authorization_pending":
|
|
141
|
+
continue
|
|
142
|
+
elif error_code == "slow_down":
|
|
143
|
+
interval += 1
|
|
144
|
+
continue
|
|
145
|
+
elif error_code == "expired_token":
|
|
146
|
+
console.print("[red]Authentication expired. Please try again.[/]")
|
|
147
|
+
return None
|
|
148
|
+
elif error_code == "access_denied":
|
|
149
|
+
console.print("[red]Access denied. Kairo Code requires a Max plan.[/]")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
console.print("[red]Authentication timed out. Please try again.[/]")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
except KeyboardInterrupt:
|
|
156
|
+
console.print("\n[dim]Authentication cancelled.[/]")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _extract_error_code(resp: httpx.Response) -> str:
|
|
161
|
+
"""Extract the error code string from a 400 response."""
|
|
162
|
+
try:
|
|
163
|
+
error_data = resp.json()
|
|
164
|
+
error = error_data.get("detail", {})
|
|
165
|
+
if isinstance(error, dict):
|
|
166
|
+
return error.get("error", "")
|
|
167
|
+
return str(error)
|
|
168
|
+
except Exception:
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Public API
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def authenticate(cloud_endpoint: str) -> str | None:
|
|
177
|
+
"""Run the full device code auth flow.
|
|
178
|
+
|
|
179
|
+
Returns the API key on success, or None on failure/cancellation.
|
|
180
|
+
"""
|
|
181
|
+
base_url = _derive_base_url(cloud_endpoint)
|
|
182
|
+
|
|
183
|
+
# Step 1: Request device code
|
|
184
|
+
data = _request_device_code(base_url)
|
|
185
|
+
if data is None:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
device_code = data["device_code"]
|
|
189
|
+
user_code = data["user_code"]
|
|
190
|
+
verification_uri_complete = data["verification_uri_complete"]
|
|
191
|
+
expires_in = data["expires_in"]
|
|
192
|
+
interval = data["interval"]
|
|
193
|
+
|
|
194
|
+
# Step 2: Display prompt and open browser
|
|
195
|
+
_display_verification_prompt(verification_uri_complete, user_code)
|
|
196
|
+
|
|
197
|
+
# Step 3: Poll for approval
|
|
198
|
+
token_data = _poll_for_token(base_url, device_code, expires_in, interval)
|
|
199
|
+
if token_data is None:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
api_key = token_data["access_token"]
|
|
203
|
+
email = token_data.get("email", "")
|
|
204
|
+
plan = token_data.get("plan", "max")
|
|
205
|
+
|
|
206
|
+
save_credentials(api_key, email, plan)
|
|
207
|
+
console.print(f"[green bold]Authenticated as {email}[/]")
|
|
208
|
+
console.print()
|
|
209
|
+
return api_key
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ensure_authenticated(cloud_endpoint: str) -> str | None:
|
|
213
|
+
"""Check for saved credentials or trigger auth flow. Returns api_key or None."""
|
|
214
|
+
creds = load_credentials()
|
|
215
|
+
if creds and creds.get("api_key"):
|
|
216
|
+
base_url = _derive_base_url(cloud_endpoint)
|
|
217
|
+
try:
|
|
218
|
+
resp = httpx.get(
|
|
219
|
+
f"{base_url}/v1/models",
|
|
220
|
+
headers={"Authorization": f"Bearer {creds['api_key']}"},
|
|
221
|
+
timeout=5,
|
|
222
|
+
)
|
|
223
|
+
if resp.status_code == 200:
|
|
224
|
+
console.print(f"[dim]Authenticated as {creds.get('email', 'unknown')}[/]")
|
|
225
|
+
return creds["api_key"]
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
console.print("[yellow]Saved credentials are invalid. Re-authenticating...[/]")
|
|
230
|
+
clear_credentials()
|
|
231
|
+
|
|
232
|
+
return authenticate(cloud_endpoint)
|
kairo_code/config.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Configuration loader for Kairo Code"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_config() -> Path:
|
|
11
|
+
"""Find config.yaml in current directory or parent directories."""
|
|
12
|
+
current = Path.cwd()
|
|
13
|
+
|
|
14
|
+
# Check current and parent directories
|
|
15
|
+
for path in [current] + list(current.parents):
|
|
16
|
+
config_path = path / "config.yaml"
|
|
17
|
+
if config_path.exists():
|
|
18
|
+
return config_path
|
|
19
|
+
|
|
20
|
+
# Fall back to package directory
|
|
21
|
+
package_dir = Path(__file__).parent.parent
|
|
22
|
+
config_path = package_dir / "config.yaml"
|
|
23
|
+
if config_path.exists():
|
|
24
|
+
return config_path
|
|
25
|
+
|
|
26
|
+
raise FileNotFoundError("config.yaml not found")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
30
|
+
"""Load configuration from YAML file."""
|
|
31
|
+
if config_path is None:
|
|
32
|
+
config_path = find_config()
|
|
33
|
+
|
|
34
|
+
with open(config_path) as f:
|
|
35
|
+
config = yaml.safe_load(f)
|
|
36
|
+
|
|
37
|
+
# Override with environment variables if set
|
|
38
|
+
if os.getenv("KAIRO_CLOUD_ENDPOINT"):
|
|
39
|
+
config.setdefault("cloud", {})["endpoint"] = os.getenv("KAIRO_CLOUD_ENDPOINT")
|
|
40
|
+
|
|
41
|
+
if os.getenv("KAIRO_CLOUD_API_KEY"):
|
|
42
|
+
config.setdefault("cloud", {})["api_key"] = os.getenv("KAIRO_CLOUD_API_KEY")
|
|
43
|
+
|
|
44
|
+
if os.getenv("KAIRO_ROUTER_MODEL"):
|
|
45
|
+
config["models"]["router"] = os.getenv("KAIRO_ROUTER_MODEL")
|
|
46
|
+
|
|
47
|
+
if os.getenv("KAIRO_CODER_MODEL"):
|
|
48
|
+
config["models"]["coder"] = os.getenv("KAIRO_CODER_MODEL")
|
|
49
|
+
|
|
50
|
+
return config
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Config:
|
|
54
|
+
"""Configuration singleton for easy access throughout the app."""
|
|
55
|
+
|
|
56
|
+
_instance: "Config | None" = None
|
|
57
|
+
|
|
58
|
+
def __new__(cls) -> "Config":
|
|
59
|
+
if cls._instance is None:
|
|
60
|
+
cls._instance = super().__new__(cls)
|
|
61
|
+
cls._instance._config = load_config()
|
|
62
|
+
return cls._instance
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def router_model(self) -> str:
|
|
66
|
+
return self._config["models"]["router"]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def coder_model(self) -> str:
|
|
70
|
+
return self._config["models"]["coder"]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def chat_model(self) -> str:
|
|
74
|
+
return self._config["models"]["chat"]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def cloud_endpoint(self) -> str:
|
|
78
|
+
return self._config.get("cloud", {}).get("endpoint", "http://localhost:8000/v1")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def cloud_api_key(self) -> str:
|
|
82
|
+
# Priority: env var / config file > saved CLI credentials > placeholder
|
|
83
|
+
key = self._config.get("cloud", {}).get("api_key", "")
|
|
84
|
+
if key:
|
|
85
|
+
return key
|
|
86
|
+
# Check saved CLI credentials
|
|
87
|
+
from kairo_code.auth import get_api_key
|
|
88
|
+
cli_key = get_api_key()
|
|
89
|
+
if cli_key:
|
|
90
|
+
return cli_key
|
|
91
|
+
# OpenAI client requires a non-empty string
|
|
92
|
+
return "not-needed"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def cloud_timeout(self) -> int:
|
|
96
|
+
return self._config.get("cloud", {}).get("timeout", 120)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def search_max_results(self) -> int:
|
|
100
|
+
return self._config["search"]["max_results"]
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def max_history(self) -> int:
|
|
104
|
+
return self._config["conversation"]["max_history"]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def system_prompt(self) -> str:
|
|
108
|
+
return self._config["conversation"]["system_prompt"]
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def router_prompt(self) -> str:
|
|
112
|
+
return self._config["router"]["system_prompt"]
|
|
113
|
+
|
|
114
|
+
# Safety settings
|
|
115
|
+
@property
|
|
116
|
+
def auto_approve(self) -> bool:
|
|
117
|
+
return self._config.get("safety", {}).get("auto_approve", False)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def auto_approve_tools(self) -> list[str]:
|
|
121
|
+
return self._config.get("safety", {}).get("auto_approve_tools", [])
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def blocked_commands(self) -> list[str]:
|
|
125
|
+
return self._config.get("safety", {}).get("blocked_commands", [])
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def allowed_commands(self) -> list[str]:
|
|
129
|
+
return self._config.get("safety", {}).get("allowed_commands", [])
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def max_file_lines(self) -> int:
|
|
133
|
+
return self._config.get("safety", {}).get("max_file_lines", 500)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def max_iterations(self) -> int:
|
|
137
|
+
return self._config.get("safety", {}).get("max_iterations", 12)
|
|
138
|
+
|
|
139
|
+
def should_auto_approve(self, tool_name: str) -> bool:
|
|
140
|
+
"""Check if a tool should be auto-approved."""
|
|
141
|
+
if self.auto_approve:
|
|
142
|
+
return True
|
|
143
|
+
return tool_name in self.auto_approve_tools
|
|
144
|
+
|
|
145
|
+
def is_command_allowed(self, command: str) -> tuple[bool, str]:
|
|
146
|
+
"""Check if a bash command is allowed. Returns (allowed, reason)."""
|
|
147
|
+
cmd_lower = command.lower().strip()
|
|
148
|
+
|
|
149
|
+
# Check blocked commands
|
|
150
|
+
for blocked in self.blocked_commands:
|
|
151
|
+
if blocked.lower() in cmd_lower:
|
|
152
|
+
return False, f"Command contains blocked pattern: {blocked}"
|
|
153
|
+
|
|
154
|
+
# Check allowed commands (if whitelist is set)
|
|
155
|
+
if self.allowed_commands:
|
|
156
|
+
for allowed in self.allowed_commands:
|
|
157
|
+
if cmd_lower.startswith(allowed.lower()):
|
|
158
|
+
return True, ""
|
|
159
|
+
return False, "Command not in allowed list"
|
|
160
|
+
|
|
161
|
+
return True, ""
|
|
162
|
+
|
|
163
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
164
|
+
"""Get a config value by dot-notation key."""
|
|
165
|
+
keys = key.split(".")
|
|
166
|
+
value = self._config
|
|
167
|
+
for k in keys:
|
|
168
|
+
if isinstance(value, dict) and k in value:
|
|
169
|
+
value = value[k]
|
|
170
|
+
else:
|
|
171
|
+
return default
|
|
172
|
+
return value
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Conversation manager for maintaining chat history with local persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import stat
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field, asdict
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CONVERSATIONS_DIR = Path.home() / ".kairo" / "conversations"
|
|
17
|
+
|
|
18
|
+
# Only allow alphanumeric + hyphens for conversation IDs (no path traversal)
|
|
19
|
+
_SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate_conversation_id(cid: str) -> str:
|
|
23
|
+
"""Validate conversation ID to prevent path traversal."""
|
|
24
|
+
if not _SAFE_ID_RE.match(cid):
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"Invalid conversation ID: {cid!r}. "
|
|
27
|
+
"Only alphanumeric characters, hyphens, and underscores are allowed (max 64 chars)."
|
|
28
|
+
)
|
|
29
|
+
return cid
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Message:
|
|
34
|
+
"""A single message in the conversation."""
|
|
35
|
+
role: str # "user", "assistant", "system"
|
|
36
|
+
content: str
|
|
37
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Conversation:
|
|
41
|
+
"""Manages conversation history with context window limits and local persistence."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, system_prompt: str | None = None, conversation_id: str | None = None):
|
|
44
|
+
self.config = Config()
|
|
45
|
+
self.system_prompt = system_prompt or self.config.system_prompt
|
|
46
|
+
self.messages: list[Message] = []
|
|
47
|
+
self.id = _validate_conversation_id(conversation_id) if conversation_id else uuid.uuid4().hex[:12]
|
|
48
|
+
self.created_at = datetime.now(timezone.utc).isoformat()
|
|
49
|
+
|
|
50
|
+
def add_user(self, content: str) -> None:
|
|
51
|
+
"""Add a user message."""
|
|
52
|
+
self.messages.append(Message(role="user", content=content))
|
|
53
|
+
self._trim_history()
|
|
54
|
+
self._auto_save()
|
|
55
|
+
|
|
56
|
+
def add_assistant(self, content: str) -> None:
|
|
57
|
+
"""Add an assistant message."""
|
|
58
|
+
self.messages.append(Message(role="assistant", content=content))
|
|
59
|
+
self._trim_history()
|
|
60
|
+
self._auto_save()
|
|
61
|
+
|
|
62
|
+
def add_system(self, content: str) -> None:
|
|
63
|
+
"""Add a system message."""
|
|
64
|
+
self.messages.append(Message(role="system", content=content))
|
|
65
|
+
|
|
66
|
+
def _trim_history(self) -> None:
|
|
67
|
+
"""Trim history to stay within max_history limit."""
|
|
68
|
+
max_messages = self.config.max_history
|
|
69
|
+
if len(self.messages) > max_messages:
|
|
70
|
+
self.messages = self.messages[-max_messages:]
|
|
71
|
+
|
|
72
|
+
def to_messages(self) -> list[dict[str, str]]:
|
|
73
|
+
"""Convert to format expected by OpenAI-compatible API."""
|
|
74
|
+
result = [{"role": "system", "content": self.system_prompt}]
|
|
75
|
+
for msg in self.messages:
|
|
76
|
+
result.append({"role": msg.role, "content": msg.content})
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
def clear(self) -> None:
|
|
80
|
+
"""Clear conversation history."""
|
|
81
|
+
self.messages = []
|
|
82
|
+
|
|
83
|
+
def get_context_summary(self) -> str:
|
|
84
|
+
"""Get a brief summary of the conversation context."""
|
|
85
|
+
if not self.messages:
|
|
86
|
+
return "No conversation history"
|
|
87
|
+
return f"{len(self.messages)} messages in history"
|
|
88
|
+
|
|
89
|
+
# ── Persistence ──────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def _auto_save(self) -> None:
|
|
92
|
+
"""Auto-save after each message."""
|
|
93
|
+
try:
|
|
94
|
+
self.save()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass # Don't break the conversation if save fails
|
|
97
|
+
|
|
98
|
+
def save(self) -> Path:
|
|
99
|
+
"""Save conversation to disk with secure permissions."""
|
|
100
|
+
CONVERSATIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
# Secure the directory: owner-only access
|
|
102
|
+
try:
|
|
103
|
+
os.chmod(CONVERSATIONS_DIR, stat.S_IRWXU) # 0700
|
|
104
|
+
except OSError:
|
|
105
|
+
pass # Windows doesn't support Unix permissions
|
|
106
|
+
path = CONVERSATIONS_DIR / f"{self.id}.json"
|
|
107
|
+
data = {
|
|
108
|
+
"id": self.id,
|
|
109
|
+
"created_at": self.created_at,
|
|
110
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
111
|
+
"system_prompt": self.system_prompt,
|
|
112
|
+
"messages": [asdict(m) for m in self.messages],
|
|
113
|
+
}
|
|
114
|
+
path.write_text(json.dumps(data, indent=2))
|
|
115
|
+
# Secure the file: owner read/write only
|
|
116
|
+
try:
|
|
117
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
return path
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def load(cls, conversation_id: str) -> "Conversation":
|
|
124
|
+
"""Load a conversation from disk."""
|
|
125
|
+
_validate_conversation_id(conversation_id)
|
|
126
|
+
path = CONVERSATIONS_DIR / f"{conversation_id}.json"
|
|
127
|
+
if not path.exists():
|
|
128
|
+
raise FileNotFoundError(f"Conversation {conversation_id} not found")
|
|
129
|
+
|
|
130
|
+
data = json.loads(path.read_text())
|
|
131
|
+
conv = cls(
|
|
132
|
+
system_prompt=data.get("system_prompt"),
|
|
133
|
+
conversation_id=data["id"],
|
|
134
|
+
)
|
|
135
|
+
conv.created_at = data.get("created_at", conv.created_at)
|
|
136
|
+
conv.messages = [
|
|
137
|
+
Message(
|
|
138
|
+
role=m["role"],
|
|
139
|
+
content=m["content"],
|
|
140
|
+
metadata=m.get("metadata", {}),
|
|
141
|
+
)
|
|
142
|
+
for m in data.get("messages", [])
|
|
143
|
+
]
|
|
144
|
+
return conv
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def list_saved() -> list[dict[str, str]]:
|
|
148
|
+
"""List all saved conversations (id, created_at, preview)."""
|
|
149
|
+
if not CONVERSATIONS_DIR.exists():
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
conversations = []
|
|
153
|
+
for path in sorted(CONVERSATIONS_DIR.glob("*.json"), reverse=True):
|
|
154
|
+
try:
|
|
155
|
+
data = json.loads(path.read_text())
|
|
156
|
+
# Get first user message as preview
|
|
157
|
+
preview = ""
|
|
158
|
+
for m in data.get("messages", []):
|
|
159
|
+
if m.get("role") == "user":
|
|
160
|
+
preview = m["content"][:80]
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
conversations.append({
|
|
164
|
+
"id": data["id"],
|
|
165
|
+
"created_at": data.get("created_at", ""),
|
|
166
|
+
"updated_at": data.get("updated_at", ""),
|
|
167
|
+
"preview": preview,
|
|
168
|
+
"message_count": str(len(data.get("messages", []))),
|
|
169
|
+
})
|
|
170
|
+
except Exception:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
return conversations
|