docent-cli 1.0.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.
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypeVar
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from docent.core.tool import Tool, collect_actions
8
+
9
+ _REGISTRY: dict[str, type[Tool]] = {}
10
+ _RESERVED_NAMES = frozenset({"list", "info", "config", "version"})
11
+
12
+ T = TypeVar("T", bound=Tool)
13
+
14
+
15
+ def register_tool(cls: type[T]) -> type[T]:
16
+ """Decorator: validate a Tool subclass and add it to the registry.
17
+
18
+ Registers the *class*, not an instance. Instances are created per
19
+ invocation by the caller (CLI command, UI request handler, etc.) so
20
+ tool runs never share mutable state.
21
+ """
22
+ if not isinstance(cls, type) or not issubclass(cls, Tool):
23
+ raise TypeError(
24
+ f"@register_tool must decorate a Tool subclass; got {cls!r}"
25
+ )
26
+
27
+ for attr in ("name", "description"):
28
+ if not hasattr(cls, attr):
29
+ raise TypeError(
30
+ f"Tool {cls.__name__} is missing required class attribute '{attr}'"
31
+ )
32
+
33
+ if not isinstance(cls.name, str) or not cls.name:
34
+ raise TypeError(
35
+ f"Tool {cls.__name__}.name must be a non-empty string"
36
+ )
37
+
38
+ if cls.name in _RESERVED_NAMES:
39
+ raise ValueError(
40
+ f"Tool name '{cls.name}' is reserved for a built-in CLI command; choose another"
41
+ )
42
+
43
+ actions = collect_actions(cls)
44
+ has_run = "run" in cls.__dict__
45
+ has_input_schema = cls.input_schema is not None
46
+
47
+ if actions and (has_run or has_input_schema):
48
+ raise TypeError(
49
+ f"Tool {cls.__name__} declares both @action methods and run()/input_schema. "
50
+ f"A tool must be single-action OR multi-action, not both."
51
+ )
52
+
53
+ if not actions:
54
+ if not has_run:
55
+ raise TypeError(
56
+ f"Tool {cls.__name__} must define run() or at least one @action method"
57
+ )
58
+ if not has_input_schema:
59
+ raise TypeError(
60
+ f"Tool {cls.__name__}.input_schema must be set for single-action tools"
61
+ )
62
+ if not (isinstance(cls.input_schema, type) and issubclass(cls.input_schema, BaseModel)):
63
+ raise TypeError(
64
+ f"Tool {cls.__name__}.input_schema must be a Pydantic BaseModel subclass"
65
+ )
66
+ else:
67
+ for cli_name, (method_name, meta) in actions.items():
68
+ if not (
69
+ isinstance(meta.input_schema, type)
70
+ and issubclass(meta.input_schema, BaseModel)
71
+ ):
72
+ raise TypeError(
73
+ f"Tool {cls.__name__}.{method_name}: @action input_schema must be a "
74
+ f"Pydantic BaseModel subclass"
75
+ )
76
+
77
+ if cls.name in _REGISTRY:
78
+ existing = _REGISTRY[cls.name]
79
+ raise ValueError(
80
+ f"Tool name '{cls.name}' is already registered by "
81
+ f"{existing.__module__}.{existing.__name__}; cannot re-register "
82
+ f"from {cls.__module__}.{cls.__name__}"
83
+ )
84
+
85
+ _REGISTRY[cls.name] = cls
86
+ return cls
87
+
88
+
89
+ def get_tool(name: str) -> type[Tool]:
90
+ if name not in _REGISTRY:
91
+ raise KeyError(f"No tool registered with name '{name}'")
92
+ return _REGISTRY[name]
93
+
94
+
95
+ def all_tools() -> dict[str, type[Tool]]:
96
+ return dict(_REGISTRY)
docent/core/tool.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable, ClassVar, TypeVar
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from docent.core.context import Context
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Action:
14
+ """Metadata attached to a method by `@action`.
15
+
16
+ The registry introspects each Tool class for methods carrying an `Action`
17
+ via `_docent_action`. CLI name defaults to the method name with
18
+ underscores → dashes (override via `name=`).
19
+ """
20
+
21
+ description: str
22
+ input_schema: type[BaseModel]
23
+ name: str | None = None
24
+
25
+
26
+ F = TypeVar("F", bound=Callable[..., Any])
27
+
28
+
29
+ def action(
30
+ *,
31
+ description: str,
32
+ input_schema: type[BaseModel],
33
+ name: str | None = None,
34
+ ) -> Callable[[F], F]:
35
+ """Decorator: mark a method on a Tool as an invocable action."""
36
+
37
+ def decorator(fn: F) -> F:
38
+ fn._docent_action = Action( # type: ignore[attr-defined]
39
+ description=description, input_schema=input_schema, name=name
40
+ )
41
+ return fn
42
+
43
+ return decorator
44
+
45
+
46
+ class Tool(ABC):
47
+ """Base class for every Docent tool.
48
+
49
+ Two paths:
50
+ - **Single-action**: set `input_schema` and override `run()`.
51
+ - **Multi-action**: decorate one or more methods with `@action(...)`.
52
+
53
+ A tool must be one or the other - never both. Enforced at registration.
54
+ Metadata lives as class attributes so the registry (and later the UI)
55
+ can read it without constructing an instance.
56
+ """
57
+
58
+ name: ClassVar[str]
59
+ description: ClassVar[str]
60
+ category: ClassVar[str | None] = None
61
+
62
+ input_schema: ClassVar[type[BaseModel] | None] = None
63
+
64
+ def run(self, inputs: BaseModel, context: Context) -> Any:
65
+ """Single-action entry point. Multi-action tools don't override this."""
66
+ raise NotImplementedError(
67
+ f"{type(self).__name__}.run() not implemented - "
68
+ f"is this a multi-action tool? Invoke one of its @action methods instead."
69
+ )
70
+
71
+
72
+ def collect_actions(cls: type[Tool]) -> dict[str, tuple[str, Action]]:
73
+ """Return `{cli_action_name: (method_name, Action)}` for all `@action` methods.
74
+
75
+ Raises `ValueError` on CLI-name collisions between actions.
76
+ """
77
+ found: dict[str, tuple[str, Action]] = {}
78
+ for attr_name in dir(cls):
79
+ attr = getattr(cls, attr_name, None)
80
+ if callable(attr) and hasattr(attr, "_docent_action"):
81
+ meta: Action = attr._docent_action # type: ignore[attr-defined]
82
+ cli_name = meta.name or attr_name.replace("_", "-")
83
+ if cli_name in found:
84
+ prev_method = found[cli_name][0]
85
+ raise ValueError(
86
+ f"Tool {cls.__name__} has two actions with the same CLI name "
87
+ f"'{cli_name}': {prev_method} and {attr_name}"
88
+ )
89
+ found[cli_name] = (attr_name, meta)
90
+ return found
@@ -0,0 +1,3 @@
1
+ from docent.execution.executor import Executor, ProcessExecutionError, ProcessResult
2
+
3
+ __all__ = ["Executor", "ProcessExecutionError", "ProcessResult"]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ProcessResult:
11
+ args: list[str]
12
+ returncode: int
13
+ stdout: str
14
+ stderr: str
15
+ duration: float
16
+
17
+
18
+ class ProcessExecutionError(RuntimeError):
19
+ """Raised by `Executor.run` when `check=True` and the process exits non-zero."""
20
+
21
+ def __init__(self, result: ProcessResult) -> None:
22
+ self.result = result
23
+ super().__init__(
24
+ f"Command {result.args!r} exited with {result.returncode} "
25
+ f"after {result.duration:.2f}s"
26
+ )
27
+
28
+
29
+ class Executor:
30
+ """Run external commands and return structured results.
31
+
32
+ Commands are always given as `list[str]` - never a shell string - so there
33
+ is no shell-injection surface. For tools that truly need shell features,
34
+ pass `["bash", "-c", "..."]` (or `["cmd", "/c", "..."]`) explicitly.
35
+ """
36
+
37
+ def run(
38
+ self,
39
+ args: list[str],
40
+ *,
41
+ timeout: float | None = None,
42
+ cwd: Path | str | None = None,
43
+ env: dict[str, str] | None = None,
44
+ check: bool = True,
45
+ ) -> ProcessResult:
46
+ start = time.perf_counter()
47
+ completed = subprocess.run(
48
+ args,
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=timeout,
52
+ cwd=cwd,
53
+ env=env,
54
+ check=False,
55
+ )
56
+ duration = time.perf_counter() - start
57
+
58
+ result = ProcessResult(
59
+ args=list(args),
60
+ returncode=completed.returncode,
61
+ stdout=completed.stdout,
62
+ stderr=completed.stderr,
63
+ duration=duration,
64
+ )
65
+
66
+ if check and completed.returncode != 0:
67
+ raise ProcessExecutionError(result)
68
+
69
+ return result
@@ -0,0 +1,3 @@
1
+ from docent.learning.run_log import RunLog
2
+
3
+ __all__ = ["RunLog"]
@@ -0,0 +1,69 @@
1
+ """Per-namespace run-log helpers.
2
+
3
+ A `RunLog(namespace)` is a JSONL file at `~/.docent/data/<namespace>/run-log.jsonl`
4
+ capped at `max_lines` (default 50). Append is cheap (single line write); tail is
5
+ cheap (read + split); rollover rewrites atomically when the cap is hit.
6
+
7
+ Entries are free-form JSON dicts. A `timestamp` field is auto-added on append
8
+ if the caller didn't supply one.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from docent.utils.paths import data_dir
19
+
20
+
21
+ class RunLog:
22
+ def __init__(self, namespace: str, *, max_lines: int = 50) -> None:
23
+ if not namespace or "/" in namespace or "\\" in namespace:
24
+ raise ValueError(f"Invalid namespace: {namespace!r}")
25
+ if max_lines < 1:
26
+ raise ValueError(f"max_lines must be >= 1, got {max_lines}")
27
+ self.namespace = namespace
28
+ self.max_lines = max_lines
29
+
30
+ @property
31
+ def path(self) -> Path:
32
+ return data_dir() / self.namespace / "run-log.jsonl"
33
+
34
+ def append(self, entry: dict[str, Any]) -> None:
35
+ record = dict(entry)
36
+ record.setdefault("timestamp", datetime.now().isoformat())
37
+ line = json.dumps(record, ensure_ascii=False)
38
+
39
+ self.path.parent.mkdir(parents=True, exist_ok=True)
40
+ existing = self._read_lines()
41
+
42
+ if len(existing) >= self.max_lines:
43
+ kept = existing[-(self.max_lines - 1):] if self.max_lines > 1 else []
44
+ self._atomic_rewrite(kept + [line])
45
+ else:
46
+ with self.path.open("a", encoding="utf-8") as f:
47
+ f.write(line + "\n")
48
+
49
+ def tail(self, n: int) -> list[dict[str, Any]]:
50
+ if n < 0:
51
+ raise ValueError(f"n must be >= 0, got {n}")
52
+ if n == 0:
53
+ return []
54
+ lines = self._read_lines()
55
+ return [json.loads(line) for line in lines[-n:]]
56
+
57
+ def all(self) -> list[dict[str, Any]]:
58
+ return [json.loads(line) for line in self._read_lines()]
59
+
60
+ def _read_lines(self) -> list[str]:
61
+ if not self.path.exists():
62
+ return []
63
+ text = self.path.read_text(encoding="utf-8")
64
+ return [line for line in text.splitlines() if line.strip()]
65
+
66
+ def _atomic_rewrite(self, lines: list[str]) -> None:
67
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
68
+ tmp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
69
+ os.replace(tmp, self.path)
docent/llm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from docent.llm.client import LLMClient, LLMResponse
2
+
3
+ __all__ = ["LLMClient", "LLMResponse"]
docent/llm/client.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from docent.config import Settings
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class LLMResponse:
11
+ text: str
12
+ model: str
13
+
14
+
15
+ class LLMClient:
16
+ """Thin wrapper around litellm. The single entry point for model calls.
17
+
18
+ litellm is imported lazily inside `complete()` so meta-commands
19
+ (`--version`, `list`, `info`) never pay the ~1s import cost.
20
+ """
21
+
22
+ def __init__(self, settings: Settings) -> None:
23
+ self.settings = settings
24
+ # Propagate config-provided API keys into env if the env var is unset.
25
+ # Env vars set by the user always win.
26
+ if settings.anthropic_api_key and not os.environ.get("ANTHROPIC_API_KEY"):
27
+ os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
28
+ if settings.openai_api_key and not os.environ.get("OPENAI_API_KEY"):
29
+ os.environ["OPENAI_API_KEY"] = settings.openai_api_key
30
+
31
+ def complete(
32
+ self,
33
+ prompt: str,
34
+ *,
35
+ system: str | None = None,
36
+ model: str | None = None,
37
+ temperature: float = 0.7,
38
+ max_tokens: int | None = None,
39
+ ) -> LLMResponse:
40
+ import litellm
41
+
42
+ messages: list[dict[str, str]] = []
43
+ if system:
44
+ messages.append({"role": "system", "content": system})
45
+ messages.append({"role": "user", "content": prompt})
46
+
47
+ kwargs: dict[str, object] = {}
48
+ if max_tokens is not None:
49
+ kwargs["max_tokens"] = max_tokens
50
+
51
+ response = litellm.completion(
52
+ model=model or self.settings.default_model,
53
+ messages=messages,
54
+ temperature=temperature,
55
+ num_retries=2,
56
+ **kwargs,
57
+ )
58
+
59
+ text = response.choices[0].message.content or ""
60
+ return LLMResponse(text=text, model=response.model)
docent/mcp_server.py ADDED
@@ -0,0 +1,187 @@
1
+ """Step 13 — Full MCP adapter.
2
+
3
+ Exposes every registered Docent action as an MCP tool callable from Claude Code
4
+ (or any other MCP-compatible client) over stdio.
5
+
6
+ Tool naming convention: `{tool_name}__{action_name}` where the action's CLI name
7
+ has hyphens replaced by underscores.
8
+
9
+ Example: reading tool's `sync-from-mendeley` → `reading__sync_from_mendeley`
10
+
11
+ The double-underscore separator is unambiguous: tool names are single-word
12
+ identifiers (`reading`, `paper`) and action names never contain `__`.
13
+
14
+ Usage:
15
+ docent serve # recommended — loads plugins first
16
+
17
+ Claude Code .mcp.json:
18
+ {
19
+ "mcpServers": {
20
+ "docent": {
21
+ "command": "uv",
22
+ "args": ["--directory", "<project-root>", "run", "docent", "serve"]
23
+ }
24
+ }
25
+ }
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import inspect
31
+ import json
32
+ from typing import Any
33
+
34
+ from mcp import types
35
+ from mcp.server import Server
36
+ from mcp.server.stdio import stdio_server
37
+ from pydantic import BaseModel
38
+
39
+ from docent.config import load_settings
40
+ from docent.core import (
41
+ Context,
42
+ ProgressEvent,
43
+ all_tools,
44
+ collect_actions,
45
+ load_plugins,
46
+ )
47
+ from docent.execution import Executor
48
+ from docent.llm import LLMClient
49
+ from docent.tools import discover_tools
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Naming helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def mcp_tool_name(tool_name: str, action_cli_name: str) -> str:
57
+ """Build the MCP tool name from a (tool, action) pair."""
58
+ return f"{tool_name}__{action_cli_name.replace('-', '_')}"
59
+
60
+
61
+ def parse_mcp_tool_name(mcp_name: str) -> tuple[str, str] | None:
62
+ """Return (tool_name, action_cli_name) or None if the name is not a Docent tool."""
63
+ if "__" not in mcp_name:
64
+ return None
65
+ tool_name, action_part = mcp_name.split("__", 1)
66
+ action_cli_name = action_part.replace("_", "-")
67
+ return tool_name, action_cli_name
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Registry introspection
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def build_mcp_tools() -> list[types.Tool]:
75
+ """Return one MCP Tool descriptor per (tool, action) pair in the registry."""
76
+ result = []
77
+ for tool_name, tool_cls in sorted(all_tools().items()):
78
+ for action_cli_name, (_method, meta) in sorted(collect_actions(tool_cls).items()):
79
+ result.append(
80
+ types.Tool(
81
+ name=mcp_tool_name(tool_name, action_cli_name),
82
+ description=f"[{tool_name}] {meta.description}",
83
+ inputSchema=meta.input_schema.model_json_schema(),
84
+ )
85
+ )
86
+ return result
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Action invocation
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def _make_context() -> Context:
94
+ settings = load_settings()
95
+ return Context(settings=settings, llm=LLMClient(settings), executor=Executor())
96
+
97
+
98
+ def _serialize(result: Any) -> str:
99
+ if isinstance(result, BaseModel):
100
+ return result.model_dump_json(indent=2)
101
+ try:
102
+ return json.dumps(result, indent=2, default=str)
103
+ except Exception:
104
+ return str(result)
105
+
106
+
107
+ def invoke_action(
108
+ tool_name: str,
109
+ action_cli_name: str,
110
+ arguments: dict[str, Any],
111
+ ) -> str:
112
+ """Run one Docent action and return its result as a JSON string.
113
+
114
+ Generator actions (streaming) collect ProgressEvent messages as a
115
+ prefix and the final return value as the last line.
116
+ """
117
+ tools = all_tools()
118
+ if tool_name not in tools:
119
+ raise ValueError(f"No tool named '{tool_name}'")
120
+
121
+ tool_cls = tools[tool_name]
122
+ actions = collect_actions(tool_cls)
123
+ if action_cli_name not in actions:
124
+ raise ValueError(f"Tool '{tool_name}' has no action '{action_cli_name}'")
125
+
126
+ method_name, meta = actions[action_cli_name]
127
+ inputs = meta.input_schema(**arguments)
128
+ ctx = _make_context()
129
+ method = getattr(tool_cls(), method_name)
130
+ raw = method(inputs, ctx)
131
+
132
+ if inspect.isgenerator(raw):
133
+ lines: list[str] = []
134
+ try:
135
+ while True:
136
+ evt = next(raw)
137
+ if isinstance(evt, ProgressEvent) and evt.message:
138
+ lines.append(f"[{evt.phase}] {evt.message}")
139
+ except StopIteration as stop:
140
+ lines.append(_serialize(stop.value))
141
+ return "\n".join(lines)
142
+
143
+ return _serialize(raw)
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # MCP server
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def run_server() -> None:
151
+ """Load plugins, build the MCP tool list, and serve over stdio.
152
+
153
+ Called by `docent serve`. Blocks until the client disconnects.
154
+ """
155
+ import sys
156
+ discover_tools()
157
+ load_plugins()
158
+ tools = build_mcp_tools()
159
+ print(f"[docent] MCP server ready — {len(tools)} tools registered. Waiting for client…", file=sys.stderr, flush=True)
160
+
161
+ server = Server("docent")
162
+
163
+ @server.list_tools()
164
+ async def list_tools() -> list[types.Tool]:
165
+ return build_mcp_tools()
166
+
167
+ @server.call_tool()
168
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
169
+ parsed = parse_mcp_tool_name(name)
170
+ if parsed is None:
171
+ return [types.TextContent(type="text", text=f"Unknown tool format: {name!r}")]
172
+ tool_name, action_cli_name = parsed
173
+ try:
174
+ text = invoke_action(tool_name, action_cli_name, arguments or {})
175
+ return [types.TextContent(type="text", text=text)]
176
+ except Exception as exc:
177
+ return [types.TextContent(type="text", text=f"Error: {exc}")]
178
+
179
+ async def _serve() -> None:
180
+ async with stdio_server() as (read_stream, write_stream):
181
+ await server.run(
182
+ read_stream,
183
+ write_stream,
184
+ server.create_initialization_options(),
185
+ )
186
+
187
+ asyncio.run(_serve())
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import pkgutil
5
+
6
+
7
+ def discover_tools() -> None:
8
+ """Import every module in `docent.tools` so `@register_tool` fires.
9
+
10
+ Each `.py` in this package is treated as a tool module. Names beginning
11
+ with `_` are skipped so tests and scratch modules can coexist without
12
+ being auto-loaded.
13
+ """
14
+ for _, name, _ in pkgutil.iter_modules(__path__):
15
+ if name.startswith("_"):
16
+ continue
17
+ importlib.import_module(f"{__name__}.{name}")
docent/ui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from docent.ui.console import configure_console, get_console
2
+
3
+ __all__ = ["configure_console", "get_console"]
docent/ui/console.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+
5
+ from docent.ui.theme import docent_theme
6
+
7
+ _console: Console | None = None
8
+
9
+
10
+ def get_console() -> Console:
11
+ global _console
12
+ if _console is None:
13
+ _console = Console(theme=docent_theme)
14
+ return _console
15
+
16
+
17
+ def configure_console(*, no_color: bool = False, quiet: bool = False) -> Console:
18
+ """Replace the singleton with one matching the given flags.
19
+
20
+ Called once from the CLI root callback, after parsing global flags.
21
+ """
22
+ global _console
23
+ _console = Console(
24
+ theme=docent_theme,
25
+ no_color=no_color,
26
+ quiet=quiet,
27
+ )
28
+ return _console
docent/ui/theme.py ADDED
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.theme import Theme
4
+
5
+ docent_theme = Theme(
6
+ {
7
+ "success": "bold green",
8
+ "error": "bold red",
9
+ "warning": "yellow",
10
+ "info": "cyan",
11
+ "muted": "dim white",
12
+ "accent": "bold magenta",
13
+ "header": "bold",
14
+ "prompt": "bold cyan",
15
+ }
16
+ )
File without changes