kivi-cli 2.0.6__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.
- kivi_cli-2.0.6.dist-info/METADATA +17 -0
- kivi_cli-2.0.6.dist-info/RECORD +47 -0
- kivi_cli-2.0.6.dist-info/WHEEL +5 -0
- kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
- kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
- kiviai/__init__.py +0 -0
- kiviai/backend.py +1372 -0
- kiviai/index.html +6172 -0
- kiviai/services/__init__.py +0 -0
- kiviai/services/git_service.py +219 -0
- kiviai/services/monitor.py +97 -0
- kiviai/services/providers/__init__.py +0 -0
- kiviai/services/providers/base.py +131 -0
- kiviai/services/providers/claude.py +125 -0
- kiviai/services/providers/copilot.py +251 -0
- kiviai/services/providers/inhouse.py +130 -0
- kiviai/services/providers/kivi.py +108 -0
- kiviai/services/providers/model_costs.py +137 -0
- kiviai/services/providers/openai_agent.py +113 -0
- kiviai/services/providers/opencode.py +157 -0
- kiviai/services/providers/orchestrator_provider.py +163 -0
- kiviai/services/providers/registry.py +89 -0
- kiviai/services/providers/vllm.py +319 -0
- kiviai/services/scheduler.py +244 -0
- kiviai/services/tools/__init__.py +1 -0
- kiviai/services/tools/copilot_tools/__init__.py +92 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
- kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
- kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
- kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
- kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
- kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
- kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
- kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
- kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
- kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
- kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
- kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
- kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
- kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
- kiviai/services/tools/copilot_tools/web/_store.py +122 -0
- kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
- kiviai/services/tools/copilot_tools/web/search.py +116 -0
- kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
- kiviai/services/tools/tool_manager.py +239 -0
|
File without changes
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git service — enhanced git operations for the UI.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- Branch management (create, switch, list, delete)
|
|
6
|
+
- Status and diff
|
|
7
|
+
- Commit with message
|
|
8
|
+
- Repo detection
|
|
9
|
+
- Log viewing
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("cc-ui.git")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitService:
|
|
22
|
+
@staticmethod
|
|
23
|
+
async def _run(cmd: list[str], cwd: str) -> tuple[int, str, str]:
|
|
24
|
+
"""Run a git command and return (returncode, stdout, stderr)."""
|
|
25
|
+
proc = await asyncio.create_subprocess_exec(
|
|
26
|
+
*cmd,
|
|
27
|
+
stdout=asyncio.subprocess.PIPE,
|
|
28
|
+
stderr=asyncio.subprocess.PIPE,
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
)
|
|
31
|
+
stdout, stderr = await proc.communicate()
|
|
32
|
+
return proc.returncode, stdout.decode().strip(), stderr.decode().strip()
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
async def is_repo(cwd: str) -> bool:
|
|
36
|
+
rc, _, _ = await GitService._run(
|
|
37
|
+
["git", "rev-parse", "--is-inside-work-tree"], cwd
|
|
38
|
+
)
|
|
39
|
+
return rc == 0
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
async def get_status(cwd: str) -> dict:
|
|
43
|
+
"""Get comprehensive git status."""
|
|
44
|
+
if not await GitService.is_repo(cwd):
|
|
45
|
+
return {"is_git": False}
|
|
46
|
+
|
|
47
|
+
rc, status, _ = await GitService._run(["git", "status", "--short"], cwd)
|
|
48
|
+
rc, branch, _ = await GitService._run(["git", "branch", "--show-current"], cwd)
|
|
49
|
+
rc, diff, _ = await GitService._run(["git", "diff", "HEAD"], cwd)
|
|
50
|
+
rc, remote, _ = await GitService._run(["git", "remote", "-v"], cwd)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"is_git": True,
|
|
54
|
+
"branch": branch,
|
|
55
|
+
"status": status,
|
|
56
|
+
"diff": diff,
|
|
57
|
+
"remote": remote.split("\n")[0] if remote else "",
|
|
58
|
+
"has_changes": bool(status.strip()),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
async def list_branches(cwd: str) -> list[dict]:
|
|
63
|
+
"""List all branches with current marker."""
|
|
64
|
+
if not await GitService.is_repo(cwd):
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
rc, output, _ = await GitService._run(["git", "branch", "-a", "--format=%(refname:short)|%(objectname:short)|%(HEAD)"], cwd)
|
|
68
|
+
branches = []
|
|
69
|
+
for line in output.splitlines():
|
|
70
|
+
if not line.strip():
|
|
71
|
+
continue
|
|
72
|
+
parts = line.split("|")
|
|
73
|
+
if len(parts) >= 3:
|
|
74
|
+
branches.append({
|
|
75
|
+
"name": parts[0].strip(),
|
|
76
|
+
"sha": parts[1].strip(),
|
|
77
|
+
"current": parts[2].strip() == "*",
|
|
78
|
+
})
|
|
79
|
+
return branches
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
async def create_branch(cwd: str, name: str, checkout: bool = True) -> dict:
|
|
83
|
+
"""Create a new branch, optionally check it out."""
|
|
84
|
+
if not await GitService.is_repo(cwd):
|
|
85
|
+
return {"error": "Not a git repository"}
|
|
86
|
+
|
|
87
|
+
if checkout:
|
|
88
|
+
rc, out, err = await GitService._run(["git", "checkout", "-b", name], cwd)
|
|
89
|
+
else:
|
|
90
|
+
rc, out, err = await GitService._run(["git", "branch", name], cwd)
|
|
91
|
+
|
|
92
|
+
return {"success": rc == 0, "output": out or err, "branch": name}
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
async def switch_branch(cwd: str, name: str) -> dict:
|
|
96
|
+
"""Switch to an existing branch."""
|
|
97
|
+
rc, out, err = await GitService._run(["git", "checkout", name], cwd)
|
|
98
|
+
return {"success": rc == 0, "output": out or err, "branch": name}
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
async def commit(cwd: str, message: str, add_all: bool = True) -> dict:
|
|
102
|
+
"""Commit changes."""
|
|
103
|
+
if add_all:
|
|
104
|
+
await GitService._run(["git", "add", "-A"], cwd)
|
|
105
|
+
rc, out, err = await GitService._run(["git", "commit", "-m", message], cwd)
|
|
106
|
+
return {"success": rc == 0, "output": out or err}
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
async def get_log(cwd: str, n: int = 20) -> list[dict]:
|
|
110
|
+
"""Get recent commit log."""
|
|
111
|
+
if not await GitService.is_repo(cwd):
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
rc, output, _ = await GitService._run(
|
|
115
|
+
["git", "log", f"-{n}", "--format=%H|%h|%s|%an|%ai"], cwd
|
|
116
|
+
)
|
|
117
|
+
commits = []
|
|
118
|
+
for line in output.splitlines():
|
|
119
|
+
if not line.strip():
|
|
120
|
+
continue
|
|
121
|
+
parts = line.split("|", 4)
|
|
122
|
+
if len(parts) >= 5:
|
|
123
|
+
commits.append({
|
|
124
|
+
"sha": parts[0], "short_sha": parts[1],
|
|
125
|
+
"message": parts[2], "author": parts[3],
|
|
126
|
+
"date": parts[4],
|
|
127
|
+
})
|
|
128
|
+
return commits
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
async def get_diff(cwd: str) -> dict:
|
|
132
|
+
"""Get status + diff (for the git panel)."""
|
|
133
|
+
if not await GitService.is_repo(cwd):
|
|
134
|
+
return {"is_git": False, "status": "", "diff": ""}
|
|
135
|
+
|
|
136
|
+
rc, status, _ = await GitService._run(["git", "status", "--short"], cwd)
|
|
137
|
+
rc, diff, _ = await GitService._run(["git", "diff", "HEAD"], cwd)
|
|
138
|
+
return {"is_git": True, "status": status, "diff": diff}
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
async def stash(cwd: str, message: str = "") -> dict:
|
|
142
|
+
cmd = ["git", "stash"]
|
|
143
|
+
if message:
|
|
144
|
+
cmd += ["-m", message]
|
|
145
|
+
rc, out, err = await GitService._run(cmd, cwd)
|
|
146
|
+
return {"success": rc == 0, "output": out or err}
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
async def stash_pop(cwd: str) -> dict:
|
|
150
|
+
rc, out, err = await GitService._run(["git", "stash", "pop"], cwd)
|
|
151
|
+
return {"success": rc == 0, "output": out or err}
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
async def list_prs(cwd: str, limit: int = 20) -> list[dict]:
|
|
155
|
+
"""List open PRs using gh CLI."""
|
|
156
|
+
rc, out, err = await GitService._run(
|
|
157
|
+
["gh", "pr", "list", "--json",
|
|
158
|
+
"number,title,author,headRefName,baseRefName,state,url,createdAt,isDraft",
|
|
159
|
+
"--limit", str(limit)],
|
|
160
|
+
cwd,
|
|
161
|
+
)
|
|
162
|
+
if rc != 0:
|
|
163
|
+
return []
|
|
164
|
+
import json
|
|
165
|
+
try:
|
|
166
|
+
return json.loads(out)
|
|
167
|
+
except Exception:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
async def create_pr(cwd: str, title: str, body: str = "", base: str = "main", head: str = "") -> dict:
|
|
172
|
+
"""Create a PR using gh CLI."""
|
|
173
|
+
cmd = ["gh", "pr", "create", "--title", title, "--body", body, "--base", base]
|
|
174
|
+
if head:
|
|
175
|
+
cmd.extend(["--head", head])
|
|
176
|
+
rc, out, err = await GitService._run(cmd, cwd)
|
|
177
|
+
return {"success": rc == 0, "output": out or err}
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
async def merge_pr(cwd: str, pr_number: int, method: str = "merge") -> dict:
|
|
181
|
+
"""Merge a PR using gh CLI."""
|
|
182
|
+
rc, out, err = await GitService._run(
|
|
183
|
+
["gh", "pr", "merge", str(pr_number), f"--{method}"], cwd
|
|
184
|
+
)
|
|
185
|
+
return {"success": rc == 0, "output": out or err}
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
async def pr_diff(cwd: str, pr_number: int) -> str:
|
|
189
|
+
"""Get diff for a PR using gh CLI."""
|
|
190
|
+
rc, out, err = await GitService._run(
|
|
191
|
+
["gh", "pr", "diff", str(pr_number)], cwd
|
|
192
|
+
)
|
|
193
|
+
return out if rc == 0 else err
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
async def get_all_diffs(cwd: str) -> dict:
|
|
197
|
+
"""Get staged, unstaged, and untracked diffs."""
|
|
198
|
+
_, staged, _ = await GitService._run(["git", "diff", "--cached"], cwd)
|
|
199
|
+
_, unstaged, _ = await GitService._run(["git", "diff"], cwd)
|
|
200
|
+
_, untracked_files, _ = await GitService._run(
|
|
201
|
+
["git", "ls-files", "--others", "--exclude-standard"], cwd
|
|
202
|
+
)
|
|
203
|
+
untracked_diff = ""
|
|
204
|
+
for f in untracked_files.splitlines():
|
|
205
|
+
if f:
|
|
206
|
+
try:
|
|
207
|
+
fpath = os.path.join(cwd, f)
|
|
208
|
+
if os.path.isfile(fpath) and os.path.getsize(fpath) < 50000:
|
|
209
|
+
with open(fpath, 'r', errors='replace') as fh:
|
|
210
|
+
content = fh.read()
|
|
211
|
+
untracked_diff += f"\n--- /dev/null\n+++ b/{f}\n" + "\n".join("+" + line for line in content.splitlines()) + "\n"
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
return {
|
|
215
|
+
"staged": staged,
|
|
216
|
+
"unstaged": unstaged,
|
|
217
|
+
"untracked": untracked_diff,
|
|
218
|
+
"total": staged + "\n" + unstaged + "\n" + untracked_diff,
|
|
219
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Monitor service — health checks, system metrics, provider status.
|
|
3
|
+
|
|
4
|
+
Tracks:
|
|
5
|
+
- Provider availability (which AI backends are online)
|
|
6
|
+
- System resources (CPU, memory, disk)
|
|
7
|
+
- Task statistics (running, completed, error rates)
|
|
8
|
+
- Uptime and response times
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("cc-ui.monitor")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Monitor:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self._start_time = time.time()
|
|
25
|
+
self._task_stats = {
|
|
26
|
+
"total": 0, "running": 0, "done": 0,
|
|
27
|
+
"error": 0, "stopped": 0,
|
|
28
|
+
}
|
|
29
|
+
self._provider_status: dict[str, dict] = {}
|
|
30
|
+
self._last_health_check: str = ""
|
|
31
|
+
|
|
32
|
+
def update_task_stats(self, tasks: dict):
|
|
33
|
+
"""Recalculate task statistics from task dict."""
|
|
34
|
+
stats = {"total": 0, "running": 0, "done": 0, "error": 0, "stopped": 0}
|
|
35
|
+
for t in tasks.values():
|
|
36
|
+
stats["total"] += 1
|
|
37
|
+
status = t.get("status", "unknown")
|
|
38
|
+
if status in stats:
|
|
39
|
+
stats[status] += 1
|
|
40
|
+
self._task_stats = stats
|
|
41
|
+
|
|
42
|
+
async def check_providers(self) -> dict:
|
|
43
|
+
"""Run health checks on all registered providers."""
|
|
44
|
+
try:
|
|
45
|
+
from kiviai.services.providers.registry import health_check_all
|
|
46
|
+
self._provider_status = await health_check_all()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
self._provider_status = {"error": str(e)}
|
|
49
|
+
self._last_health_check = datetime.now().isoformat()
|
|
50
|
+
return self._provider_status
|
|
51
|
+
|
|
52
|
+
def get_system_info(self) -> dict:
|
|
53
|
+
"""Get system resource info."""
|
|
54
|
+
info = {
|
|
55
|
+
"uptime_seconds": round(time.time() - self._start_time),
|
|
56
|
+
"pid": os.getpid(),
|
|
57
|
+
"python_version": os.sys.version.split()[0],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
import psutil
|
|
62
|
+
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
|
|
63
|
+
mem = psutil.virtual_memory()
|
|
64
|
+
info["memory_used_gb"] = round(mem.used / (1024**3), 2)
|
|
65
|
+
info["memory_total_gb"] = round(mem.total / (1024**3), 2)
|
|
66
|
+
info["memory_percent"] = mem.percent
|
|
67
|
+
disk = psutil.disk_usage("/")
|
|
68
|
+
info["disk_used_gb"] = round(disk.used / (1024**3), 2)
|
|
69
|
+
info["disk_total_gb"] = round(disk.total / (1024**3), 2)
|
|
70
|
+
except ImportError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return info
|
|
74
|
+
|
|
75
|
+
def get_dashboard(self) -> dict:
|
|
76
|
+
"""Full monitoring dashboard data."""
|
|
77
|
+
return {
|
|
78
|
+
"timestamp": datetime.now().isoformat(),
|
|
79
|
+
"uptime_seconds": round(time.time() - self._start_time),
|
|
80
|
+
"tasks": self._task_stats,
|
|
81
|
+
"providers": self._provider_status,
|
|
82
|
+
"last_health_check": self._last_health_check,
|
|
83
|
+
"system": self.get_system_info(),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def get_metrics(self) -> dict:
|
|
87
|
+
"""Prometheus-style metrics."""
|
|
88
|
+
m = {
|
|
89
|
+
"ccui_uptime_seconds": round(time.time() - self._start_time),
|
|
90
|
+
"ccui_tasks_total": self._task_stats["total"],
|
|
91
|
+
"ccui_tasks_running": self._task_stats["running"],
|
|
92
|
+
"ccui_tasks_done": self._task_stats["done"],
|
|
93
|
+
"ccui_tasks_error": self._task_stats["error"],
|
|
94
|
+
}
|
|
95
|
+
for name, status in self._provider_status.items():
|
|
96
|
+
m[f"ccui_provider_{name}_up"] = 1 if status.get("status") == "ok" else 0
|
|
97
|
+
return m
|
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base provider protocol for all AI integrations.
|
|
3
|
+
|
|
4
|
+
Every provider implements this interface so the system can swap
|
|
5
|
+
between Claude, Copilot, VLLM, etc. with zero friction.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, AsyncIterator, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventType(str, Enum):
|
|
19
|
+
TEXT = "text"
|
|
20
|
+
TOOL_START = "tool_start"
|
|
21
|
+
TOOL_RESULT = "tool_result"
|
|
22
|
+
THINKING = "thinking"
|
|
23
|
+
AGENT_GROUP = "agent_group"
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
DONE = "done"
|
|
26
|
+
COST = "cost"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ProviderEvent:
|
|
31
|
+
"""Unified event emitted by all providers."""
|
|
32
|
+
type: EventType
|
|
33
|
+
content: str = ""
|
|
34
|
+
metadata: dict = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def to_history_entry(self) -> dict | None:
|
|
37
|
+
"""Convert to a history message dict for storage."""
|
|
38
|
+
if self.type == EventType.TEXT:
|
|
39
|
+
return {"role": "assistant", "content": self.content}
|
|
40
|
+
elif self.type == EventType.TOOL_START:
|
|
41
|
+
title = self.metadata.get("title", "⚙ tool")
|
|
42
|
+
args = self.metadata.get("args", "")
|
|
43
|
+
return {
|
|
44
|
+
"role": "assistant",
|
|
45
|
+
"content": f"```json\n{args}\n```" if args else "",
|
|
46
|
+
"metadata": {"title": title, "status": "pending"},
|
|
47
|
+
}
|
|
48
|
+
elif self.type == EventType.TOOL_RESULT:
|
|
49
|
+
title = self.metadata.get("title", "✓ result")
|
|
50
|
+
is_error = self.metadata.get("is_error", False)
|
|
51
|
+
icon = "❌" if is_error else "✓"
|
|
52
|
+
preview = self.content[:600] + ("…" if len(self.content) > 600 else "")
|
|
53
|
+
return {
|
|
54
|
+
"role": "assistant",
|
|
55
|
+
"content": f"```\n{preview}\n```",
|
|
56
|
+
"metadata": {"title": f"{icon} result", "status": "done"},
|
|
57
|
+
}
|
|
58
|
+
elif self.type == EventType.THINKING:
|
|
59
|
+
return {
|
|
60
|
+
"role": "assistant",
|
|
61
|
+
"content": self.content[:500],
|
|
62
|
+
"metadata": {"title": "💭 Thinking", "status": "done"},
|
|
63
|
+
}
|
|
64
|
+
elif self.type == EventType.ERROR:
|
|
65
|
+
return {"role": "assistant", "content": f"❌ {self.content}"}
|
|
66
|
+
elif self.type == EventType.AGENT_GROUP:
|
|
67
|
+
return {
|
|
68
|
+
"role": "agent-group",
|
|
69
|
+
"agent_id": self.metadata.get("agent_id", ""),
|
|
70
|
+
"agent_label": self.metadata.get("agent_label", "Agent"),
|
|
71
|
+
"model": self.metadata.get("model", ""),
|
|
72
|
+
"status": self.metadata.get("status", "running"),
|
|
73
|
+
"children": self.metadata.get("children", []),
|
|
74
|
+
}
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ProviderConfig:
|
|
80
|
+
"""Configuration for a provider instance."""
|
|
81
|
+
model: str = ""
|
|
82
|
+
mode: str = "default"
|
|
83
|
+
cwd: str = ""
|
|
84
|
+
session_id: str | None = None
|
|
85
|
+
api_key: str = ""
|
|
86
|
+
base_url: str = ""
|
|
87
|
+
extra: dict = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BaseProvider(ABC):
|
|
91
|
+
"""Abstract base for all AI provider adapters."""
|
|
92
|
+
|
|
93
|
+
name: str = "base"
|
|
94
|
+
display_name: str = "Base Provider"
|
|
95
|
+
description: str = ""
|
|
96
|
+
supports_streaming: bool = True
|
|
97
|
+
supports_tools: bool = False
|
|
98
|
+
supports_sessions: bool = False
|
|
99
|
+
supports_agents: bool = False
|
|
100
|
+
available_models: list[str] = []
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
async def run(
|
|
104
|
+
self,
|
|
105
|
+
prompt: str,
|
|
106
|
+
config: ProviderConfig,
|
|
107
|
+
history: list[dict] | None = None,
|
|
108
|
+
) -> AsyncIterator[ProviderEvent]:
|
|
109
|
+
"""Execute a prompt and yield events."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
async def stop(self) -> None:
|
|
113
|
+
"""Signal the provider to stop current execution."""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
async def health_check(self) -> dict:
|
|
117
|
+
"""Check if the provider is available and responsive."""
|
|
118
|
+
return {"status": "unknown", "provider": self.name}
|
|
119
|
+
|
|
120
|
+
def get_capabilities(self) -> dict:
|
|
121
|
+
"""Return provider capabilities for the UI."""
|
|
122
|
+
return {
|
|
123
|
+
"name": self.name,
|
|
124
|
+
"display_name": self.display_name,
|
|
125
|
+
"description": self.description,
|
|
126
|
+
"streaming": self.supports_streaming,
|
|
127
|
+
"tools": self.supports_tools,
|
|
128
|
+
"sessions": self.supports_sessions,
|
|
129
|
+
"agents": self.supports_agents,
|
|
130
|
+
"models": self.available_models,
|
|
131
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Claude Code provider — uses claude_agent_sdk for streaming."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import AsyncIterator
|
|
8
|
+
|
|
9
|
+
from .base import BaseProvider, ProviderConfig, ProviderEvent, EventType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClaudeProvider(BaseProvider):
|
|
13
|
+
name = "claude"
|
|
14
|
+
display_name = "Claude Code"
|
|
15
|
+
description = "Anthropic's agentic coding assistant. Reads/writes files, runs shell commands, manages git, and reasons through complex multi-step tasks. Supports session resumption and sub-agent orchestration."
|
|
16
|
+
supports_streaming = True
|
|
17
|
+
supports_tools = True
|
|
18
|
+
supports_sessions = True
|
|
19
|
+
supports_agents = True
|
|
20
|
+
available_models = [
|
|
21
|
+
"sonnet", "opus", "haiku", "fable",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self._stop = False
|
|
26
|
+
|
|
27
|
+
async def run(self, prompt: str, config: ProviderConfig, history=None) -> AsyncIterator[ProviderEvent]:
|
|
28
|
+
from claude_agent_sdk import (
|
|
29
|
+
query, ClaudeAgentOptions,
|
|
30
|
+
AssistantMessage, ResultMessage, SystemMessage,
|
|
31
|
+
TextBlock, ToolUseBlock, ToolResultBlock, ThinkingBlock,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
self._stop = False
|
|
35
|
+
opts = ClaudeAgentOptions(
|
|
36
|
+
permission_mode=config.mode or "bypassPermissions",
|
|
37
|
+
resume=config.session_id,
|
|
38
|
+
cwd=config.cwd or None,
|
|
39
|
+
env={**os.environ, **config.extra.get("env", {})},
|
|
40
|
+
model=config.model or None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Extended CLI flags via extra dict
|
|
44
|
+
if config.extra.get("system_prompt"):
|
|
45
|
+
opts.system_prompt = config.extra["system_prompt"]
|
|
46
|
+
if config.extra.get("append_system_prompt"):
|
|
47
|
+
opts.append_system_prompt = config.extra["append_system_prompt"]
|
|
48
|
+
if config.extra.get("effort"):
|
|
49
|
+
opts.effort = config.extra["effort"]
|
|
50
|
+
if config.extra.get("max_budget_usd"):
|
|
51
|
+
opts.max_budget_usd = float(config.extra["max_budget_usd"])
|
|
52
|
+
if config.extra.get("allowed_tools"):
|
|
53
|
+
opts.allowed_tools = config.extra["allowed_tools"]
|
|
54
|
+
if config.extra.get("disallowed_tools"):
|
|
55
|
+
opts.disallowed_tools = config.extra["disallowed_tools"]
|
|
56
|
+
if config.extra.get("mcp_config"):
|
|
57
|
+
opts.mcp_config = config.extra["mcp_config"]
|
|
58
|
+
|
|
59
|
+
# File attachments: prepend to prompt
|
|
60
|
+
files = config.extra.get("files", [])
|
|
61
|
+
if files:
|
|
62
|
+
file_context = "\n".join(f"[Attached file: {f}]" for f in files)
|
|
63
|
+
prompt = file_context + "\n\n" + prompt
|
|
64
|
+
|
|
65
|
+
text_buf = ""
|
|
66
|
+
try:
|
|
67
|
+
async for msg in query(prompt=prompt, options=opts):
|
|
68
|
+
if self._stop:
|
|
69
|
+
yield ProviderEvent(type=EventType.TEXT, content="⏹ *stopped*")
|
|
70
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if isinstance(msg, AssistantMessage):
|
|
74
|
+
if msg.error:
|
|
75
|
+
yield ProviderEvent(type=EventType.ERROR, content=str(msg.error))
|
|
76
|
+
return
|
|
77
|
+
for block in msg.content:
|
|
78
|
+
if isinstance(block, TextBlock):
|
|
79
|
+
text_buf += block.text
|
|
80
|
+
yield ProviderEvent(type=EventType.TEXT, content=block.text)
|
|
81
|
+
elif isinstance(block, ThinkingBlock):
|
|
82
|
+
yield ProviderEvent(type=EventType.THINKING, content=block.thinking[:500])
|
|
83
|
+
elif isinstance(block, ToolUseBlock):
|
|
84
|
+
args = json.dumps(block.input, indent=2) if block.input else ""
|
|
85
|
+
yield ProviderEvent(
|
|
86
|
+
type=EventType.TOOL_START,
|
|
87
|
+
metadata={"title": f"⚙ {block.name}", "args": args},
|
|
88
|
+
)
|
|
89
|
+
elif isinstance(block, ToolResultBlock):
|
|
90
|
+
content = block.content or ""
|
|
91
|
+
if isinstance(content, list):
|
|
92
|
+
content = "\n".join(c.get("text", str(c)) for c in content)
|
|
93
|
+
yield ProviderEvent(
|
|
94
|
+
type=EventType.TOOL_RESULT,
|
|
95
|
+
content=str(content),
|
|
96
|
+
metadata={"is_error": block.is_error},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
elif isinstance(msg, ResultMessage):
|
|
100
|
+
yield ProviderEvent(
|
|
101
|
+
type=EventType.COST,
|
|
102
|
+
metadata={
|
|
103
|
+
"session_id": msg.session_id,
|
|
104
|
+
"total_cost_usd": msg.total_cost_usd or 0,
|
|
105
|
+
"usage": msg.usage or {},
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
yield ProviderEvent(type=EventType.ERROR, content=str(e))
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
114
|
+
|
|
115
|
+
async def stop(self):
|
|
116
|
+
self._stop = True
|
|
117
|
+
|
|
118
|
+
async def health_check(self):
|
|
119
|
+
import shutil
|
|
120
|
+
has_claude = shutil.which("claude") is not None
|
|
121
|
+
return {
|
|
122
|
+
"status": "ok" if has_claude else "unavailable",
|
|
123
|
+
"provider": self.name,
|
|
124
|
+
"binary": "claude" if has_claude else None,
|
|
125
|
+
}
|