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/__init__.py +1 -0
- docent/bundled_plugins/__init__.py +0 -0
- docent/bundled_plugins/reading/__init__.py +1205 -0
- docent/bundled_plugins/reading/mendeley_cache.py +183 -0
- docent/bundled_plugins/reading/mendeley_client.py +132 -0
- docent/bundled_plugins/reading/reading_notify.py +78 -0
- docent/bundled_plugins/reading/reading_store.py +105 -0
- docent/cli.py +310 -0
- docent/config/__init__.py +4 -0
- docent/config/loader.py +76 -0
- docent/config/settings.py +51 -0
- docent/core/__init__.py +19 -0
- docent/core/context.py +14 -0
- docent/core/events.py +35 -0
- docent/core/plugin_loader.py +99 -0
- docent/core/registry.py +96 -0
- docent/core/tool.py +90 -0
- docent/execution/__init__.py +3 -0
- docent/execution/executor.py +69 -0
- docent/learning/__init__.py +3 -0
- docent/learning/run_log.py +69 -0
- docent/llm/__init__.py +3 -0
- docent/llm/client.py +60 -0
- docent/mcp_server.py +187 -0
- docent/tools/__init__.py +17 -0
- docent/ui/__init__.py +3 -0
- docent/ui/console.py +28 -0
- docent/ui/theme.py +16 -0
- docent/utils/__init__.py +0 -0
- docent/utils/paths.py +36 -0
- docent/utils/prompt.py +63 -0
- docent_cli-1.0.0.dist-info/METADATA +174 -0
- docent_cli-1.0.0.dist-info/RECORD +35 -0
- docent_cli-1.0.0.dist-info/WHEEL +4 -0
- docent_cli-1.0.0.dist-info/entry_points.txt +3 -0
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()
|
docent/config/loader.py
ADDED
|
@@ -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)
|
docent/core/__init__.py
ADDED
|
@@ -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)
|