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.
docent/cli.py ADDED
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Any, Callable
5
+
6
+ import typer
7
+ from pydantic import BaseModel
8
+ from rich import box
9
+ from rich.panel import Panel
10
+ from rich.progress import (
11
+ BarColumn,
12
+ MofNCompleteColumn,
13
+ Progress,
14
+ SpinnerColumn,
15
+ TextColumn,
16
+ TimeElapsedColumn,
17
+ )
18
+ from rich.table import Table
19
+
20
+ from docent import __version__
21
+ from docent.config import load_settings
22
+ from docent.core import (
23
+ Context,
24
+ ProgressEvent,
25
+ Tool,
26
+ all_tools,
27
+ collect_actions,
28
+ load_plugins,
29
+ run_startup_hooks,
30
+ )
31
+ from docent.execution import Executor
32
+ from docent.llm import LLMClient
33
+ from docent.tools import discover_tools
34
+ from docent.ui import configure_console, get_console
35
+
36
+ app = typer.Typer(
37
+ name="docent",
38
+ help="Docent — a personal control center for grad school workflows.",
39
+ no_args_is_help=True,
40
+ add_completion=False,
41
+ )
42
+
43
+
44
+ def _version_callback(value: bool) -> None:
45
+ if value:
46
+ typer.echo(f"docent {__version__}")
47
+ raise typer.Exit()
48
+
49
+
50
+ @app.callback()
51
+ def main(
52
+ ctx: typer.Context,
53
+ version: bool = typer.Option(
54
+ False,
55
+ "--version",
56
+ help="Show the Docent version and exit.",
57
+ callback=_version_callback,
58
+ is_eager=True,
59
+ ),
60
+ verbose: bool = typer.Option(
61
+ False,
62
+ "--verbose",
63
+ "-v",
64
+ help="Verbose output.",
65
+ ),
66
+ no_color: bool = typer.Option(
67
+ False,
68
+ "--no-color",
69
+ help="Disable colored output.",
70
+ ),
71
+ ) -> None:
72
+ """Docent — grad-school workflow dispatcher."""
73
+ settings = load_settings()
74
+ settings.verbose = verbose or settings.verbose
75
+ settings.no_color = no_color or settings.no_color
76
+
77
+ configure_console(no_color=settings.no_color)
78
+
79
+ ctx.obj = Context(settings=settings, llm=LLMClient(settings), executor=Executor())
80
+ run_startup_hooks(ctx.obj)
81
+
82
+
83
+ @app.command("list", help="List all registered tools.")
84
+ def list_command(ctx: typer.Context) -> None:
85
+ console = get_console()
86
+ tools = all_tools()
87
+ if not tools:
88
+ console.print("[dim]No tools registered yet.[/]")
89
+ return
90
+
91
+ by_category: dict[str, list[type[Tool]]] = {}
92
+ for tool_cls in tools.values():
93
+ by_category.setdefault(tool_cls.category or "Uncategorized", []).append(tool_cls)
94
+
95
+ for category, group in sorted(by_category.items()):
96
+ table = Table(title=category, box=box.ROUNDED, show_header=True, header_style="bold")
97
+ table.add_column("Name", style="cyan", no_wrap=True)
98
+ table.add_column("Description")
99
+ for tc in sorted(group, key=lambda c: c.name):
100
+ actions = collect_actions(tc)
101
+ name_display = tc.name if not actions else f"{tc.name} ({len(actions)} actions)"
102
+ table.add_row(name_display, tc.description)
103
+ console.print(table)
104
+
105
+
106
+ @app.command("info", help="Show details about a registered tool.")
107
+ def info_command(
108
+ ctx: typer.Context,
109
+ name: str = typer.Argument(..., help="Name of the tool."),
110
+ ) -> None:
111
+ console = get_console()
112
+ tools = all_tools()
113
+ if name not in tools:
114
+ console.print(f"[red]No tool named '{name}'.[/]")
115
+ raise typer.Exit(2)
116
+
117
+ tool_cls = tools[name]
118
+ lines = [
119
+ f"[bold]Name:[/] {tool_cls.name}",
120
+ f"[bold]Category:[/] {tool_cls.category or 'Uncategorized'}",
121
+ f"[bold]Description:[/] {tool_cls.description}",
122
+ ]
123
+
124
+ actions = collect_actions(tool_cls)
125
+ if actions:
126
+ lines.append("")
127
+ lines.append(f"[bold]Actions ({len(actions)}):[/]")
128
+ for cli_name, (_method_name, meta) in sorted(actions.items()):
129
+ lines.append(f" [cyan]{cli_name}[/] - {meta.description}")
130
+ for fname, finfo in meta.input_schema.model_fields.items():
131
+ lines.append(f" {_format_field(fname, finfo)}")
132
+ else:
133
+ assert tool_cls.input_schema is not None
134
+ lines.append("")
135
+ lines.append("[bold]Inputs:[/]")
136
+ for fname, finfo in tool_cls.input_schema.model_fields.items():
137
+ lines.append(f" {_format_field(fname, finfo)}")
138
+
139
+ console.print(Panel("\n".join(lines), title=tool_cls.name, border_style="cyan"))
140
+
141
+
142
+ def _format_field(fname: str, finfo: Any) -> str:
143
+ status = "(required)" if finfo.is_required() else f"(default={finfo.default!r})"
144
+ annot = getattr(finfo.annotation, "__name__", str(finfo.annotation))
145
+ desc = f" - {finfo.description}" if finfo.description else ""
146
+ return f"--{fname.replace('_', '-')}: {annot} {status}{desc}"
147
+
148
+
149
+ def _drive_progress(gen: Any) -> Any:
150
+ """Drive a generator-based action, rendering events with Rich Progress.
151
+
152
+ Phase changes swap to a fresh task. Events with (current, total) advance
153
+ a bar; events without it (or with level=warn/error) print a console line.
154
+ The action's `return` value is captured from `StopIteration.value`.
155
+ """
156
+ console = get_console()
157
+ columns = (
158
+ SpinnerColumn(),
159
+ TextColumn("[bold cyan]{task.description}"),
160
+ BarColumn(),
161
+ MofNCompleteColumn(),
162
+ TextColumn("[dim]{task.fields[item]}"),
163
+ TimeElapsedColumn(),
164
+ )
165
+ result: Any = None
166
+ with Progress(*columns, console=console, transient=False) as progress:
167
+ task_id: int | None = None
168
+ current_phase: str | None = None
169
+ try:
170
+ while True:
171
+ evt = next(gen)
172
+ if not isinstance(evt, ProgressEvent):
173
+ progress.console.print(f"[yellow]warn[/] non-event yielded: {evt!r}")
174
+ continue
175
+ if evt.level in ("warn", "error"):
176
+ tag = "[yellow]warn[/]" if evt.level == "warn" else "[red]error[/]"
177
+ text = evt.message or (f"{evt.phase}: {evt.item}" if evt.item else evt.phase)
178
+ progress.console.print(f"{tag} {text}")
179
+ continue
180
+ if evt.total is not None:
181
+ if task_id is None or evt.phase != current_phase:
182
+ if task_id is not None:
183
+ progress.remove_task(task_id)
184
+ task_id = progress.add_task(
185
+ evt.phase, total=evt.total, item=evt.item or ""
186
+ )
187
+ current_phase = evt.phase
188
+ progress.update(
189
+ task_id, completed=evt.current or 0, item=evt.item or ""
190
+ )
191
+ elif evt.message:
192
+ progress.console.print(f"[dim]{evt.phase}[/] {evt.message}")
193
+ except StopIteration as stop:
194
+ result = stop.value
195
+ return result
196
+
197
+
198
+ def _build_callback(
199
+ schema: type[BaseModel],
200
+ invoke: Callable[[BaseModel, Context], Any],
201
+ name: str,
202
+ doc: str,
203
+ ) -> Any:
204
+ """Build a Typer callback with a synthesized signature from a Pydantic schema.
205
+
206
+ `invoke(inputs, context)` is called with validated inputs and the Context
207
+ from `ctx.obj`. Its return value (if not None) is printed via the CLI's
208
+ console singleton.
209
+ """
210
+
211
+ def callback(**kwargs: Any) -> None:
212
+ ctx: typer.Context = kwargs.pop("ctx")
213
+ inputs = schema(**kwargs)
214
+ context: Context = ctx.obj
215
+ maybe = invoke(inputs, context)
216
+ result = _drive_progress(maybe) if inspect.isgenerator(maybe) else maybe
217
+ if result is not None:
218
+ get_console().print(result)
219
+
220
+ params = [
221
+ inspect.Parameter("ctx", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=typer.Context),
222
+ ]
223
+ annotations: dict[str, Any] = {"ctx": typer.Context}
224
+
225
+ for fname, finfo in schema.model_fields.items():
226
+ cli_flag = "--" + fname.replace("_", "-")
227
+ help_text = finfo.description or ""
228
+ option_default = ... if finfo.is_required() else finfo.default
229
+ option = typer.Option(option_default, cli_flag, help=help_text)
230
+ params.append(
231
+ inspect.Parameter(
232
+ fname,
233
+ inspect.Parameter.KEYWORD_ONLY,
234
+ default=option,
235
+ annotation=finfo.annotation,
236
+ )
237
+ )
238
+ annotations[fname] = finfo.annotation
239
+
240
+ callback.__signature__ = inspect.Signature(params) # type: ignore[attr-defined]
241
+ callback.__annotations__ = annotations
242
+ callback.__name__ = name
243
+ callback.__doc__ = doc
244
+ return callback
245
+
246
+
247
+ def _register_tool_in_app(tool_cls: type[Tool]) -> None:
248
+ """Attach a single- or multi-action tool to the top-level Typer app."""
249
+ actions = collect_actions(tool_cls)
250
+
251
+ if not actions:
252
+ assert tool_cls.input_schema is not None
253
+ callback = _build_callback(
254
+ schema=tool_cls.input_schema,
255
+ invoke=lambda inp, ctx: tool_cls().run(inp, ctx),
256
+ name=tool_cls.name.replace("-", "_"),
257
+ doc=tool_cls.description,
258
+ )
259
+ app.command(name=tool_cls.name, help=tool_cls.description)(callback)
260
+ return
261
+
262
+ subapp = typer.Typer(
263
+ name=tool_cls.name,
264
+ help=tool_cls.description,
265
+ no_args_is_help=True,
266
+ add_completion=False,
267
+ )
268
+ for cli_name, (method_name, meta) in sorted(actions.items()):
269
+ def make_invoke(mname: str) -> Callable[[BaseModel, Context], Any]:
270
+ return lambda inp, ctx, _m=mname: getattr(tool_cls(), _m)(inp, ctx)
271
+
272
+ callback = _build_callback(
273
+ schema=meta.input_schema,
274
+ invoke=make_invoke(method_name),
275
+ name=cli_name.replace("-", "_"),
276
+ doc=meta.description,
277
+ )
278
+ subapp.command(name=cli_name, help=meta.description)(callback)
279
+ app.add_typer(subapp, name=tool_cls.name, help=tool_cls.description)
280
+
281
+
282
+ discover_tools()
283
+ load_plugins()
284
+ for _tool_cls in all_tools().values():
285
+ _register_tool_in_app(_tool_cls)
286
+
287
+
288
+ @app.command("serve", help="Start the Docent MCP server (stdio transport).")
289
+ def serve_command() -> None:
290
+ """Expose all registered Docent actions as MCP tools over stdio.
291
+
292
+ Add to Claude Code's .mcp.json:
293
+
294
+ \\b
295
+ {
296
+ "mcpServers": {
297
+ "docent": {
298
+ "command": "uv",
299
+ "args": ["--directory", "<project-root>", "run", "docent", "serve"]
300
+ }
301
+ }
302
+ }
303
+ """
304
+ from docent.mcp_server import run_server
305
+
306
+ run_server()
307
+
308
+
309
+ if __name__ == "__main__":
310
+ app()
@@ -0,0 +1,4 @@
1
+ from docent.config.loader import load_settings, write_setting
2
+ from docent.config.settings import ReadingSettings, Settings
3
+
4
+ __all__ = ["ReadingSettings", "Settings", "load_settings", "write_setting"]
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import tomli_w
8
+
9
+ from docent.config.settings import Settings
10
+ from docent.utils.paths import cache_dir, config_file, data_dir, logs_dir, root_dir
11
+
12
+ _DEFAULT_CONFIG_TOML = """# Docent configuration
13
+ # Env vars (DOCENT_*) override these values.
14
+
15
+ default_model = "anthropic/claude-sonnet-4-6"
16
+ verbose = false
17
+ no_color = false
18
+
19
+ # anthropic_api_key = "sk-ant-..."
20
+ # openai_api_key = "sk-..."
21
+
22
+ [tools]
23
+ # Per-tool settings live here. Example:
24
+ # [tools.feynman]
25
+ # binary_path = "/usr/local/bin/feynman"
26
+ """
27
+
28
+
29
+ def _ensure_runtime_dirs() -> None:
30
+ for d in (root_dir(), cache_dir(), logs_dir(), data_dir()):
31
+ d.mkdir(parents=True, exist_ok=True)
32
+
33
+
34
+ def _ensure_config_file() -> Path:
35
+ path = config_file()
36
+ if not path.exists():
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ path.write_text(_DEFAULT_CONFIG_TOML, encoding="utf-8")
39
+ return path
40
+
41
+
42
+ def load_settings() -> Settings:
43
+ _ensure_runtime_dirs()
44
+ path = _ensure_config_file()
45
+ with path.open("rb") as f:
46
+ toml_data = tomllib.load(f)
47
+ return Settings(**toml_data)
48
+
49
+
50
+ def write_setting(key_path: str, value: Any) -> Path:
51
+ """Persist a setting into config.toml under a dotted key path.
52
+
53
+ `key_path` is a dotted path like "paper.database_dir". Sections are
54
+ created on demand. Existing TOML structure is preserved (round-trip
55
+ via tomllib + tomli_w). Returns the config-file path written.
56
+
57
+ Use for one-off `config-set` style writes; not for bulk migration.
58
+ """
59
+ if not key_path or any(not seg for seg in key_path.split(".")):
60
+ raise ValueError(f"Invalid setting key {key_path!r}")
61
+ _ensure_runtime_dirs()
62
+ path = _ensure_config_file()
63
+ with path.open("rb") as f:
64
+ data = tomllib.load(f)
65
+ cursor: dict[str, Any] = data
66
+ segments = key_path.split(".")
67
+ for seg in segments[:-1]:
68
+ next_cursor = cursor.get(seg)
69
+ if not isinstance(next_cursor, dict):
70
+ next_cursor = {}
71
+ cursor[seg] = next_cursor
72
+ cursor = next_cursor
73
+ cursor[segments[-1]] = value
74
+ with path.open("wb") as f:
75
+ tomli_w.dump(data, f)
76
+ return path
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
8
+
9
+
10
+ class ReadingSettings(BaseModel):
11
+ """First-party `reading` tool settings. Stored under `[reading]` in config.toml.
12
+
13
+ Env-overridable as `DOCENT_READING__<FIELD>` (double underscore for nesting).
14
+ `database_dir` accepts a path with `~`; expansion is the caller's job.
15
+ `database_dir` IS the Mendeley watch folder — Mendeley auto-imports anything
16
+ dropped here.
17
+ """
18
+
19
+ database_dir: Path | None = None
20
+ mendeley_mcp_command: list[str] | None = None # e.g. ["uvx", "mendeley-mcp"]; None -> default in mendeley_client.
21
+ queue_collection: str = "Docent-Queue" # Mendeley collection name that defines reading-queue membership.
22
+
23
+
24
+ class Settings(BaseSettings):
25
+ model_config = SettingsConfigDict(
26
+ env_prefix="DOCENT_",
27
+ env_nested_delimiter="__",
28
+ extra="ignore",
29
+ )
30
+
31
+ default_model: str = "anthropic/claude-sonnet-4-6"
32
+ verbose: bool = False
33
+ no_color: bool = False
34
+
35
+ anthropic_api_key: str | None = None
36
+ openai_api_key: str | None = None
37
+
38
+ reading: ReadingSettings = Field(default_factory=ReadingSettings)
39
+
40
+ tools: dict[str, dict[str, Any]] = Field(default_factory=dict)
41
+
42
+ @classmethod
43
+ def settings_customise_sources(
44
+ cls,
45
+ settings_cls: type[BaseSettings],
46
+ init_settings: PydanticBaseSettingsSource,
47
+ env_settings: PydanticBaseSettingsSource,
48
+ dotenv_settings: PydanticBaseSettingsSource,
49
+ file_secret_settings: PydanticBaseSettingsSource,
50
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
51
+ return (env_settings, init_settings, file_secret_settings)
@@ -0,0 +1,19 @@
1
+ from docent.core.context import Context
2
+ from docent.core.events import ProgressEvent
3
+ from docent.core.plugin_loader import load_plugins, run_startup_hooks
4
+ from docent.core.registry import all_tools, get_tool, register_tool
5
+ from docent.core.tool import Action, Tool, action, collect_actions
6
+
7
+ __all__ = [
8
+ "Action",
9
+ "Context",
10
+ "ProgressEvent",
11
+ "Tool",
12
+ "action",
13
+ "all_tools",
14
+ "collect_actions",
15
+ "get_tool",
16
+ "load_plugins",
17
+ "register_tool",
18
+ "run_startup_hooks",
19
+ ]
docent/core/context.py ADDED
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from docent.config import Settings
6
+ from docent.execution import Executor
7
+ from docent.llm import LLMClient
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Context:
12
+ settings: Settings
13
+ llm: LLMClient
14
+ executor: Executor
docent/core/events.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class ProgressEvent(BaseModel):
9
+ """Streaming event yielded by long-running actions.
10
+
11
+ A generator-based action yields zero or more `ProgressEvent` values during
12
+ execution and `return`s its final result. The CLI dispatcher renders each
13
+ event live (via Rich `Progress`) and captures the result from
14
+ `StopIteration.value`. Tests can drive the same generator and collect
15
+ events into a list.
16
+
17
+ Field semantics:
18
+ - `phase`: short identifier for the work the action is doing now
19
+ (e.g. "discover", "add", "scholar"). Phase changes signal that the
20
+ renderer should swap to a fresh progress bar.
21
+ - `current` / `total`: optional 1-based progress within the phase.
22
+ When both are set, the renderer draws a bar; when both are None,
23
+ the event is rendered as an info line.
24
+ - `item`: short label for the current item (filename, DOI, ...).
25
+ - `message`: free-form text; required when there's no (current, total).
26
+ - `level`: severity. Errors and warnings render as console lines
27
+ independent of any progress bar.
28
+ """
29
+
30
+ phase: str
31
+ message: str = ""
32
+ current: int | None = None
33
+ total: int | None = None
34
+ item: str = ""
35
+ level: Literal["info", "warn", "error"] = "info"
@@ -0,0 +1,99 @@
1
+ """External plugin discovery.
2
+
3
+ Search order:
4
+ 1. src/docent/bundled_plugins/ (shipped plugins — may not exist yet, skip gracefully)
5
+ 2. ~/.docent/plugins/ (user-installed plugins, via plugins_dir() from paths.py)
6
+
7
+ Each directory is added to sys.path before importing, so plugin packages can use
8
+ relative imports internally. Both flat *.py files and packages (dir + __init__.py)
9
+ are supported. Names starting with _ are skipped.
10
+
11
+ Broken plugins: print one-line warning to stderr, continue loading others.
12
+ Name conflicts (plugin registers a name already taken): the registry raises ValueError,
13
+ which is caught and printed as a warning — same skip behaviour.
14
+
15
+ Startup hooks: plugins may define on_startup(context: Context) at module level.
16
+ The loader collects them; run_startup_hooks(context) calls them all after the CLI
17
+ creates its Context object.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Callable
26
+
27
+ from docent.core.context import Context
28
+ from docent.utils.paths import plugins_dir
29
+
30
+ _STARTUP_HOOKS: list[Callable[[Context], None]] = []
31
+
32
+
33
+ def _bundled_plugins_dir() -> Path:
34
+ import docent
35
+
36
+ return Path(docent.__file__).parent / "bundled_plugins"
37
+
38
+
39
+ def _ensure_in_sys_path(directory: Path) -> None:
40
+ str_path = str(directory)
41
+ if str_path not in sys.path:
42
+ sys.path.insert(0, str_path)
43
+
44
+
45
+ def _load_plugin_module(module_name: str) -> bool:
46
+ """Import a plugin module and collect its startup hook if present.
47
+
48
+ Returns True if loaded successfully, False if there was a recoverable error.
49
+ """
50
+ try:
51
+ importlib.import_module(module_name)
52
+ except Exception as exc:
53
+ print(f"Warning: failed to load plugin '{module_name}': {exc}", file=sys.stderr)
54
+ return False
55
+
56
+ module = sys.modules.get(module_name)
57
+ if module is not None and hasattr(module, "on_startup"):
58
+ hook = getattr(module, "on_startup")
59
+ if callable(hook):
60
+ _STARTUP_HOOKS.append(hook)
61
+
62
+ return True
63
+
64
+
65
+ def _scan_plugin_dir(directory: Path) -> None:
66
+ """Scan a directory for plugin modules/packages and load them."""
67
+ if not directory.exists():
68
+ return
69
+
70
+ _ensure_in_sys_path(directory)
71
+
72
+ for entry in directory.iterdir():
73
+ name = entry.name
74
+
75
+ if name.startswith("_"):
76
+ continue
77
+
78
+ if entry.is_file() and name.endswith(".py"):
79
+ module_name = name[:-3]
80
+ _load_plugin_module(module_name)
81
+ elif entry.is_dir() and (entry / "__init__.py").exists():
82
+ module_name = name
83
+ _load_plugin_module(module_name)
84
+
85
+
86
+ def load_plugins() -> None:
87
+ """Discover and load all external plugins."""
88
+ _STARTUP_HOOKS.clear()
89
+ _scan_plugin_dir(_bundled_plugins_dir())
90
+ _scan_plugin_dir(plugins_dir())
91
+
92
+
93
+ def run_startup_hooks(context: Context) -> None:
94
+ """Call all collected startup hooks."""
95
+ for hook in _STARTUP_HOOKS:
96
+ try:
97
+ hook(context)
98
+ except Exception as exc:
99
+ print(f"Warning: startup hook failed: {exc}", file=sys.stderr)