dulus 0.2.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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
plugin/store.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Plugin store: install/uninstall/enable/disable/update + config persistence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .types import PluginEntry, PluginManifest, PluginScope, parse_plugin_identifier, sanitize_plugin_name
|
|
14
|
+
|
|
15
|
+
# ── Config paths ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
USER_PLUGIN_DIR = Path.home() / ".dulus" / "plugins"
|
|
18
|
+
USER_PLUGIN_CFG = Path.home() / ".dulus" / "plugins.json"
|
|
19
|
+
|
|
20
|
+
def _project_plugin_dir() -> Path:
|
|
21
|
+
return Path.cwd() / ".dulus-context" / "plugins"
|
|
22
|
+
|
|
23
|
+
def _project_plugin_cfg() -> Path:
|
|
24
|
+
return Path.cwd() / ".dulus-context" / "plugins.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Config read/write ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def _read_cfg(cfg_path: Path) -> dict:
|
|
30
|
+
if cfg_path.exists():
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
return {"plugins": {}}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write_cfg(cfg_path: Path, data: dict) -> None:
|
|
39
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
cfg_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _plugin_dir_for(scope: PluginScope) -> Path:
|
|
44
|
+
return USER_PLUGIN_DIR if scope == PluginScope.USER else _project_plugin_dir()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _plugin_cfg_for(scope: PluginScope) -> Path:
|
|
48
|
+
return USER_PLUGIN_CFG if scope == PluginScope.USER else _project_plugin_cfg()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── List ──────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def list_plugins(scope: PluginScope | None = None) -> list[PluginEntry]:
|
|
54
|
+
"""Return all installed plugins (optionally filtered by scope)."""
|
|
55
|
+
entries: list[PluginEntry] = []
|
|
56
|
+
scopes = [PluginScope.USER, PluginScope.PROJECT] if scope is None else [scope]
|
|
57
|
+
for sc in scopes:
|
|
58
|
+
cfg = _read_cfg(_plugin_cfg_for(sc))
|
|
59
|
+
for name, data in cfg.get("plugins", {}).items():
|
|
60
|
+
entry = PluginEntry.from_dict(data)
|
|
61
|
+
entry.manifest = PluginManifest.from_plugin_dir(entry.install_dir)
|
|
62
|
+
entries.append(entry)
|
|
63
|
+
return entries
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_plugin(name: str, scope: PluginScope | None = None) -> PluginEntry | None:
|
|
67
|
+
for entry in list_plugins(scope):
|
|
68
|
+
if entry.name == name:
|
|
69
|
+
return entry
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Install ───────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def install_plugin(
|
|
76
|
+
identifier: str,
|
|
77
|
+
scope: PluginScope = PluginScope.USER,
|
|
78
|
+
force: bool = False,
|
|
79
|
+
) -> tuple[bool, str]:
|
|
80
|
+
"""
|
|
81
|
+
Install a plugin. identifier = 'name' | 'name@git_url' | 'name@local_path'.
|
|
82
|
+
Returns (success, message).
|
|
83
|
+
"""
|
|
84
|
+
name, source = parse_plugin_identifier(identifier)
|
|
85
|
+
safe_name = sanitize_plugin_name(name)
|
|
86
|
+
|
|
87
|
+
# Check if already installed
|
|
88
|
+
existing = get_plugin(safe_name, scope)
|
|
89
|
+
if existing and not force:
|
|
90
|
+
return False, f"Plugin '{safe_name}' is already installed in {scope.value} scope. Use --force to reinstall."
|
|
91
|
+
|
|
92
|
+
plugin_dir = _plugin_dir_for(scope) / safe_name
|
|
93
|
+
deps_to_install = []
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
if source is None:
|
|
97
|
+
# No source → treat name as a local path if it exists, else error
|
|
98
|
+
local = Path(name)
|
|
99
|
+
if local.exists() and local.is_dir():
|
|
100
|
+
source = str(local.resolve())
|
|
101
|
+
else:
|
|
102
|
+
return False, (
|
|
103
|
+
f"No source specified for '{name}'. "
|
|
104
|
+
"Provide 'name@git_url' or 'name@/local/path'."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Install from local path or git
|
|
108
|
+
if plugin_dir.exists() and force:
|
|
109
|
+
shutil.rmtree(plugin_dir)
|
|
110
|
+
|
|
111
|
+
if _is_git_url(source):
|
|
112
|
+
ok, msg = _clone_plugin(source, plugin_dir)
|
|
113
|
+
if not ok:
|
|
114
|
+
return False, msg
|
|
115
|
+
else:
|
|
116
|
+
local_src = Path(source)
|
|
117
|
+
if not local_src.exists():
|
|
118
|
+
return False, f"Local path not found: {source}"
|
|
119
|
+
shutil.copytree(str(local_src), str(plugin_dir))
|
|
120
|
+
|
|
121
|
+
# Load and validate manifest
|
|
122
|
+
manifest = PluginManifest.from_plugin_dir(plugin_dir)
|
|
123
|
+
if manifest is None:
|
|
124
|
+
# No plugin.json / PLUGIN.md — ask user before auto-adapting
|
|
125
|
+
print()
|
|
126
|
+
try:
|
|
127
|
+
answer = input(
|
|
128
|
+
"No plugin manifest found. "
|
|
129
|
+
"Would you like Dulus to auto-adapt this repository?\n"
|
|
130
|
+
"This uses AI to analyze the repo and generate a plugin manifest.\n"
|
|
131
|
+
"It may take a few minutes. [Y/n] "
|
|
132
|
+
).strip().lower()
|
|
133
|
+
except (EOFError, KeyboardInterrupt):
|
|
134
|
+
answer = "n"
|
|
135
|
+
|
|
136
|
+
if answer in ("", "y", "yes"):
|
|
137
|
+
from .autoadapter import autoadapt_if_needed
|
|
138
|
+
from config import load_config
|
|
139
|
+
adapted_ok = autoadapt_if_needed(plugin_dir, safe_name, load_config())
|
|
140
|
+
if not adapted_ok:
|
|
141
|
+
print()
|
|
142
|
+
try:
|
|
143
|
+
keep = input(f"Auto-adaptation for '{safe_name}' failed. Keep partially adapted files for manual fixing? [y/N] ").strip().lower()
|
|
144
|
+
except (EOFError, KeyboardInterrupt):
|
|
145
|
+
keep = "n"
|
|
146
|
+
|
|
147
|
+
if keep not in ("y", "yes"):
|
|
148
|
+
# Clean up the cloned repo
|
|
149
|
+
def _force_remove(func, path, _exc_info):
|
|
150
|
+
os.chmod(path, stat.S_IWRITE)
|
|
151
|
+
func(path)
|
|
152
|
+
try:
|
|
153
|
+
shutil.rmtree(plugin_dir, onexc=_force_remove)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
return False, f"Auto-adaptation failed for '{safe_name}'. Plugin directory removed."
|
|
157
|
+
else:
|
|
158
|
+
return False, f"Auto-adaptation failed for '{safe_name}'. Files kept in {plugin_dir}. Set enabled=true in plugin.json manually if you fix it."
|
|
159
|
+
manifest = PluginManifest.from_plugin_dir(plugin_dir)
|
|
160
|
+
else:
|
|
161
|
+
print("Skipping auto-adaptation.")
|
|
162
|
+
|
|
163
|
+
if manifest is None:
|
|
164
|
+
manifest = PluginManifest(name=safe_name, description="(no manifest)")
|
|
165
|
+
|
|
166
|
+
if manifest.dependencies:
|
|
167
|
+
deps_to_install.extend(manifest.dependencies)
|
|
168
|
+
|
|
169
|
+
if not deps_to_install:
|
|
170
|
+
# Fallback: Recursive requirements search
|
|
171
|
+
req_files = list(plugin_dir.rglob("*requirements*.txt"))
|
|
172
|
+
for rf in req_files:
|
|
173
|
+
# Skip if in ignored dir
|
|
174
|
+
if any(x in str(rf.parents) for x in [".git", "venv", "__pycache__"]):
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
for line in rf.read_text(encoding="utf-8").splitlines():
|
|
178
|
+
line = line.strip()
|
|
179
|
+
if line and not line.startswith("#") and not line.startswith("-r"):
|
|
180
|
+
deps_to_install.append(line)
|
|
181
|
+
except Exception:
|
|
182
|
+
continue
|
|
183
|
+
deps_to_install = list(dict.fromkeys(deps_to_install))
|
|
184
|
+
|
|
185
|
+
if deps_to_install:
|
|
186
|
+
print(f"Installing {len(deps_to_install)} dependencies for '{safe_name}'...")
|
|
187
|
+
dep_ok, dep_msg = _install_dependencies(deps_to_install, cwd=plugin_dir)
|
|
188
|
+
if dep_ok:
|
|
189
|
+
print(f"Dependencies installed for '{safe_name}'.")
|
|
190
|
+
else:
|
|
191
|
+
return False, dep_msg
|
|
192
|
+
|
|
193
|
+
# Persist to config
|
|
194
|
+
entry = PluginEntry(
|
|
195
|
+
name=safe_name,
|
|
196
|
+
scope=scope,
|
|
197
|
+
source=source,
|
|
198
|
+
install_dir=plugin_dir,
|
|
199
|
+
enabled=True,
|
|
200
|
+
manifest=manifest,
|
|
201
|
+
)
|
|
202
|
+
_save_entry(entry)
|
|
203
|
+
|
|
204
|
+
# Hot-reload tools into registry
|
|
205
|
+
try:
|
|
206
|
+
from .loader import register_plugin_tools
|
|
207
|
+
register_plugin_tools(scope)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
return True, f"Plugin '{safe_name}' installed successfully ({scope.value} scope)."
|
|
212
|
+
|
|
213
|
+
except Exception as e:
|
|
214
|
+
return False, f"Install failed: {e}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_git_url(source: str) -> bool:
|
|
218
|
+
return (
|
|
219
|
+
source.startswith("https://")
|
|
220
|
+
or source.startswith("git@")
|
|
221
|
+
or source.startswith("http://")
|
|
222
|
+
or source.endswith(".git")
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _clone_plugin(url: str, dest: Path) -> tuple[bool, str]:
|
|
227
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
cmd = ["git", "clone", "--depth", "1", url, str(dest)]
|
|
229
|
+
# Use a hidden config check or just check sys.argv if needed,
|
|
230
|
+
# but store.py doesn't have easy access to 'config' in this function.
|
|
231
|
+
# However, we can use the 'info' function if we import it.
|
|
232
|
+
from common import info
|
|
233
|
+
# We'll assume verbose intent if specifically triggered via /plugin
|
|
234
|
+
info(f" Running: {' '.join(cmd)}")
|
|
235
|
+
result = subprocess.run(
|
|
236
|
+
cmd,
|
|
237
|
+
capture_output=True, text=True,
|
|
238
|
+
)
|
|
239
|
+
if result.returncode != 0:
|
|
240
|
+
return False, f"git clone failed: {result.stderr.strip()}"
|
|
241
|
+
return True, "cloned"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _install_dependencies(deps: list[str], cwd: Path | None = None) -> tuple[bool, str]:
|
|
245
|
+
final_args = []
|
|
246
|
+
for d in deps:
|
|
247
|
+
d = d.strip()
|
|
248
|
+
if d.startswith("-r"):
|
|
249
|
+
# Aggressive split: remove -r, then strip the rest
|
|
250
|
+
path_part = d[2:].strip()
|
|
251
|
+
if path_part:
|
|
252
|
+
final_args.extend(["-r", path_part])
|
|
253
|
+
else:
|
|
254
|
+
final_args.append(d)
|
|
255
|
+
|
|
256
|
+
cmd = [sys.executable, "-m", "pip", "install", "--quiet", "--break-system-packages"] + final_args
|
|
257
|
+
from common import info
|
|
258
|
+
info(f" Running: {' '.join(cmd)}")
|
|
259
|
+
|
|
260
|
+
result = subprocess.run(
|
|
261
|
+
cmd,
|
|
262
|
+
capture_output=True, text=True,
|
|
263
|
+
cwd=str(cwd) if cwd else None
|
|
264
|
+
)
|
|
265
|
+
if result.returncode != 0:
|
|
266
|
+
return False, f"pip install failed: {result.stderr.strip()}"
|
|
267
|
+
return True, "deps installed"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _update_plugin_list_memory(scope: PluginScope) -> None:
|
|
271
|
+
try:
|
|
272
|
+
from datetime import datetime
|
|
273
|
+
from memory.store import MemoryEntry, save_memory
|
|
274
|
+
plugins = list_plugins(scope)
|
|
275
|
+
names = [f"- {p.name}{' (disabled)' if not p.enabled else ''}: {p.manifest.description}" for p in plugins if p.manifest]
|
|
276
|
+
content = "Currently installed plugins:\n" + "\n".join(names) if names else "No plugins currently installed."
|
|
277
|
+
mem_scope = "project" if scope == PluginScope.PROJECT else "user"
|
|
278
|
+
mem = MemoryEntry(
|
|
279
|
+
name="installed_plugins_list",
|
|
280
|
+
description="Dynamically updated list of all installed Dulus plugins and their status.",
|
|
281
|
+
type=mem_scope,
|
|
282
|
+
content=content,
|
|
283
|
+
hall="facts",
|
|
284
|
+
created=datetime.now().strftime("%Y-%m-%d"),
|
|
285
|
+
scope=mem_scope,
|
|
286
|
+
source="tool",
|
|
287
|
+
)
|
|
288
|
+
save_memory(mem, scope=mem_scope)
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _save_entry(entry: PluginEntry) -> None:
|
|
294
|
+
cfg_path = _plugin_cfg_for(entry.scope)
|
|
295
|
+
data = _read_cfg(cfg_path)
|
|
296
|
+
data.setdefault("plugins", {})[entry.name] = entry.to_dict()
|
|
297
|
+
_write_cfg(cfg_path, data)
|
|
298
|
+
_update_plugin_list_memory(entry.scope)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _remove_entry(name: str, scope: PluginScope) -> None:
|
|
302
|
+
cfg_path = _plugin_cfg_for(scope)
|
|
303
|
+
data = _read_cfg(cfg_path)
|
|
304
|
+
data.get("plugins", {}).pop(name, None)
|
|
305
|
+
_write_cfg(cfg_path, data)
|
|
306
|
+
_update_plugin_list_memory(scope)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ── Uninstall ─────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def uninstall_plugin(
|
|
312
|
+
name: str,
|
|
313
|
+
scope: PluginScope | None = None,
|
|
314
|
+
keep_data: bool = False,
|
|
315
|
+
) -> tuple[bool, str]:
|
|
316
|
+
entry = get_plugin(name, scope)
|
|
317
|
+
if entry is None:
|
|
318
|
+
return False, f"Plugin '{name}' not found."
|
|
319
|
+
if not keep_data and entry.install_dir.exists():
|
|
320
|
+
def _force_remove(func, path, _exc_info):
|
|
321
|
+
"""Handle read-only files (e.g. .git pack files on Windows)."""
|
|
322
|
+
os.chmod(path, stat.S_IWRITE)
|
|
323
|
+
func(path)
|
|
324
|
+
shutil.rmtree(entry.install_dir, onexc=_force_remove)
|
|
325
|
+
_remove_entry(entry.name, entry.scope)
|
|
326
|
+
return True, f"Plugin '{name}' uninstalled."
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ── Enable / Disable ──────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
def _set_enabled(name: str, scope: PluginScope | None, enabled: bool) -> tuple[bool, str]:
|
|
332
|
+
entry = get_plugin(name, scope)
|
|
333
|
+
if entry is None:
|
|
334
|
+
return False, f"Plugin '{name}' not found."
|
|
335
|
+
entry.enabled = enabled
|
|
336
|
+
_save_entry(entry)
|
|
337
|
+
state = "enabled" if enabled else "disabled"
|
|
338
|
+
return True, f"Plugin '{name}' {state}."
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def enable_plugin(name: str, scope: PluginScope | None = None) -> tuple[bool, str]:
|
|
342
|
+
return _set_enabled(name, scope, True)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def disable_plugin(name: str, scope: PluginScope | None = None) -> tuple[bool, str]:
|
|
346
|
+
return _set_enabled(name, scope, False)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def disable_all_plugins(scope: PluginScope | None = None) -> tuple[bool, str]:
|
|
350
|
+
entries = list_plugins(scope)
|
|
351
|
+
if not entries:
|
|
352
|
+
return True, "No plugins to disable."
|
|
353
|
+
for entry in entries:
|
|
354
|
+
entry.enabled = False
|
|
355
|
+
_save_entry(entry)
|
|
356
|
+
return True, f"Disabled {len(entries)} plugin(s)."
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ── Update ────────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
def update_plugin(name: str, scope: PluginScope | None = None) -> tuple[bool, str]:
|
|
362
|
+
entry = get_plugin(name, scope)
|
|
363
|
+
if entry is None:
|
|
364
|
+
return False, f"Plugin '{name}' not found."
|
|
365
|
+
if not _is_git_url(entry.source):
|
|
366
|
+
return False, f"Plugin '{name}' was installed from a local path; cannot auto-update."
|
|
367
|
+
if not entry.install_dir.exists():
|
|
368
|
+
return False, f"Install directory missing: {entry.install_dir}"
|
|
369
|
+
result = subprocess.run(
|
|
370
|
+
["git", "pull", "--ff-only"],
|
|
371
|
+
cwd=str(entry.install_dir),
|
|
372
|
+
capture_output=True, text=True,
|
|
373
|
+
)
|
|
374
|
+
if result.returncode != 0:
|
|
375
|
+
return False, f"git pull failed: {result.stderr.strip()}"
|
|
376
|
+
# Re-install dependencies if manifest changed
|
|
377
|
+
manifest = PluginManifest.from_plugin_dir(entry.install_dir)
|
|
378
|
+
if manifest and manifest.dependencies:
|
|
379
|
+
_install_dependencies(manifest.dependencies)
|
|
380
|
+
# Hot-reload tools
|
|
381
|
+
try:
|
|
382
|
+
from .loader import register_plugin_tools
|
|
383
|
+
register_plugin_tools(entry.scope)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
return True, f"Plugin '{name}' updated. {result.stdout.strip()}"
|
plugin/types.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Plugin system types: manifest, entry, scope."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PluginScope(str, Enum):
|
|
12
|
+
USER = "user" # ~/.dulus/plugins/
|
|
13
|
+
PROJECT = "project" # .dulus/plugins/ (cwd)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PluginManifest:
|
|
18
|
+
"""Parsed from PLUGIN.md YAML frontmatter or plugin.json."""
|
|
19
|
+
name: str
|
|
20
|
+
version: str = "0.1.0"
|
|
21
|
+
description: str = ""
|
|
22
|
+
author: str = ""
|
|
23
|
+
tags: list[str] = field(default_factory=list)
|
|
24
|
+
tools: list[str] = field(default_factory=list) # python modules exporting tools
|
|
25
|
+
skills: list[str] = field(default_factory=list) # skill .md files
|
|
26
|
+
mcp_servers: dict[str, Any] = field(default_factory=dict) # name → mcp server config
|
|
27
|
+
dependencies: list[str] = field(default_factory=list) # pip packages
|
|
28
|
+
homepage: str = ""
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, data: dict) -> "PluginManifest":
|
|
32
|
+
# Robust handling for dependencies
|
|
33
|
+
deps = data.get("dependencies", [])
|
|
34
|
+
if isinstance(deps, dict):
|
|
35
|
+
deps = deps.get("requirements") or deps.get("pip") or []
|
|
36
|
+
|
|
37
|
+
# Robust handling for tools, skills, and mcp_servers
|
|
38
|
+
tools = data.get("tools", [])
|
|
39
|
+
if not isinstance(tools, list): tools = []
|
|
40
|
+
|
|
41
|
+
skills = data.get("skills", [])
|
|
42
|
+
if not isinstance(skills, list): skills = []
|
|
43
|
+
|
|
44
|
+
mcp = data.get("mcp_servers", {})
|
|
45
|
+
if not isinstance(mcp, dict): mcp = {}
|
|
46
|
+
|
|
47
|
+
return cls(
|
|
48
|
+
name=data.get("name", "unknown"),
|
|
49
|
+
version=str(data.get("version", "0.1.0")),
|
|
50
|
+
description=data.get("description", ""),
|
|
51
|
+
author=data.get("author", ""),
|
|
52
|
+
tags=data.get("tags", []),
|
|
53
|
+
tools=tools,
|
|
54
|
+
skills=skills,
|
|
55
|
+
mcp_servers=mcp,
|
|
56
|
+
dependencies=list(deps) if isinstance(deps, list) else [],
|
|
57
|
+
homepage=data.get("homepage", ""),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_plugin_dir(cls, plugin_dir: Path) -> "PluginManifest | None":
|
|
62
|
+
"""Load manifest from a plugin directory (plugin.json or PLUGIN.md frontmatter)."""
|
|
63
|
+
# Try plugin.json first
|
|
64
|
+
json_file = plugin_dir / "plugin.json"
|
|
65
|
+
if json_file.exists():
|
|
66
|
+
import json
|
|
67
|
+
try:
|
|
68
|
+
return cls.from_dict(json.loads(json_file.read_text(encoding="utf-8")))
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# Try PLUGIN.md YAML frontmatter
|
|
73
|
+
md_file = plugin_dir / "PLUGIN.md"
|
|
74
|
+
if md_file.exists():
|
|
75
|
+
return cls._from_md(md_file)
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _from_md(cls, md_file: Path) -> "PluginManifest | None":
|
|
81
|
+
text = md_file.read_text(encoding="utf-8")
|
|
82
|
+
if not text.startswith("---"):
|
|
83
|
+
return None
|
|
84
|
+
end = text.find("---", 3)
|
|
85
|
+
if end == -1:
|
|
86
|
+
return None
|
|
87
|
+
frontmatter = text[3:end].strip()
|
|
88
|
+
try:
|
|
89
|
+
import yaml # type: ignore
|
|
90
|
+
data = yaml.safe_load(frontmatter)
|
|
91
|
+
except ImportError:
|
|
92
|
+
# Minimal YAML parser for simple key: value pairs
|
|
93
|
+
data = {}
|
|
94
|
+
for line in frontmatter.splitlines():
|
|
95
|
+
if ":" in line:
|
|
96
|
+
k, _, v = line.partition(":")
|
|
97
|
+
data[k.strip()] = v.strip()
|
|
98
|
+
if isinstance(data, dict):
|
|
99
|
+
return cls.from_dict(data)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class PluginEntry:
|
|
105
|
+
"""A plugin registered in the config store."""
|
|
106
|
+
name: str
|
|
107
|
+
scope: PluginScope
|
|
108
|
+
source: str # git URL, local path, or marketplace name@url
|
|
109
|
+
install_dir: Path
|
|
110
|
+
enabled: bool = True
|
|
111
|
+
manifest: PluginManifest | None = None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def qualified_name(self) -> str:
|
|
115
|
+
return f"{self.name}@{self.scope.value}"
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict:
|
|
118
|
+
return {
|
|
119
|
+
"name": self.name,
|
|
120
|
+
"scope": self.scope.value,
|
|
121
|
+
"source": self.source,
|
|
122
|
+
"install_dir": str(self.install_dir),
|
|
123
|
+
"enabled": self.enabled,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dict(cls, data: dict) -> "PluginEntry":
|
|
128
|
+
return cls(
|
|
129
|
+
name=data["name"],
|
|
130
|
+
scope=PluginScope(data.get("scope", "user")),
|
|
131
|
+
source=data.get("source", ""),
|
|
132
|
+
install_dir=Path(data["install_dir"]),
|
|
133
|
+
enabled=data.get("enabled", True),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_plugin_identifier(identifier: str) -> tuple[str, str | None]:
|
|
138
|
+
"""Parse 'name' or 'name@source'. Returns (name, source_or_None)."""
|
|
139
|
+
if "@" in identifier:
|
|
140
|
+
name, _, source = identifier.partition("@")
|
|
141
|
+
return name.strip(), source.strip()
|
|
142
|
+
return identifier.strip(), None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def sanitize_plugin_name(name: str) -> str:
|
|
146
|
+
"""Ensure plugin name is safe for use as directory name (alphanumeric + underscore)."""
|
|
147
|
+
return re.sub(r"[^\w]", "_", name)
|