abyss-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 扩展管理 CLI
4
+ 提供 abyss extension install/update/remove/list/show 子命令。
5
+
6
+ 使用 ABYSS_HOME 环境变量可覆盖默认的 ~/.abyss 根目录(便于测试)。
7
+ """
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import click
14
+
15
+ from .installer import PackageInstaller
16
+ from .registry import (
17
+ ExtensionRegistry, ExtensionType, InstalledExtension, create_default_registry,
18
+ )
19
+
20
+
21
+ def _abyss_root() -> Path:
22
+ """获取 abyss 根目录,优先用 ABYSS_HOME 环境变量"""
23
+ home = os.environ.get("ABYSS_HOME")
24
+ if home:
25
+ return Path(home)
26
+ return Path.home() / ".abyss"
27
+
28
+
29
+ def _make_installer(abyss_root: Path) -> PackageInstaller:
30
+ """构造 PackageInstaller(带 cache + registry)"""
31
+ cache_dir = abyss_root / ".cache" / "git"
32
+ registry = ExtensionRegistry(registry_path=abyss_root / "registry.json")
33
+ return PackageInstaller(
34
+ abyss_root=abyss_root,
35
+ cache_dir=cache_dir,
36
+ registry=registry,
37
+ )
38
+
39
+
40
+ def _type_from_str(s: str) -> ExtensionType:
41
+ """字符串转 ExtensionType"""
42
+ return ExtensionType(s.lower())
43
+
44
+
45
+ @click.group(name="extension")
46
+ def extension_cli():
47
+ """管理 Skill/Command/Hook/MCP 扩展包。"""
48
+
49
+
50
+ @extension_cli.command(name="install")
51
+ @click.argument("url")
52
+ @click.option("--id", "ext_id", required=True, help="扩展唯一标识(用于管理)")
53
+ @click.option("--type", "ext_type_str", type=click.Choice(["skill", "command", "hook", "mcp"]),
54
+ default=None, help="限定安装类型,默认自动检测")
55
+ @click.option("--subpath", default=None, help="仓库内子目录路径")
56
+ def install_cmd(url, ext_id, ext_type_str, subpath):
57
+ """从 Git 仓库或本地路径安装扩展。"""
58
+ abyss_root = _abyss_root()
59
+ installer = _make_installer(abyss_root)
60
+ registry = installer._registry
61
+
62
+ if registry.get(ext_id) is not None:
63
+ raise click.ClickException(f"扩展 ID 已存在: {ext_id}(请用 update 或先 remove)")
64
+
65
+ ext_type = _type_from_str(ext_type_str) if ext_type_str else None
66
+
67
+ full_url = url
68
+ if subpath:
69
+ sep = "&" if "?" in full_url else "?"
70
+ full_url = f"{full_url}{sep}subdir={subpath}"
71
+
72
+ click.echo(f"正在安装 {ext_id} from {url} ...")
73
+ ext = installer.install(url=full_url, ext_id=ext_id, ext_type=ext_type)
74
+ if ext is None:
75
+ raise click.ClickException(
76
+ f"安装失败:URL 无效或仓库内未发现已知扩展类型(skills/commands/hooks/mcp.json)"
77
+ )
78
+
79
+ click.echo(f"[OK] 已安装 {ext.id} (类型: {ext.type.value}, commit: {ext.commit[:8]})")
80
+
81
+
82
+ @extension_cli.command(name="update")
83
+ @click.argument("ext_id")
84
+ def update_cmd(ext_id):
85
+ """更新已安装的扩展。"""
86
+ abyss_root = _abyss_root()
87
+ installer = _make_installer(abyss_root)
88
+ click.echo(f"正在更新 {ext_id} ...")
89
+ ext = installer.update(ext_id)
90
+ if ext is None:
91
+ raise click.ClickException(f"扩展不存在: {ext_id}")
92
+ click.echo(f"[OK] 已更新 {ext.id} (commit: {ext.commit[:8]})")
93
+
94
+
95
+ @extension_cli.command(name="remove")
96
+ @click.argument("ext_id")
97
+ @click.option("-y", "--yes", is_flag=True, help="跳过确认")
98
+ def remove_cmd(ext_id, yes):
99
+ """卸载已安装的扩展。"""
100
+ abyss_root = _abyss_root()
101
+ installer = _make_installer(abyss_root)
102
+ ext = installer._registry.get(ext_id)
103
+ if ext is None:
104
+ raise click.ClickException(f"扩展不存在: {ext_id}")
105
+
106
+ if not yes:
107
+ click.confirm(f"确认卸载 {ext_id} ({ext.type.value})?", abort=True)
108
+
109
+ if installer.remove(ext_id):
110
+ click.echo(f"[OK] 已卸载 {ext_id}")
111
+ else:
112
+ raise click.ClickException(f"卸载失败: {ext_id}")
113
+
114
+
115
+ @extension_cli.command(name="list")
116
+ @click.option("--type", "type_filter", type=click.Choice(["skill", "command", "hook", "mcp"]),
117
+ default=None, help="按类型过滤")
118
+ def list_cmd(type_filter):
119
+ """列出已安装的扩展。"""
120
+ abyss_root = _abyss_root()
121
+ registry = ExtensionRegistry(registry_path=abyss_root / "registry.json")
122
+
123
+ if type_filter:
124
+ exts = registry.list_by_type(_type_from_str(type_filter))
125
+ else:
126
+ exts = registry.list_all()
127
+
128
+ if not exts:
129
+ click.echo("暂无已安装的扩展。使用 `abyss extension install <url>` 安装。")
130
+ return
131
+
132
+ # 表格输出
133
+ click.echo(f"{'ID':<20} {'类型':<10} {'来源':<50} {'Commit':<10}")
134
+ click.echo("-" * 92)
135
+ for ext in exts:
136
+ source = ext.source_url
137
+ if len(source) > 48:
138
+ source = source[:45] + "..."
139
+ click.echo(
140
+ f"{ext.id:<20} {ext.type.value:<10} {source:<50} {ext.commit[:8]:<10}"
141
+ )
142
+
143
+
144
+ @extension_cli.command(name="show")
145
+ @click.argument("ext_id")
146
+ def show_cmd(ext_id):
147
+ """显示扩展详细信息。"""
148
+ abyss_root = _abyss_root()
149
+ registry = ExtensionRegistry(registry_path=abyss_root / "registry.json")
150
+ ext = registry.get(ext_id)
151
+ if ext is None:
152
+ raise click.ClickException(f"扩展不存在: {ext_id}")
153
+
154
+ click.echo(f"ID: {ext.id}")
155
+ click.echo(f"类型: {ext.type.value}")
156
+ click.echo(f"来源: {ext.source_url}")
157
+ click.echo(f"子目录: {ext.subpath or '(根)'}")
158
+ click.echo(f"Ref: {ext.ref}")
159
+ click.echo(f"Commit: {ext.commit}")
160
+ click.echo(f"安装时间: {ext.installed_at}")
@@ -0,0 +1,452 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 扩展包安装器
4
+ 从 Git 仓库或本地路径安装 Skill/Command/Hook/MCP,并自动分发到 ~/.abyss/ 对应目录。
5
+
6
+ 仓库约定目录结构:
7
+ skills/<name>/SKILL.md → ~/.abyss/skills/<name>/SKILL.md
8
+ commands/*.md → ~/.abyss/commands/*.md
9
+ hooks/<event>/*.{bat,sh} → ~/.abyss/hooks/<event>/*
10
+ mcp.json → ~/.abyss/mcp.json (合并)
11
+
12
+ 文件分发时按 ext_id 隔离到 ~/.abyss/.installed/<ext_id>/ 目录,
13
+ remove 时按隔离目录精确删除,不会误删其他扩展。
14
+ """
15
+ import shutil
16
+ import subprocess
17
+ import hashlib
18
+ import json
19
+ import sys
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Callable, List, Optional, Set
23
+ from urllib.parse import parse_qs
24
+
25
+ from .url_parser import parse_source_url, PackageSource
26
+ from .registry import (
27
+ ExtensionRegistry, ExtensionType, InstalledExtension,
28
+ )
29
+
30
+
31
+ # 已知的分发目录
32
+ _KNOWN_DIRS = {"skills", "commands", "hooks"}
33
+ _MCP_FILE = "mcp.json"
34
+
35
+
36
+ def _default_fetcher(src: PackageSource, target: Path) -> str:
37
+ """默认 fetcher:调 git clone 浅克隆到 target。
38
+
39
+ 返回 commit hash。
40
+ """
41
+ target.parent.mkdir(parents=True, exist_ok=True)
42
+ if target.exists():
43
+ shutil.rmtree(target)
44
+
45
+ cmd = ["git", "clone", "--depth=1"]
46
+ if src.ref and src.ref != "HEAD":
47
+ cmd.extend(["--branch", src.ref])
48
+ cmd.extend([src.clone_url, str(target)])
49
+ subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8")
50
+
51
+ # 获取 commit hash
52
+ out = subprocess.run(
53
+ ["git", "-C", str(target), "rev-parse", "HEAD"],
54
+ capture_output=True, text=True, check=True, encoding="utf-8"
55
+ )
56
+ return out.stdout.strip()
57
+
58
+
59
+ def _commit_from_fetcher(src: PackageSource, cache_target: Path) -> str:
60
+ """从缓存目录读取 commit hash"""
61
+ head_file = cache_target / ".abyss_commit"
62
+ if head_file.exists():
63
+ return head_file.read_text(encoding="utf-8").strip()
64
+ try:
65
+ out = subprocess.run(
66
+ ["git", "-C", str(cache_target), "rev-parse", "HEAD"],
67
+ capture_output=True, text=True, encoding="utf-8"
68
+ )
69
+ if out.returncode == 0:
70
+ commit = out.stdout.strip()
71
+ cache_target.mkdir(parents=True, exist_ok=True)
72
+ head_file.write_text(commit, encoding="utf-8")
73
+ return commit
74
+ except Exception:
75
+ pass
76
+ return ""
77
+
78
+
79
+ class PackageInstaller:
80
+ """扩展包安装器。"""
81
+
82
+ def __init__(
83
+ self,
84
+ abyss_root: Path,
85
+ cache_dir: Path,
86
+ registry: ExtensionRegistry,
87
+ fetcher: Optional[Callable[[PackageSource, Path], str]] = None,
88
+ ):
89
+ self._abyss_root = Path(abyss_root)
90
+ self._cache_dir = Path(cache_dir)
91
+ self._registry = registry
92
+ self._fetcher = fetcher or _default_fetcher
93
+ # 每个 ext_id 的隔离安装目录
94
+ self._installed_root = self._abyss_root / ".installed"
95
+
96
+ def _cache_path(self, src: PackageSource) -> Path:
97
+ """根据 clone_url 派生缓存目录名(用 hash 避免路径过长)"""
98
+ url_hash = hashlib.md5(src.clone_url.encode("utf-8")).hexdigest()[:12]
99
+ return self._cache_dir / url_hash
100
+
101
+ def _isolation_dir(self, ext_id: str) -> Path:
102
+ """每个 ext_id 独立的安装目录"""
103
+ return self._installed_root / ext_id
104
+
105
+ def _dispatch_files(
106
+ self,
107
+ source_dir: Path,
108
+ target_root: Path,
109
+ ext_id: str,
110
+ ext_type: Optional[ExtensionType] = None,
111
+ ) -> Set[str]:
112
+ """把 source_dir 中的 skills/commands/hooks 分发到 target_root。
113
+
114
+ 实际写入位置:target_root/.installed/<ext_id>/<subdir>
115
+ 这样 remove 时可按 ext_id 精确清理。
116
+
117
+ 返回分发的扩展类型集合。
118
+ """
119
+ if not source_dir.exists() or not source_dir.is_dir():
120
+ return set()
121
+
122
+ isolation = target_root / ".installed" / ext_id
123
+ isolation.mkdir(parents=True, exist_ok=True)
124
+ dispatched = set()
125
+
126
+ # 子目录分发:skills / commands / hooks
127
+ if ext_type is None or ext_type == ExtensionType.SKILL:
128
+ src_skills = source_dir / "skills"
129
+ if src_skills.exists():
130
+ self._copy_tree(src_skills, isolation / "skills")
131
+ dispatched.add("skills")
132
+ if ext_type is None or ext_type == ExtensionType.COMMAND:
133
+ src_cmds = source_dir / "commands"
134
+ if src_cmds.exists():
135
+ self._copy_tree(src_cmds, isolation / "commands")
136
+ dispatched.add("commands")
137
+ if ext_type is None or ext_type == ExtensionType.HOOK:
138
+ src_hooks = source_dir / "hooks"
139
+ if src_hooks.exists():
140
+ self._copy_tree(src_hooks, isolation / "hooks")
141
+ dispatched.add("hooks")
142
+
143
+ # MCP 配置:合并到 ~/.abyss/mcp.json
144
+ if ext_type is None or ext_type == ExtensionType.MCP:
145
+ src_mcp = source_dir / _MCP_FILE
146
+ if src_mcp.exists():
147
+ isolation_mcp = isolation / _MCP_FILE
148
+ shutil.copy2(src_mcp, isolation_mcp)
149
+ self._merge_mcp_config(isolation_mcp, target_root / _MCP_FILE)
150
+ dispatched.add("mcp")
151
+
152
+ # 创建符号链接或创建聚合目录:
153
+ # 实际上技能加载器从 ~/.abyss/skills/ 扫描
154
+ # 所以要让 abyss 能找到这些文件
155
+ self._link_to_aggregate(target_root, ext_id, dispatched)
156
+
157
+ return dispatched
158
+
159
+ def _copy_tree(self, src: Path, dst: Path) -> None:
160
+ """复制整个目录树"""
161
+ if dst.exists():
162
+ shutil.rmtree(dst)
163
+ shutil.copytree(src, dst)
164
+
165
+ def _link_to_aggregate(
166
+ self,
167
+ target_root: Path,
168
+ ext_id: str,
169
+ dispatched: Set[str],
170
+ ) -> None:
171
+ """把隔离目录中分发的内容用符号链接暴露到 aggregate 目录。
172
+
173
+ aggregate 目录布局(与原约定一致):
174
+ ~/.abyss/skills/<name>/SKILL.md
175
+ ~/.abyss/commands/<name>.md
176
+ ~/.abyss/hooks/<event>/<script>
177
+ """
178
+ isolation = target_root / ".installed" / ext_id
179
+
180
+ for kind in ("skills", "commands", "hooks"):
181
+ if kind not in dispatched:
182
+ continue
183
+ src = isolation / kind
184
+ if not src.exists():
185
+ continue
186
+ agg_root = target_root / kind
187
+ agg_root.mkdir(parents=True, exist_ok=True)
188
+ for child in src.iterdir():
189
+ dst = agg_root / child.name
190
+ if dst.exists() or dst.is_symlink():
191
+ if dst.is_symlink() or dst.is_file():
192
+ dst.unlink()
193
+ elif dst.is_dir():
194
+ shutil.rmtree(dst)
195
+ try:
196
+ # Windows 上 symlink 可能需要开发者权限;fallback 到复制
197
+ dst.symlink_to(child.resolve(), target_is_directory=child.is_dir())
198
+ except (OSError, NotImplementedError):
199
+ if child.is_dir():
200
+ if dst.exists():
201
+ shutil.rmtree(dst)
202
+ shutil.copytree(child, dst)
203
+ else:
204
+ shutil.copy2(child, dst)
205
+
206
+ # mcp.json 是合并而非链接,单独处理
207
+ if "mcp" in dispatched:
208
+ mcp_dst = target_root / _MCP_FILE
209
+ # 已经在 _merge_mcp_config 中合并,隔离目录里保留完整副本
210
+ pass
211
+
212
+ def _merge_mcp_config(self, new_mcp: Path, dest_mcp: Path) -> None:
213
+ """合并 mcp.json 中的 mcp_servers 列表到目标文件"""
214
+ try:
215
+ new_data = json.loads(new_mcp.read_text(encoding="utf-8"))
216
+ except Exception:
217
+ return
218
+ new_servers = new_data.get("mcp_servers", []) or []
219
+
220
+ existing = []
221
+ if dest_mcp.exists():
222
+ try:
223
+ existing_data = json.loads(dest_mcp.read_text(encoding="utf-8"))
224
+ existing = existing_data.get("mcp_servers", []) or []
225
+ except Exception:
226
+ existing = []
227
+
228
+ # 按 name 去重
229
+ seen = {s.get("name") for s in existing if s.get("name")}
230
+ merged = list(existing)
231
+ for s in new_servers:
232
+ if s.get("name") and s["name"] not in seen:
233
+ merged.append(s)
234
+ seen.add(s["name"])
235
+
236
+ dest_mcp.parent.mkdir(parents=True, exist_ok=True)
237
+ dest_mcp.write_text(
238
+ json.dumps({"mcp_servers": merged}, indent=2, ensure_ascii=False),
239
+ encoding="utf-8"
240
+ )
241
+
242
+ def _remove_dispatched(self, ext_id: str) -> None:
243
+ """删除指定 ext_id 分发的所有文件"""
244
+ isolation = self._installed_root / ext_id
245
+ if not isolation.exists():
246
+ return
247
+
248
+ # 清理 aggregate 中的符号链接
249
+ for kind in ("skills", "commands", "hooks"):
250
+ agg_root = self._abyss_root / kind
251
+ if not agg_root.exists():
252
+ continue
253
+ isolation_kind = isolation / kind
254
+ if not isolation_kind.exists():
255
+ continue
256
+ for child in isolation_kind.iterdir():
257
+ dst = agg_root / child.name
258
+ if dst.is_symlink():
259
+ dst.unlink()
260
+ elif dst.exists():
261
+ if dst.is_dir():
262
+ shutil.rmtree(dst)
263
+ else:
264
+ dst.unlink()
265
+
266
+ # 清理 mcp.json 中属于此扩展的 server
267
+ mcp_isolation = isolation / _MCP_FILE
268
+ if mcp_isolation.exists():
269
+ try:
270
+ iso_data = json.loads(mcp_isolation.read_text(encoding="utf-8"))
271
+ iso_servers = {s.get("name") for s in iso_data.get("mcp_servers", []) or []}
272
+ dest_mcp = self._abyss_root / _MCP_FILE
273
+ if dest_mcp.exists() and iso_servers:
274
+ dest_data = json.loads(dest_mcp.read_text(encoding="utf-8"))
275
+ dest_data["mcp_servers"] = [
276
+ s for s in dest_data.get("mcp_servers", []) or []
277
+ if s.get("name") not in iso_servers
278
+ ]
279
+ dest_mcp.write_text(
280
+ json.dumps(dest_data, indent=2, ensure_ascii=False),
281
+ encoding="utf-8"
282
+ )
283
+ except Exception:
284
+ pass
285
+
286
+ # 删除整个隔离目录
287
+ shutil.rmtree(isolation)
288
+
289
+ def install(
290
+ self,
291
+ url: str,
292
+ ext_id: str,
293
+ ext_type: Optional[ExtensionType] = None,
294
+ ) -> Optional[InstalledExtension]:
295
+ """安装扩展。返回安装记录,失败返回 None。
296
+
297
+ 参数:
298
+ url: Git 仓库 URL 或本地路径
299
+ ext_id: 扩展唯一标识
300
+ ext_type: 限定只安装某种类型,None 表示自动检测所有
301
+ """
302
+ # 支持本地路径
303
+ if url.startswith("file://"):
304
+ from urllib.parse import urlparse
305
+ import re
306
+ parsed = urlparse(url)
307
+ # file:///d:/xxx 标准格式:scheme:// + 空 host + 绝对 path
308
+ if parsed.path:
309
+ # 去掉前导 /(Windows 上 Path('/d:/xxx') 不可定位)
310
+ local_part = parsed.path.lstrip("/")
311
+ else:
312
+ # file://xxx 非标准格式(user 输入省略了第三个 /):
313
+ # 从 netloc + 原始 URL 推回
314
+ # 例如 file://d:/xxx 实际 = file:///d:/xxx
315
+ tail = re.sub(r"^file:///?", "", url)
316
+ if "?" in tail:
317
+ tail = tail.split("?", 1)[0]
318
+ local_part = tail
319
+ local_path = Path(local_part)
320
+ subpath = ""
321
+ if parsed.query:
322
+ subpath = parse_qs(parsed.query).get("subdir", [""])[0]
323
+ if not local_path.exists():
324
+ return None
325
+ return self._install_from_local(local_path, ext_id, ext_type, subpath)
326
+
327
+ try:
328
+ src = parse_source_url(url)
329
+ except ValueError:
330
+ return None
331
+
332
+ cache_target = self._cache_path(src)
333
+ try:
334
+ commit = self._fetcher(src, cache_target)
335
+ except Exception:
336
+ return None
337
+
338
+ if not commit:
339
+ commit = _commit_from_fetcher(src, cache_target)
340
+
341
+ # 提取 subpath
342
+ if src.subpath:
343
+ source_dir = cache_target / src.subpath
344
+ else:
345
+ source_dir = cache_target
346
+
347
+ if not source_dir.exists():
348
+ return None
349
+
350
+ dispatched = self._dispatch_files(
351
+ source_dir, self._abyss_root, ext_id, ext_type
352
+ )
353
+ if not dispatched:
354
+ return None
355
+
356
+ ext = InstalledExtension(
357
+ id=ext_id,
358
+ type=ext_type or ExtensionType.SKILL,
359
+ source_url=url,
360
+ subpath=src.subpath,
361
+ ref=src.ref,
362
+ commit=commit,
363
+ installed_at=datetime.now().isoformat(timespec="seconds"),
364
+ )
365
+ self._registry.add(ext)
366
+ return ext
367
+
368
+ def _install_from_local(
369
+ self,
370
+ local_path: Path,
371
+ ext_id: str,
372
+ ext_type: Optional[ExtensionType],
373
+ subpath: str = "",
374
+ ) -> Optional[InstalledExtension]:
375
+ """从本地目录安装(开发/测试用)
376
+
377
+ 参数:
378
+ local_path: 仓库根目录
379
+ subpath: 子路径,相对于 local_path。
380
+ 当指定 subpath 时,按以下规则解析:
381
+ - subpath 含 "skills" 段:当 ext_type=SKILL 时视为单个 skill 目录
382
+ - 其他情况:subpath 作为分发根,仍按 skills/commands/hooks 扫描
383
+ """
384
+ source_dir = local_path / subpath if subpath else local_path
385
+ if not source_dir.is_dir():
386
+ return None
387
+
388
+ # 如果 subpath 明确指向 skills/<name> 且指定了 SKILL 类型,
389
+ # 直接把 source_dir 当作单个 skill 目录复制到 ~/.abyss/skills/<name>
390
+ if (ext_type == ExtensionType.SKILL and subpath
391
+ and ("skills" in subpath.split("/") or source_dir.joinpath("SKILL.md").exists())):
392
+ target_skill_dir = self._abyss_root / "skills" / source_dir.name
393
+ target_skill_dir.parent.mkdir(parents=True, exist_ok=True)
394
+ self._copy_tree(source_dir, target_skill_dir)
395
+ isolation = self._abyss_root / ".installed" / ext_id
396
+ isolation.mkdir(parents=True, exist_ok=True)
397
+ self._copy_tree(source_dir, isolation / "skills" / source_dir.name)
398
+ ext = InstalledExtension(
399
+ id=ext_id,
400
+ type=ExtensionType.SKILL,
401
+ source_url=f"file://{local_path}{'?' + 'subdir=' + subpath if subpath else ''}",
402
+ subpath=subpath,
403
+ ref="local",
404
+ commit=hashlib.md5(
405
+ str(source_dir.stat().st_mtime).encode("utf-8")
406
+ ).hexdigest()[:12],
407
+ installed_at=datetime.now().isoformat(timespec="seconds"),
408
+ )
409
+ self._registry.add(ext)
410
+ return ext
411
+
412
+ dispatched = self._dispatch_files(
413
+ source_dir, self._abyss_root, ext_id, ext_type
414
+ )
415
+ if not dispatched:
416
+ return None
417
+
418
+ commit = hashlib.md5(
419
+ str(source_dir.stat().st_mtime).encode("utf-8")
420
+ ).hexdigest()[:12]
421
+
422
+ ext = InstalledExtension(
423
+ id=ext_id,
424
+ type=ext_type or ExtensionType.SKILL,
425
+ source_url=f"file://{local_path}{'?' + 'subdir=' + subpath if subpath else ''}",
426
+ subpath=subpath,
427
+ ref="local",
428
+ commit=commit,
429
+ installed_at=datetime.now().isoformat(timespec="seconds"),
430
+ )
431
+ self._registry.add(ext)
432
+ return ext
433
+
434
+ def remove(self, ext_id: str) -> bool:
435
+ """卸载扩展:删除分发的文件并从注册表移除。"""
436
+ ext = self._registry.get(ext_id)
437
+ if ext is None:
438
+ return False
439
+ self._remove_dispatched(ext_id)
440
+ self._registry.remove(ext_id)
441
+ return True
442
+
443
+ def update(self, ext_id: str) -> Optional[InstalledExtension]:
444
+ """更新扩展:重新拉取并分发。"""
445
+ ext = self._registry.get(ext_id)
446
+ if ext is None:
447
+ return None
448
+ return self.install(
449
+ url=ext.source_url,
450
+ ext_id=ext_id,
451
+ ext_type=ext.type,
452
+ )