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.
Files changed (51) hide show
  1. opencomputer/__init__.py +3 -0
  2. opencomputer/agent/__init__.py +1 -0
  3. opencomputer/agent/compaction.py +245 -0
  4. opencomputer/agent/config.py +108 -0
  5. opencomputer/agent/config_store.py +210 -0
  6. opencomputer/agent/injection.py +60 -0
  7. opencomputer/agent/loop.py +326 -0
  8. opencomputer/agent/memory.py +132 -0
  9. opencomputer/agent/prompt_builder.py +66 -0
  10. opencomputer/agent/prompts/base.j2 +23 -0
  11. opencomputer/agent/state.py +251 -0
  12. opencomputer/agent/step.py +31 -0
  13. opencomputer/cli.py +483 -0
  14. opencomputer/doctor.py +216 -0
  15. opencomputer/gateway/__init__.py +1 -0
  16. opencomputer/gateway/dispatch.py +89 -0
  17. opencomputer/gateway/protocol.py +84 -0
  18. opencomputer/gateway/server.py +77 -0
  19. opencomputer/gateway/wire_server.py +256 -0
  20. opencomputer/hooks/__init__.py +1 -0
  21. opencomputer/hooks/engine.py +79 -0
  22. opencomputer/hooks/runner.py +42 -0
  23. opencomputer/mcp/__init__.py +1 -0
  24. opencomputer/mcp/client.py +208 -0
  25. opencomputer/plugins/__init__.py +1 -0
  26. opencomputer/plugins/discovery.py +107 -0
  27. opencomputer/plugins/loader.py +155 -0
  28. opencomputer/plugins/registry.py +56 -0
  29. opencomputer/setup_wizard.py +235 -0
  30. opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
  31. opencomputer/tools/__init__.py +1 -0
  32. opencomputer/tools/bash.py +78 -0
  33. opencomputer/tools/delegate.py +98 -0
  34. opencomputer/tools/glob.py +70 -0
  35. opencomputer/tools/grep.py +117 -0
  36. opencomputer/tools/read.py +81 -0
  37. opencomputer/tools/registry.py +69 -0
  38. opencomputer/tools/skill_manage.py +265 -0
  39. opencomputer/tools/write.py +58 -0
  40. opencomputer-0.1.0.dist-info/METADATA +190 -0
  41. opencomputer-0.1.0.dist-info/RECORD +51 -0
  42. opencomputer-0.1.0.dist-info/WHEEL +4 -0
  43. opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
  44. plugin_sdk/__init__.py +66 -0
  45. plugin_sdk/channel_contract.py +74 -0
  46. plugin_sdk/core.py +129 -0
  47. plugin_sdk/hooks.py +80 -0
  48. plugin_sdk/injection.py +60 -0
  49. plugin_sdk/provider_contract.py +95 -0
  50. plugin_sdk/runtime_context.py +39 -0
  51. 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)."""