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.
- abyss/__init__.py +3 -0
- abyss/ansi_menu.py +559 -0
- abyss/api_client.py +123 -0
- abyss/commands/__init__.py +12 -0
- abyss/commands/slash.py +72 -0
- abyss/config.py +121 -0
- abyss/custom_input.py +382 -0
- abyss/extensions/__init__.py +21 -0
- abyss/extensions/cli.py +160 -0
- abyss/extensions/installer.py +452 -0
- abyss/extensions/registry.py +119 -0
- abyss/extensions/url_parser.py +86 -0
- abyss/hooks/__init__.py +12 -0
- abyss/hooks/runner.py +144 -0
- abyss/logger.py +218 -0
- abyss/main.py +763 -0
- abyss/mcp/__init__.py +13 -0
- abyss/mcp/manager.py +189 -0
- abyss/prompts/__init__.py +26 -0
- abyss/session.py +79 -0
- abyss/skills/__init__.py +12 -0
- abyss/skills/loader.py +150 -0
- abyss/tools/__init__.py +20 -0
- abyss/tools/base.py +45 -0
- abyss/tools/file_edit.py +48 -0
- abyss/tools/file_read.py +54 -0
- abyss/tools/file_write.py +44 -0
- abyss/tools/registry.py +107 -0
- abyss/tools/shell_exec.py +181 -0
- abyss/tools/web_search.py +63 -0
- abyss_cli-0.1.0.dist-info/METADATA +11 -0
- abyss_cli-0.1.0.dist-info/RECORD +35 -0
- abyss_cli-0.1.0.dist-info/WHEEL +5 -0
- abyss_cli-0.1.0.dist-info/entry_points.txt +2 -0
- abyss_cli-0.1.0.dist-info/top_level.txt +1 -0
abyss/extensions/cli.py
ADDED
|
@@ -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
|
+
)
|