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.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. 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)