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.
Files changed (47) hide show
  1. kivi_cli-2.0.6.dist-info/METADATA +17 -0
  2. kivi_cli-2.0.6.dist-info/RECORD +47 -0
  3. kivi_cli-2.0.6.dist-info/WHEEL +5 -0
  4. kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
  5. kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
  6. kiviai/__init__.py +0 -0
  7. kiviai/backend.py +1372 -0
  8. kiviai/index.html +6172 -0
  9. kiviai/services/__init__.py +0 -0
  10. kiviai/services/git_service.py +219 -0
  11. kiviai/services/monitor.py +97 -0
  12. kiviai/services/providers/__init__.py +0 -0
  13. kiviai/services/providers/base.py +131 -0
  14. kiviai/services/providers/claude.py +125 -0
  15. kiviai/services/providers/copilot.py +251 -0
  16. kiviai/services/providers/inhouse.py +130 -0
  17. kiviai/services/providers/kivi.py +108 -0
  18. kiviai/services/providers/model_costs.py +137 -0
  19. kiviai/services/providers/openai_agent.py +113 -0
  20. kiviai/services/providers/opencode.py +157 -0
  21. kiviai/services/providers/orchestrator_provider.py +163 -0
  22. kiviai/services/providers/registry.py +89 -0
  23. kiviai/services/providers/vllm.py +319 -0
  24. kiviai/services/scheduler.py +244 -0
  25. kiviai/services/tools/__init__.py +1 -0
  26. kiviai/services/tools/copilot_tools/__init__.py +92 -0
  27. kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
  28. kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
  29. kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
  30. kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
  31. kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
  32. kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
  33. kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
  34. kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
  35. kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
  36. kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
  37. kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
  38. kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
  39. kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
  40. kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
  41. kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
  42. kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
  43. kiviai/services/tools/copilot_tools/web/_store.py +122 -0
  44. kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
  45. kiviai/services/tools/copilot_tools/web/search.py +116 -0
  46. kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
  47. 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
+ }