opencomputer 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.
- opencomputer/__init__.py +3 -0
- opencomputer/agent/__init__.py +1 -0
- opencomputer/agent/compaction.py +245 -0
- opencomputer/agent/config.py +108 -0
- opencomputer/agent/config_store.py +210 -0
- opencomputer/agent/injection.py +60 -0
- opencomputer/agent/loop.py +326 -0
- opencomputer/agent/memory.py +132 -0
- opencomputer/agent/prompt_builder.py +66 -0
- opencomputer/agent/prompts/base.j2 +23 -0
- opencomputer/agent/state.py +251 -0
- opencomputer/agent/step.py +31 -0
- opencomputer/cli.py +483 -0
- opencomputer/doctor.py +216 -0
- opencomputer/gateway/__init__.py +1 -0
- opencomputer/gateway/dispatch.py +89 -0
- opencomputer/gateway/protocol.py +84 -0
- opencomputer/gateway/server.py +77 -0
- opencomputer/gateway/wire_server.py +256 -0
- opencomputer/hooks/__init__.py +1 -0
- opencomputer/hooks/engine.py +79 -0
- opencomputer/hooks/runner.py +42 -0
- opencomputer/mcp/__init__.py +1 -0
- opencomputer/mcp/client.py +208 -0
- opencomputer/plugins/__init__.py +1 -0
- opencomputer/plugins/discovery.py +107 -0
- opencomputer/plugins/loader.py +155 -0
- opencomputer/plugins/registry.py +56 -0
- opencomputer/setup_wizard.py +235 -0
- opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
- opencomputer/tools/__init__.py +1 -0
- opencomputer/tools/bash.py +78 -0
- opencomputer/tools/delegate.py +98 -0
- opencomputer/tools/glob.py +70 -0
- opencomputer/tools/grep.py +117 -0
- opencomputer/tools/read.py +81 -0
- opencomputer/tools/registry.py +69 -0
- opencomputer/tools/skill_manage.py +265 -0
- opencomputer/tools/write.py +58 -0
- opencomputer-0.1.0.dist-info/METADATA +190 -0
- opencomputer-0.1.0.dist-info/RECORD +51 -0
- opencomputer-0.1.0.dist-info/WHEEL +4 -0
- opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
- plugin_sdk/__init__.py +66 -0
- plugin_sdk/channel_contract.py +74 -0
- plugin_sdk/core.py +129 -0
- plugin_sdk/hooks.py +80 -0
- plugin_sdk/injection.py +60 -0
- plugin_sdk/provider_contract.py +95 -0
- plugin_sdk/runtime_context.py +39 -0
- plugin_sdk/tool_contract.py +67 -0
opencomputer/cli.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""OpenComputer CLI entry point — an actual working chat loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
|
|
13
|
+
from opencomputer import __version__
|
|
14
|
+
from opencomputer.agent.config import default_config
|
|
15
|
+
from opencomputer.agent.config_store import (
|
|
16
|
+
config_file_path,
|
|
17
|
+
get_value,
|
|
18
|
+
load_config,
|
|
19
|
+
save_config,
|
|
20
|
+
set_value,
|
|
21
|
+
)
|
|
22
|
+
from opencomputer.agent.loop import AgentLoop
|
|
23
|
+
from opencomputer.plugins.registry import registry as plugin_registry
|
|
24
|
+
from opencomputer.tools.bash import BashTool
|
|
25
|
+
from opencomputer.tools.delegate import DelegateTool
|
|
26
|
+
from opencomputer.tools.glob import GlobTool
|
|
27
|
+
from opencomputer.tools.grep import GrepTool
|
|
28
|
+
from opencomputer.tools.read import ReadTool
|
|
29
|
+
from opencomputer.tools.registry import registry
|
|
30
|
+
from opencomputer.tools.skill_manage import SkillManageTool
|
|
31
|
+
from opencomputer.tools.write import WriteTool
|
|
32
|
+
from plugin_sdk.runtime_context import RuntimeContext
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="opencomputer",
|
|
36
|
+
help="Personal AI agent framework — plugin-first, self-improving, multi-channel.",
|
|
37
|
+
no_args_is_help=False,
|
|
38
|
+
)
|
|
39
|
+
console = Console()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _register_builtin_tools() -> None:
|
|
43
|
+
"""Register the core bundled tools. Only runs once per process."""
|
|
44
|
+
if "Read" in registry.names():
|
|
45
|
+
return
|
|
46
|
+
registry.register(ReadTool())
|
|
47
|
+
registry.register(WriteTool())
|
|
48
|
+
registry.register(BashTool())
|
|
49
|
+
registry.register(GrepTool())
|
|
50
|
+
registry.register(GlobTool())
|
|
51
|
+
registry.register(SkillManageTool())
|
|
52
|
+
registry.register(DelegateTool())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _discover_plugins() -> int:
|
|
56
|
+
"""Discover + load plugins from known search paths. Returns count loaded."""
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
# In-tree extensions + user plugin dir
|
|
60
|
+
search_paths: list[Path] = []
|
|
61
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
62
|
+
ext_dir = repo_root / "extensions"
|
|
63
|
+
if ext_dir.exists():
|
|
64
|
+
search_paths.append(ext_dir)
|
|
65
|
+
user_dir = Path.home() / ".opencomputer" / "plugins"
|
|
66
|
+
if user_dir.exists():
|
|
67
|
+
search_paths.append(user_dir)
|
|
68
|
+
|
|
69
|
+
loaded = plugin_registry.load_all(search_paths)
|
|
70
|
+
return len(loaded)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_provider(provider_name: str):
|
|
74
|
+
"""Resolve a provider by name from the plugin registry.
|
|
75
|
+
|
|
76
|
+
Providers are plugins — discovered via plugin.json + activated on demand.
|
|
77
|
+
There is no in-tree fallback: if a provider isn't registered, the user
|
|
78
|
+
needs to install (or enable) the corresponding plugin.
|
|
79
|
+
"""
|
|
80
|
+
registered = plugin_registry.providers.get(provider_name)
|
|
81
|
+
if registered is None:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
f"Provider '{provider_name}' is not available. "
|
|
84
|
+
f"Installed providers: {list(plugin_registry.providers.keys()) or 'none'}. "
|
|
85
|
+
f"Ensure the relevant plugin is in extensions/ or ~/.opencomputer/plugins/."
|
|
86
|
+
)
|
|
87
|
+
# Plugins register the CLASS — instantiate with defaults (reads env vars)
|
|
88
|
+
return registered() if isinstance(registered, type) else registered
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.callback(invoke_without_command=True)
|
|
92
|
+
def default(
|
|
93
|
+
ctx: typer.Context,
|
|
94
|
+
version: bool = typer.Option(False, "--version", "-V", help="Show version and exit."),
|
|
95
|
+
) -> None:
|
|
96
|
+
if version:
|
|
97
|
+
console.print(f"opencomputer {__version__}")
|
|
98
|
+
raise typer.Exit()
|
|
99
|
+
if ctx.invoked_subcommand is None:
|
|
100
|
+
chat()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_provider_key(provider_name: str) -> None:
|
|
104
|
+
"""Verify the right env var is set for the configured provider."""
|
|
105
|
+
key_env = {
|
|
106
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
107
|
+
"openai": "OPENAI_API_KEY",
|
|
108
|
+
}.get(provider_name)
|
|
109
|
+
if key_env and not os.environ.get(key_env):
|
|
110
|
+
console.print(
|
|
111
|
+
f"[bold red]error:[/bold red] {key_env} not set.\n"
|
|
112
|
+
f"[dim]export {key_env}=your-key to continue.[/dim]"
|
|
113
|
+
)
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def chat(
|
|
119
|
+
resume: str = typer.Option(
|
|
120
|
+
"", "--resume", "-r", help="Resume a session by id (latest if empty)."
|
|
121
|
+
),
|
|
122
|
+
plan: bool = typer.Option(
|
|
123
|
+
False, "--plan", help="Plan mode — agent describes actions, refuses destructive tools."
|
|
124
|
+
),
|
|
125
|
+
no_compact: bool = typer.Option(
|
|
126
|
+
False, "--no-compact", help="Disable automatic context compaction (debugging)."
|
|
127
|
+
),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Start an interactive chat session."""
|
|
130
|
+
cfg = load_config()
|
|
131
|
+
_check_provider_key(cfg.model.provider)
|
|
132
|
+
|
|
133
|
+
from opencomputer.mcp.client import MCPManager
|
|
134
|
+
|
|
135
|
+
_register_builtin_tools()
|
|
136
|
+
n_plugins = _discover_plugins()
|
|
137
|
+
provider = _resolve_provider(cfg.model.provider)
|
|
138
|
+
runtime = RuntimeContext(plan_mode=plan)
|
|
139
|
+
loop = AgentLoop(provider=provider, config=cfg, compaction_disabled=no_compact)
|
|
140
|
+
mcp_mgr = MCPManager(tool_registry=registry)
|
|
141
|
+
|
|
142
|
+
# Wire the delegate factory so the model can spawn subagents
|
|
143
|
+
DelegateTool.set_factory(
|
|
144
|
+
lambda: AgentLoop(provider=provider, config=cfg, compaction_disabled=no_compact)
|
|
145
|
+
)
|
|
146
|
+
DelegateTool.set_runtime(runtime)
|
|
147
|
+
|
|
148
|
+
# Connect MCP servers synchronously in chat mode (simpler — no event loop yet)
|
|
149
|
+
n_mcp_tools = 0
|
|
150
|
+
if cfg.mcp.servers:
|
|
151
|
+
n_mcp_tools = asyncio.run(mcp_mgr.connect_all(list(cfg.mcp.servers)))
|
|
152
|
+
|
|
153
|
+
session_id = resume or str(uuid.uuid4())
|
|
154
|
+
console.print(f"[bold cyan]OpenComputer v{__version__}[/bold cyan]")
|
|
155
|
+
console.print(f"[dim]session: {session_id}[/dim]")
|
|
156
|
+
console.print(f"[dim]model: {cfg.model.model} ({cfg.model.provider})[/dim]")
|
|
157
|
+
console.print(f"[dim]tools: {', '.join(sorted(registry.names()))}[/dim]")
|
|
158
|
+
console.print(f"[dim]plugins: {n_plugins} loaded[/dim]")
|
|
159
|
+
if plan:
|
|
160
|
+
console.print("[bold yellow]plan mode ON[/bold yellow] — destructive tools will be refused")
|
|
161
|
+
if no_compact:
|
|
162
|
+
console.print("[dim]compaction disabled[/dim]")
|
|
163
|
+
if cfg.mcp.servers:
|
|
164
|
+
console.print(f"[dim]mcp: {n_mcp_tools} tool(s) from {len(cfg.mcp.servers)} server(s)[/dim]")
|
|
165
|
+
console.print("[dim]Type 'exit' to quit. Ctrl+C to interrupt.[/dim]\n")
|
|
166
|
+
|
|
167
|
+
async def _run_turn(user_input: str) -> None:
|
|
168
|
+
# Stream tokens to the terminal as they arrive
|
|
169
|
+
printed_header = {"val": False}
|
|
170
|
+
|
|
171
|
+
def on_chunk(text: str) -> None:
|
|
172
|
+
if not printed_header["val"]:
|
|
173
|
+
console.print("[bold magenta]oc ›[/bold magenta] ", end="")
|
|
174
|
+
printed_header["val"] = True
|
|
175
|
+
# Print raw text (not markdown) so streaming is smooth;
|
|
176
|
+
# final full message is re-rendered as Markdown below.
|
|
177
|
+
console.print(text, end="", markup=False, highlight=False)
|
|
178
|
+
|
|
179
|
+
result = await loop.run_conversation(
|
|
180
|
+
user_message=user_input,
|
|
181
|
+
session_id=session_id,
|
|
182
|
+
runtime=runtime,
|
|
183
|
+
stream_callback=on_chunk,
|
|
184
|
+
)
|
|
185
|
+
# Newline after streaming content (if any)
|
|
186
|
+
if printed_header["val"]:
|
|
187
|
+
console.print()
|
|
188
|
+
# Re-render as Markdown for code fences / lists if content is present
|
|
189
|
+
# and wasn't already streamed as text (prevents double output).
|
|
190
|
+
if result.final_message.content.strip() and not printed_header["val"]:
|
|
191
|
+
console.print("[bold magenta]oc ›[/bold magenta]")
|
|
192
|
+
console.print(Markdown(result.final_message.content))
|
|
193
|
+
console.print(
|
|
194
|
+
f"[dim]({result.iterations} iterations · "
|
|
195
|
+
f"{result.input_tokens} in / {result.output_tokens} out)[/dim]\n"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
while True:
|
|
199
|
+
try:
|
|
200
|
+
user_input = console.input("[bold green]you ›[/bold green] ")
|
|
201
|
+
except (KeyboardInterrupt, EOFError):
|
|
202
|
+
console.print("\n[dim]bye.[/dim]")
|
|
203
|
+
return
|
|
204
|
+
if user_input.strip().lower() in {"exit", "quit", ":q"}:
|
|
205
|
+
console.print("[dim]bye.[/dim]")
|
|
206
|
+
return
|
|
207
|
+
if not user_input.strip():
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
asyncio.run(_run_turn(user_input))
|
|
211
|
+
except Exception as e:
|
|
212
|
+
console.print(f"[bold red]error:[/bold red] {type(e).__name__}: {e}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def search(
|
|
217
|
+
query: str = typer.Argument(..., help="Query to search across past sessions."),
|
|
218
|
+
limit: int = typer.Option(20, "--limit", "-n"),
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Full-text search across saved sessions (FTS5)."""
|
|
221
|
+
from opencomputer.agent.state import SessionDB
|
|
222
|
+
|
|
223
|
+
cfg = default_config()
|
|
224
|
+
db = SessionDB(cfg.session.db_path)
|
|
225
|
+
hits = db.search(query, limit=limit)
|
|
226
|
+
if not hits:
|
|
227
|
+
console.print("[dim]no matches[/dim]")
|
|
228
|
+
return
|
|
229
|
+
for h in hits:
|
|
230
|
+
console.print(
|
|
231
|
+
f"[cyan]{h['role']}[/cyan] [dim]({h['session_id'][:8]}…)[/dim] {h['snippet']}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def sessions(limit: int = typer.Option(10, "--limit", "-n")) -> None:
|
|
237
|
+
"""List recent sessions."""
|
|
238
|
+
from opencomputer.agent.state import SessionDB
|
|
239
|
+
|
|
240
|
+
cfg = default_config()
|
|
241
|
+
db = SessionDB(cfg.session.db_path)
|
|
242
|
+
rows = db.list_sessions(limit=limit)
|
|
243
|
+
for r in rows:
|
|
244
|
+
title = r.get("title") or "[untitled]"
|
|
245
|
+
console.print(
|
|
246
|
+
f"[dim]{r['id'][:8]}…[/dim] "
|
|
247
|
+
f"msgs={r['message_count']:<3} {title}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command()
|
|
252
|
+
def wire(
|
|
253
|
+
host: str = typer.Option("127.0.0.1", "--host"),
|
|
254
|
+
port: int = typer.Option(18789, "--port"),
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Run the wire server — JSON-over-WebSocket API for TUI / IDE / web clients."""
|
|
257
|
+
from opencomputer.gateway.wire_server import WireServer
|
|
258
|
+
|
|
259
|
+
cfg = load_config()
|
|
260
|
+
_check_provider_key(cfg.model.provider)
|
|
261
|
+
|
|
262
|
+
_register_builtin_tools()
|
|
263
|
+
_discover_plugins()
|
|
264
|
+
|
|
265
|
+
provider = _resolve_provider(cfg.model.provider)
|
|
266
|
+
loop = AgentLoop(provider=provider, config=cfg)
|
|
267
|
+
DelegateTool.set_factory(lambda: AgentLoop(provider=provider, config=cfg))
|
|
268
|
+
|
|
269
|
+
server = WireServer(loop=loop, host=host, port=port)
|
|
270
|
+
console.print(
|
|
271
|
+
f"[bold cyan]OpenComputer wire server[/bold cyan] — ws://{host}:{port}"
|
|
272
|
+
)
|
|
273
|
+
console.print(f"[dim]model: {cfg.model.model} ({cfg.model.provider})[/dim]")
|
|
274
|
+
console.print("[dim]ctrl+c to stop[/dim]\n")
|
|
275
|
+
|
|
276
|
+
async def _run():
|
|
277
|
+
await server.start()
|
|
278
|
+
try:
|
|
279
|
+
await asyncio.Future() # run forever
|
|
280
|
+
finally:
|
|
281
|
+
await server.stop()
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
asyncio.run(_run())
|
|
285
|
+
except KeyboardInterrupt:
|
|
286
|
+
console.print("\n[dim]wire server stopped[/dim]")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command()
|
|
290
|
+
def gateway() -> None:
|
|
291
|
+
"""Run the gateway daemon — connects all configured channel adapters.
|
|
292
|
+
|
|
293
|
+
Requires provider API key + at least one channel token (TELEGRAM_BOT_TOKEN,
|
|
294
|
+
DISCORD_BOT_TOKEN, etc.) in the environment. The same agent loop runs,
|
|
295
|
+
but input comes from channels instead of the terminal.
|
|
296
|
+
"""
|
|
297
|
+
from opencomputer.gateway.server import Gateway
|
|
298
|
+
from opencomputer.mcp.client import MCPManager
|
|
299
|
+
|
|
300
|
+
cfg = load_config()
|
|
301
|
+
_check_provider_key(cfg.model.provider)
|
|
302
|
+
|
|
303
|
+
_register_builtin_tools()
|
|
304
|
+
n_plugins = _discover_plugins()
|
|
305
|
+
|
|
306
|
+
provider = _resolve_provider(cfg.model.provider)
|
|
307
|
+
loop = AgentLoop(provider=provider, config=cfg)
|
|
308
|
+
DelegateTool.set_factory(lambda: AgentLoop(provider=provider, config=cfg))
|
|
309
|
+
|
|
310
|
+
# Connect to MCP servers in the background (kimi-cli deferred pattern)
|
|
311
|
+
mcp_mgr = MCPManager(tool_registry=registry)
|
|
312
|
+
if cfg.mcp.servers:
|
|
313
|
+
console.print(
|
|
314
|
+
f"[dim]mcp: deferring connection to {len(cfg.mcp.servers)} server(s)[/dim]"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
gw = Gateway(loop=loop)
|
|
318
|
+
for platform_name, adapter in plugin_registry.channels.items():
|
|
319
|
+
console.print(f"[dim]registering channel:[/dim] [cyan]{platform_name}[/cyan]")
|
|
320
|
+
gw.register_adapter(adapter)
|
|
321
|
+
|
|
322
|
+
if not gw.adapters:
|
|
323
|
+
console.print(
|
|
324
|
+
"[bold yellow]warning:[/bold yellow] no channel adapters registered. "
|
|
325
|
+
"Set TELEGRAM_BOT_TOKEN (or another channel token) and ensure the "
|
|
326
|
+
"channel plugin is discovered."
|
|
327
|
+
)
|
|
328
|
+
console.print(f"[dim]plugins loaded: {n_plugins}[/dim]")
|
|
329
|
+
raise typer.Exit(1)
|
|
330
|
+
|
|
331
|
+
console.print(
|
|
332
|
+
f"[bold cyan]OpenComputer gateway[/bold cyan] — "
|
|
333
|
+
f"{len(gw.adapters)} channel(s), model={cfg.model.model}"
|
|
334
|
+
)
|
|
335
|
+
console.print("[dim]ctrl+c to stop[/dim]\n")
|
|
336
|
+
|
|
337
|
+
async def _run():
|
|
338
|
+
if cfg.mcp.servers:
|
|
339
|
+
asyncio.create_task(
|
|
340
|
+
mcp_mgr.connect_all(list(cfg.mcp.servers))
|
|
341
|
+
)
|
|
342
|
+
try:
|
|
343
|
+
await gw.serve_forever()
|
|
344
|
+
finally:
|
|
345
|
+
await mcp_mgr.shutdown()
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
asyncio.run(_run())
|
|
349
|
+
except KeyboardInterrupt:
|
|
350
|
+
console.print("\n[dim]gateway stopped[/dim]")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@app.command()
|
|
354
|
+
def plugins() -> None:
|
|
355
|
+
"""List discovered plugins (metadata only — no activation)."""
|
|
356
|
+
from pathlib import Path
|
|
357
|
+
|
|
358
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
359
|
+
search_paths: list[Path] = []
|
|
360
|
+
ext_dir = repo_root / "extensions"
|
|
361
|
+
if ext_dir.exists():
|
|
362
|
+
search_paths.append(ext_dir)
|
|
363
|
+
user_dir = Path.home() / ".opencomputer" / "plugins"
|
|
364
|
+
if user_dir.exists():
|
|
365
|
+
search_paths.append(user_dir)
|
|
366
|
+
|
|
367
|
+
candidates = plugin_registry.list_candidates(search_paths)
|
|
368
|
+
if not candidates:
|
|
369
|
+
console.print("[dim]no plugins found in:[/dim]")
|
|
370
|
+
for p in search_paths:
|
|
371
|
+
console.print(f"[dim] {p}[/dim]")
|
|
372
|
+
return
|
|
373
|
+
for c in candidates:
|
|
374
|
+
m = c.manifest
|
|
375
|
+
console.print(
|
|
376
|
+
f"[cyan]{m.id}[/cyan] v{m.version} — {m.description or '[no description]'}"
|
|
377
|
+
)
|
|
378
|
+
console.print(f"[dim] kind: {m.kind} root: {c.root_dir}[/dim]")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@app.command()
|
|
382
|
+
def setup() -> None:
|
|
383
|
+
"""Interactive first-run wizard — pick provider, enter key, test."""
|
|
384
|
+
from opencomputer.setup_wizard import run_setup
|
|
385
|
+
|
|
386
|
+
run_setup()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.command()
|
|
390
|
+
def doctor() -> None:
|
|
391
|
+
"""Diagnose common config/env issues."""
|
|
392
|
+
from opencomputer.doctor import run_doctor
|
|
393
|
+
|
|
394
|
+
failures = run_doctor()
|
|
395
|
+
if failures:
|
|
396
|
+
raise typer.Exit(1)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.command()
|
|
400
|
+
def skills() -> None:
|
|
401
|
+
"""List available skills."""
|
|
402
|
+
from opencomputer.agent.memory import MemoryManager
|
|
403
|
+
|
|
404
|
+
cfg = default_config()
|
|
405
|
+
mem = MemoryManager(cfg.memory.declarative_path, cfg.memory.skills_path)
|
|
406
|
+
found = mem.list_skills()
|
|
407
|
+
if not found:
|
|
408
|
+
console.print("[dim]no skills found at[/dim] " + str(cfg.memory.skills_path))
|
|
409
|
+
return
|
|
410
|
+
for s in found:
|
|
411
|
+
console.print(f"[cyan]{s.name}[/cyan] — {s.description}")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
config_app = typer.Typer(
|
|
415
|
+
name="config", help="Manage OpenComputer config (~/.opencomputer/config.yaml)"
|
|
416
|
+
)
|
|
417
|
+
app.add_typer(config_app, name="config")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@config_app.command("show")
|
|
421
|
+
def config_show() -> None:
|
|
422
|
+
"""Print current effective config (defaults + overrides from disk)."""
|
|
423
|
+
import yaml
|
|
424
|
+
|
|
425
|
+
from opencomputer.agent.config_store import _to_yaml_dict
|
|
426
|
+
|
|
427
|
+
cfg = load_config()
|
|
428
|
+
console.print(yaml.safe_dump(_to_yaml_dict(cfg), default_flow_style=False, sort_keys=False))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@config_app.command("get")
|
|
432
|
+
def config_get(key: str = typer.Argument(..., help="Dotted key, e.g. model.provider")) -> None:
|
|
433
|
+
"""Get a single config value by dotted key."""
|
|
434
|
+
cfg = load_config()
|
|
435
|
+
try:
|
|
436
|
+
value = get_value(cfg, key)
|
|
437
|
+
except KeyError as e:
|
|
438
|
+
console.print(f"[bold red]error:[/bold red] {e}")
|
|
439
|
+
raise typer.Exit(1) from None
|
|
440
|
+
console.print(str(value))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@config_app.command("set")
|
|
444
|
+
def config_set(
|
|
445
|
+
key: str = typer.Argument(..., help="Dotted key, e.g. model.provider"),
|
|
446
|
+
value: str = typer.Argument(..., help="New value"),
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Set a config value and persist to ~/.opencomputer/config.yaml."""
|
|
449
|
+
cfg = load_config()
|
|
450
|
+
# Attempt to coerce numeric / bool / path values sensibly
|
|
451
|
+
coerced: object = value
|
|
452
|
+
if value.lower() in {"true", "false"}:
|
|
453
|
+
coerced = value.lower() == "true"
|
|
454
|
+
else:
|
|
455
|
+
try:
|
|
456
|
+
coerced = int(value)
|
|
457
|
+
except ValueError:
|
|
458
|
+
try:
|
|
459
|
+
coerced = float(value)
|
|
460
|
+
except ValueError:
|
|
461
|
+
coerced = value
|
|
462
|
+
try:
|
|
463
|
+
new_cfg = set_value(cfg, key, coerced)
|
|
464
|
+
except KeyError as e:
|
|
465
|
+
console.print(f"[bold red]error:[/bold red] {e}")
|
|
466
|
+
raise typer.Exit(1) from None
|
|
467
|
+
save_config(new_cfg)
|
|
468
|
+
console.print(f"[green]✓[/green] {key} = {coerced!r}")
|
|
469
|
+
console.print(f"[dim]saved to {config_file_path()}[/dim]")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@config_app.command("path")
|
|
473
|
+
def config_path() -> None:
|
|
474
|
+
"""Print the path to the config file."""
|
|
475
|
+
console.print(str(config_file_path()))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def main() -> None:
|
|
479
|
+
app()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
if __name__ == "__main__":
|
|
483
|
+
main()
|
opencomputer/doctor.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
opencomputer doctor — diagnose common issues.
|
|
3
|
+
|
|
4
|
+
Runs a battery of checks and prints a pass/fail report. Intended to be
|
|
5
|
+
the first thing a user runs when something isn't working.
|
|
6
|
+
|
|
7
|
+
Checks:
|
|
8
|
+
1. Python version
|
|
9
|
+
2. Config file exists + is valid YAML
|
|
10
|
+
3. Configured provider's plugin is installed
|
|
11
|
+
4. Provider API key is set in environment
|
|
12
|
+
5. Optional channel tokens are set if configured
|
|
13
|
+
6. Session DB is writable
|
|
14
|
+
7. Skills directory is writable
|
|
15
|
+
8. MCP servers can be reached (skipped if none configured)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Literal
|
|
26
|
+
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class Check:
|
|
34
|
+
name: str
|
|
35
|
+
status: Literal["pass", "fail", "warn", "skip"]
|
|
36
|
+
detail: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _status_icon(s: str) -> str:
|
|
40
|
+
return {"pass": "[green]✓[/green]", "fail": "[red]✗[/red]",
|
|
41
|
+
"warn": "[yellow]![/yellow]", "skip": "[dim]·[/dim]"}[s]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_python() -> Check:
|
|
45
|
+
v = sys.version_info
|
|
46
|
+
if (v.major, v.minor) < (3, 12):
|
|
47
|
+
return Check(
|
|
48
|
+
"python version", "fail",
|
|
49
|
+
f"need Python >=3.12, got {v.major}.{v.minor}.{v.micro}"
|
|
50
|
+
)
|
|
51
|
+
return Check("python version", "pass", f"{v.major}.{v.minor}.{v.micro}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_config() -> tuple[Check, object]:
|
|
55
|
+
from opencomputer.agent.config_store import config_file_path, load_config
|
|
56
|
+
|
|
57
|
+
path = config_file_path()
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return (
|
|
60
|
+
Check(
|
|
61
|
+
"config file", "warn",
|
|
62
|
+
f"no config at {path} — run `opencomputer setup`"
|
|
63
|
+
),
|
|
64
|
+
None,
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
cfg = load_config()
|
|
68
|
+
return Check("config file", "pass", str(path)), cfg
|
|
69
|
+
except Exception as e: # noqa: BLE001
|
|
70
|
+
return Check("config file", "fail", f"{path}: {e}"), None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_provider_plugin(cfg) -> Check:
|
|
74
|
+
from opencomputer.plugins.registry import registry as plugin_registry
|
|
75
|
+
|
|
76
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
77
|
+
ext_dir = repo_root / "extensions"
|
|
78
|
+
if ext_dir.exists():
|
|
79
|
+
plugin_registry.load_all([ext_dir])
|
|
80
|
+
|
|
81
|
+
if cfg is None:
|
|
82
|
+
return Check("provider plugin", "skip", "no config")
|
|
83
|
+
|
|
84
|
+
provider_id = cfg.model.provider
|
|
85
|
+
if provider_id in plugin_registry.providers:
|
|
86
|
+
return Check("provider plugin", "pass", f"'{provider_id}' registered")
|
|
87
|
+
return Check(
|
|
88
|
+
"provider plugin", "fail",
|
|
89
|
+
f"provider '{provider_id}' not found. "
|
|
90
|
+
f"installed: {list(plugin_registry.providers.keys()) or 'none'}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_provider_key(cfg) -> Check:
|
|
95
|
+
if cfg is None:
|
|
96
|
+
return Check("provider API key", "skip", "no config")
|
|
97
|
+
env_key = cfg.model.api_key_env
|
|
98
|
+
if os.environ.get(env_key):
|
|
99
|
+
return Check("provider API key", "pass", f"{env_key} is set")
|
|
100
|
+
return Check(
|
|
101
|
+
"provider API key", "fail",
|
|
102
|
+
f"{env_key} not set — export it before running"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_session_db(cfg) -> Check:
|
|
107
|
+
if cfg is None:
|
|
108
|
+
return Check("session DB", "skip", "no config")
|
|
109
|
+
db_path = cfg.session.db_path
|
|
110
|
+
try:
|
|
111
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
test_file = db_path.parent / ".writetest"
|
|
113
|
+
test_file.write_text("x")
|
|
114
|
+
test_file.unlink()
|
|
115
|
+
return Check("session DB path", "pass", str(db_path))
|
|
116
|
+
except Exception as e: # noqa: BLE001
|
|
117
|
+
return Check("session DB path", "fail", f"{db_path}: {e}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_skills_dir(cfg) -> Check:
|
|
121
|
+
if cfg is None:
|
|
122
|
+
return Check("skills dir", "skip", "no config")
|
|
123
|
+
p = cfg.memory.skills_path
|
|
124
|
+
try:
|
|
125
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
return Check("skills dir", "pass", str(p))
|
|
127
|
+
except Exception as e: # noqa: BLE001
|
|
128
|
+
return Check("skills dir", "fail", f"{p}: {e}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _check_channel_tokens(cfg) -> list[Check]:
|
|
132
|
+
out: list[Check] = []
|
|
133
|
+
if cfg is None:
|
|
134
|
+
return out
|
|
135
|
+
from opencomputer.plugins.registry import registry as plugin_registry
|
|
136
|
+
|
|
137
|
+
# Generic: for each channel plugin registered, check its conventional env var.
|
|
138
|
+
# We know about Telegram specifically because it's bundled.
|
|
139
|
+
if "telegram" in plugin_registry.channels:
|
|
140
|
+
tok = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
141
|
+
if tok:
|
|
142
|
+
out.append(Check("telegram token", "pass", "TELEGRAM_BOT_TOKEN set"))
|
|
143
|
+
else:
|
|
144
|
+
out.append(Check("telegram token", "skip", "TELEGRAM_BOT_TOKEN not set"))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _check_mcp(cfg) -> list[Check]:
|
|
149
|
+
out: list[Check] = []
|
|
150
|
+
if cfg is None or not cfg.mcp.servers:
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
from opencomputer.mcp.client import MCPConnection
|
|
154
|
+
|
|
155
|
+
for server in cfg.mcp.servers:
|
|
156
|
+
if not server.enabled:
|
|
157
|
+
out.append(Check(f"mcp:{server.name}", "skip", "disabled in config"))
|
|
158
|
+
continue
|
|
159
|
+
conn = MCPConnection(config=server)
|
|
160
|
+
try:
|
|
161
|
+
ok = await conn.connect()
|
|
162
|
+
if ok:
|
|
163
|
+
out.append(
|
|
164
|
+
Check(f"mcp:{server.name}", "pass", f"{len(conn.tools)} tool(s)")
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
out.append(Check(f"mcp:{server.name}", "fail", "connect returned False"))
|
|
168
|
+
finally:
|
|
169
|
+
await conn.disconnect()
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_doctor() -> int:
|
|
174
|
+
"""Run all checks and print a report. Returns the number of failed checks."""
|
|
175
|
+
console.print("\n[bold cyan]OpenComputer — Doctor[/bold cyan]\n")
|
|
176
|
+
|
|
177
|
+
checks: list[Check] = [_check_python()]
|
|
178
|
+
cfg_check, cfg = _check_config()
|
|
179
|
+
checks.append(cfg_check)
|
|
180
|
+
|
|
181
|
+
checks.append(_check_provider_plugin(cfg))
|
|
182
|
+
checks.append(_check_provider_key(cfg))
|
|
183
|
+
checks.append(_check_session_db(cfg))
|
|
184
|
+
checks.append(_check_skills_dir(cfg))
|
|
185
|
+
checks.extend(_check_channel_tokens(cfg))
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
mcp_checks = asyncio.run(_check_mcp(cfg))
|
|
189
|
+
except RuntimeError:
|
|
190
|
+
mcp_checks = []
|
|
191
|
+
checks.extend(mcp_checks)
|
|
192
|
+
|
|
193
|
+
# Print
|
|
194
|
+
max_name = max(len(c.name) for c in checks)
|
|
195
|
+
for c in checks:
|
|
196
|
+
pad = " " * (max_name - len(c.name))
|
|
197
|
+
detail = f" [dim]— {c.detail}[/dim]" if c.detail else ""
|
|
198
|
+
console.print(f" {_status_icon(c.status)} {c.name}{pad}{detail}")
|
|
199
|
+
|
|
200
|
+
failures = sum(1 for c in checks if c.status == "fail")
|
|
201
|
+
warnings = sum(1 for c in checks if c.status == "warn")
|
|
202
|
+
console.print()
|
|
203
|
+
if failures:
|
|
204
|
+
console.print(
|
|
205
|
+
f"[red bold]{failures} failure(s)[/red bold] — fix these before running."
|
|
206
|
+
)
|
|
207
|
+
elif warnings:
|
|
208
|
+
console.print(
|
|
209
|
+
f"[yellow bold]{warnings} warning(s)[/yellow bold] — should still work."
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
console.print("[green bold]All checks passed.[/green bold]")
|
|
213
|
+
return failures
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = ["run_doctor"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Gateway — WS daemon + platform dispatch (Phase 2)."""
|