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/commands/slash.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Slash Commands 模块
|
|
4
|
+
支持从 .md 文件加载用户自定义斜杠命令,文件名即命令名,内容为 Prompt 模板。
|
|
5
|
+
模板支持 {{var}} 变量替换和 $USER_INPUT 占位符(用户在命令后追加的输入)。
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SlashCommand:
|
|
13
|
+
"""单个 slash 命令:名称 + Prompt 模板,支持变量展开。"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, name: str, template: str):
|
|
16
|
+
self.name = name
|
|
17
|
+
self.template = template
|
|
18
|
+
|
|
19
|
+
def expand(self, variables: Dict[str, str]) -> str:
|
|
20
|
+
"""展开模板变量。
|
|
21
|
+
- {{var}} 替换为 variables[var],缺失保留原占位符
|
|
22
|
+
- $VAR 替换为 variables[VAR],缺失保留原占位符
|
|
23
|
+
"""
|
|
24
|
+
result = self.template
|
|
25
|
+
for key, value in variables.items():
|
|
26
|
+
result = result.replace("{{" + key + "}}", value)
|
|
27
|
+
result = result.replace("$" + key, value)
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SlashCommandRegistry:
|
|
32
|
+
"""从指定目录加载 .md 文件作为 slash 命令的注册表。"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, commands_dir: Optional[Path] = None):
|
|
35
|
+
self._commands: Dict[str, SlashCommand] = {}
|
|
36
|
+
if commands_dir is not None:
|
|
37
|
+
self._load_from_dir(commands_dir)
|
|
38
|
+
|
|
39
|
+
def _load_from_dir(self, commands_dir: Path) -> None:
|
|
40
|
+
"""从目录加载所有顶层 .md 文件作为命令"""
|
|
41
|
+
if not commands_dir.exists() or not commands_dir.is_dir():
|
|
42
|
+
return
|
|
43
|
+
for md_file in commands_dir.iterdir():
|
|
44
|
+
if not md_file.is_file() or md_file.suffix != ".md":
|
|
45
|
+
continue
|
|
46
|
+
name = md_file.stem
|
|
47
|
+
try:
|
|
48
|
+
template = md_file.read_text(encoding="utf-8")
|
|
49
|
+
except Exception:
|
|
50
|
+
continue
|
|
51
|
+
self._commands[name] = SlashCommand(name, template)
|
|
52
|
+
|
|
53
|
+
def get(self, name: str) -> Optional[SlashCommand]:
|
|
54
|
+
"""按名获取命令,未定义返回 None"""
|
|
55
|
+
return self._commands.get(name)
|
|
56
|
+
|
|
57
|
+
def list_names(self) -> List[str]:
|
|
58
|
+
"""返回所有已加载命令名"""
|
|
59
|
+
return list(self._commands.keys())
|
|
60
|
+
|
|
61
|
+
def expand_command(self, name: str, variables: Dict[str, str]) -> Optional[str]:
|
|
62
|
+
"""获取并展开指定命令,未定义返回 None"""
|
|
63
|
+
cmd = self.get(name)
|
|
64
|
+
if cmd is None:
|
|
65
|
+
return None
|
|
66
|
+
return cmd.expand(variables)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def create_default_registry() -> SlashCommandRegistry:
|
|
70
|
+
"""创建指向 ~/.abyss/commands/ 的默认注册表"""
|
|
71
|
+
commands_dir = Path.home() / ".abyss" / "commands"
|
|
72
|
+
return SlashCommandRegistry(commands_dir=commands_dir)
|
abyss/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
配置管理
|
|
4
|
+
管理用户配置,存储在 ~/.abyss/config.json
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".abyss"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Config:
|
|
15
|
+
"""管理用户配置,存储在 ~/.abyss/config.json。
|
|
16
|
+
|
|
17
|
+
使用原子写入防止配置文件损坏导致数据丢失:
|
|
18
|
+
- save() 先写临时文件,成功后再替换正式文件
|
|
19
|
+
- _load() 遇到损坏文件时,自动创建 .bak 备份后回退默认值
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
DEFAULTS = {
|
|
23
|
+
"api_key": "",
|
|
24
|
+
"base_url": "https://api.deepseek.com",
|
|
25
|
+
"model": "deepseek-v4-pro",
|
|
26
|
+
"thinking": True,
|
|
27
|
+
"reasoning_effort": "high",
|
|
28
|
+
"auto_confirm": False,
|
|
29
|
+
"max_tokens": 65536, # 默认 64K;deepseek-v4 输出上限 384K,可按需调大
|
|
30
|
+
"show_reasoning": False,
|
|
31
|
+
"user_id": "default",
|
|
32
|
+
"max_retries": 3,
|
|
33
|
+
"retry_delay": 1.0,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
MODELS = {
|
|
37
|
+
"deepseek-v4-pro": {"context": "1M", "output": "384K", "thinking": True},
|
|
38
|
+
"deepseek-v4-flash": {"context": "1M", "output": "384K", "thinking": True},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self._config = {}
|
|
43
|
+
self._load()
|
|
44
|
+
|
|
45
|
+
def _load(self):
|
|
46
|
+
"""从配置文件加载配置。文件损坏时备份后回退默认值。"""
|
|
47
|
+
if not CONFIG_FILE.exists():
|
|
48
|
+
self._config = dict(self.DEFAULTS)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
53
|
+
self._config = json.load(f)
|
|
54
|
+
# 确保 _config 至少是个 dict
|
|
55
|
+
if not isinstance(self._config, dict):
|
|
56
|
+
self._backup_broken_file("非 dict 类型")
|
|
57
|
+
self._config = dict(self.DEFAULTS)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
self._backup_broken_file("JSON 解析失败")
|
|
60
|
+
self._config = dict(self.DEFAULTS)
|
|
61
|
+
except (IOError, OSError):
|
|
62
|
+
self._backup_broken_file("文件不可读")
|
|
63
|
+
self._config = dict(self.DEFAULTS)
|
|
64
|
+
|
|
65
|
+
def _backup_broken_file(self, reason: str):
|
|
66
|
+
"""将损坏的配置文件重命名为 .bak,避免被默认配置覆盖丢失数据。"""
|
|
67
|
+
try:
|
|
68
|
+
bak = CONFIG_FILE.with_suffix(".json.bak")
|
|
69
|
+
CONFIG_FILE.replace(bak)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def save(self):
|
|
74
|
+
"""原子保存配置:写入临时文件后替换正式文件,防止写入中断导致文件损坏。"""
|
|
75
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
tmp_path = CONFIG_FILE.with_suffix(".tmp")
|
|
77
|
+
try:
|
|
78
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
79
|
+
json.dump(self._config, f, indent=2, ensure_ascii=False)
|
|
80
|
+
tmp_path.replace(CONFIG_FILE)
|
|
81
|
+
except Exception:
|
|
82
|
+
# 清理临时文件
|
|
83
|
+
try:
|
|
84
|
+
tmp_path.unlink(missing_ok=True)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
def get(self, key: str):
|
|
90
|
+
"""获取配置项"""
|
|
91
|
+
return self._config.get(key, self.DEFAULTS.get(key))
|
|
92
|
+
|
|
93
|
+
def set(self, key: str, value):
|
|
94
|
+
"""设置配置项"""
|
|
95
|
+
self._config[key] = value
|
|
96
|
+
self.save()
|
|
97
|
+
|
|
98
|
+
def delete(self, key: str) -> bool:
|
|
99
|
+
"""删除配置项(用于 /config unset 清理垃圾数据)。
|
|
100
|
+
|
|
101
|
+
幂等:key 不存在时不抛异常。
|
|
102
|
+
返回 True 表示删除了 key,False 表示 key 本来就不在。
|
|
103
|
+
"""
|
|
104
|
+
if key in self._config:
|
|
105
|
+
del self._config[key]
|
|
106
|
+
self.save()
|
|
107
|
+
return True
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def is_ready(self) -> bool:
|
|
111
|
+
"""检查是否已配置 API Key"""
|
|
112
|
+
return bool(self.get("api_key"))
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict:
|
|
115
|
+
"""返回配置字典"""
|
|
116
|
+
return dict(self._config)
|
|
117
|
+
|
|
118
|
+
def get_model_info(self) -> dict:
|
|
119
|
+
"""获取当前模型信息"""
|
|
120
|
+
model = self.get("model")
|
|
121
|
+
return self.MODELS.get(model, {})
|
abyss/custom_input.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
方案A: 自定义终端输入 + ANSI 补全菜单
|
|
4
|
+
完全绕过 prompt_toolkit 的补全系统,自己控制一切渲染。
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import msvcrt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _move_cursor_up(n: int):
|
|
12
|
+
if n > 0:
|
|
13
|
+
sys.stdout.write(f"\x1b[{n}A")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _move_cursor_down(n: int):
|
|
17
|
+
if n > 0:
|
|
18
|
+
sys.stdout.write(f"\x1b[{n}B")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _move_cursor_right(n: int):
|
|
22
|
+
if n > 0:
|
|
23
|
+
sys.stdout.write(f"\x1b[{n}C")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _move_cursor_left(n: int):
|
|
27
|
+
if n > 0:
|
|
28
|
+
sys.stdout.write(f"\x1b[{n}D")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _clear_line():
|
|
32
|
+
sys.stdout.write("\r\x1b[K")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _erase_lines_below(count: int):
|
|
36
|
+
for _ in range(count):
|
|
37
|
+
sys.stdout.write("\r\x1b[K")
|
|
38
|
+
sys.stdout.write("\n")
|
|
39
|
+
_move_cursor_up(count)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ansi_white(text: str) -> str:
|
|
43
|
+
return f"\x1b[97m{text}\x1b[0m"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ansi_gray(text: str) -> str:
|
|
47
|
+
return f"\x1b[37m{text}\x1b[0m"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ansi_yellow(text: str) -> str:
|
|
51
|
+
return f"\x1b[33m{text}\x1b[0m"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ansi_bold_white(text: str) -> str:
|
|
55
|
+
return f"\x1b[1;97m{text}\x1b[0m"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fuzzy_match(search: str, target: str) -> bool:
|
|
59
|
+
if not search:
|
|
60
|
+
return True
|
|
61
|
+
si = 0
|
|
62
|
+
for ch in target:
|
|
63
|
+
if si < len(search) and ch.lower() == search[si].lower():
|
|
64
|
+
si += 1
|
|
65
|
+
return si == len(search)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_file_completions(partial: str):
|
|
69
|
+
"""获取当前目录文件补全列表"""
|
|
70
|
+
cwd = os.getcwd()
|
|
71
|
+
try:
|
|
72
|
+
entries = sorted(os.listdir(cwd))
|
|
73
|
+
except OSError:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
results = []
|
|
77
|
+
search = partial.lower()
|
|
78
|
+
for name in entries:
|
|
79
|
+
full = os.path.join(cwd, name)
|
|
80
|
+
if not os.path.isfile(full):
|
|
81
|
+
continue
|
|
82
|
+
if name.startswith("."):
|
|
83
|
+
continue
|
|
84
|
+
if _fuzzy_match(search, name):
|
|
85
|
+
try:
|
|
86
|
+
size = os.path.getsize(full)
|
|
87
|
+
if size < 1024:
|
|
88
|
+
meta = f"{size}B"
|
|
89
|
+
elif size < 1024 * 1024:
|
|
90
|
+
meta = f"{size // 1024}KB"
|
|
91
|
+
else:
|
|
92
|
+
meta = f"{size // (1024 * 1024)}MB"
|
|
93
|
+
except OSError:
|
|
94
|
+
meta = ""
|
|
95
|
+
results.append((name, meta))
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_command_completions(partial: str):
|
|
100
|
+
"""获取 / 命令补全列表"""
|
|
101
|
+
from .main import COMMANDS
|
|
102
|
+
results = []
|
|
103
|
+
for cmd_name, cmd_desc in COMMANDS:
|
|
104
|
+
if _fuzzy_match(partial, cmd_name):
|
|
105
|
+
results.append((cmd_name, cmd_desc))
|
|
106
|
+
return results
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def custom_prompt(prompt_str: str = "> "):
|
|
110
|
+
"""
|
|
111
|
+
自定义输入循环,支持 / 和 @ 补全菜单。
|
|
112
|
+
返回用户输入的字符串。
|
|
113
|
+
"""
|
|
114
|
+
buf = list(prompt_str)
|
|
115
|
+
cursor_pos = len(buf)
|
|
116
|
+
completions = []
|
|
117
|
+
completion_start = -1
|
|
118
|
+
selected_idx = 0
|
|
119
|
+
showing_menu = False
|
|
120
|
+
max_visible = 8
|
|
121
|
+
|
|
122
|
+
# 打印初始提示符
|
|
123
|
+
sys.stdout.write(prompt_str)
|
|
124
|
+
sys.stdout.flush()
|
|
125
|
+
|
|
126
|
+
while True:
|
|
127
|
+
if not msvcrt.kbhit():
|
|
128
|
+
import time
|
|
129
|
+
time.sleep(0.01)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
key = msvcrt.getwch()
|
|
133
|
+
|
|
134
|
+
# 特殊键(两字节)
|
|
135
|
+
if key == '\x00' or key == '\xe0':
|
|
136
|
+
special = msvcrt.getwch()
|
|
137
|
+
if special == 'H': # 上箭头
|
|
138
|
+
if showing_menu and completions:
|
|
139
|
+
selected_idx = max(0, selected_idx - 1)
|
|
140
|
+
_redraw_menu(prompt_str, buf, cursor_pos, completions,
|
|
141
|
+
selected_idx, max_visible, completion_start)
|
|
142
|
+
continue
|
|
143
|
+
elif special == 'P': # 下箭头
|
|
144
|
+
if showing_menu and completions:
|
|
145
|
+
visible_count = min(len(completions), max_visible)
|
|
146
|
+
selected_idx = min(visible_count - 1, selected_idx + 1)
|
|
147
|
+
_redraw_menu(prompt_str, buf, cursor_pos, completions,
|
|
148
|
+
selected_idx, max_visible, completion_start)
|
|
149
|
+
continue
|
|
150
|
+
elif special == 'K': # 左箭头
|
|
151
|
+
if cursor_pos > len(prompt_str):
|
|
152
|
+
cursor_pos -= 1
|
|
153
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
154
|
+
continue
|
|
155
|
+
elif special == 'M': # 右箭头
|
|
156
|
+
if cursor_pos < len(buf):
|
|
157
|
+
cursor_pos += 1
|
|
158
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
159
|
+
continue
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
# Ctrl+C
|
|
163
|
+
if key == '\x03':
|
|
164
|
+
_clear_line()
|
|
165
|
+
raise KeyboardInterrupt
|
|
166
|
+
|
|
167
|
+
# Enter
|
|
168
|
+
if key == '\r' or key == '\n':
|
|
169
|
+
if showing_menu and completions:
|
|
170
|
+
# 选中补全项
|
|
171
|
+
sel_text = completions[selected_idx][0]
|
|
172
|
+
prefix_len = cursor_pos - completion_start
|
|
173
|
+
buf[completion_start:cursor_pos] = list(sel_text)
|
|
174
|
+
cursor_pos = completion_start + len(sel_text)
|
|
175
|
+
showing_menu = False
|
|
176
|
+
completions = []
|
|
177
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
178
|
+
else:
|
|
179
|
+
# 提交输入
|
|
180
|
+
result = ''.join(buf[len(prompt_str):])
|
|
181
|
+
sys.stdout.write('\n')
|
|
182
|
+
sys.stdout.flush()
|
|
183
|
+
return result
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Backspace
|
|
187
|
+
if key == '\x08' or key == '\x7f':
|
|
188
|
+
if cursor_pos > len(prompt_str):
|
|
189
|
+
cursor_pos -= 1
|
|
190
|
+
buf.pop(cursor_pos)
|
|
191
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
192
|
+
_check_completions(prompt_str, buf, cursor_pos)
|
|
193
|
+
if showing_menu:
|
|
194
|
+
_update_completions_and_redraw(prompt_str, buf, cursor_pos,
|
|
195
|
+
completions, selected_idx,
|
|
196
|
+
max_visible, completion_start)
|
|
197
|
+
else:
|
|
198
|
+
showing_menu = False
|
|
199
|
+
completions = []
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
# Tab - 触发/确认补全
|
|
203
|
+
if key == '\t':
|
|
204
|
+
if showing_menu and completions:
|
|
205
|
+
sel_text = completions[selected_idx][0]
|
|
206
|
+
prefix_len = cursor_pos - completion_start
|
|
207
|
+
buf[completion_start:cursor_pos] = list(sel_text)
|
|
208
|
+
cursor_pos = completion_start + len(sel_text)
|
|
209
|
+
showing_menu = False
|
|
210
|
+
completions = []
|
|
211
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# Escape - 关闭菜单
|
|
215
|
+
if key == '\x1b':
|
|
216
|
+
if showing_menu:
|
|
217
|
+
showing_menu = False
|
|
218
|
+
completions = []
|
|
219
|
+
_erase_lines_below(max_visible + 1)
|
|
220
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# 普通字符
|
|
224
|
+
if len(key) == 1 and key.isprintable():
|
|
225
|
+
buf.insert(cursor_pos, key)
|
|
226
|
+
cursor_pos += 1
|
|
227
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
228
|
+
|
|
229
|
+
# 检查是否需要显示补全菜单
|
|
230
|
+
_check_completions(prompt_str, buf, cursor_pos)
|
|
231
|
+
if showing_menu:
|
|
232
|
+
_update_completions_and_redraw(prompt_str, buf, cursor_pos,
|
|
233
|
+
completions, selected_idx,
|
|
234
|
+
max_visible, completion_start)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_completions(prompt_str, buf, cursor_pos):
|
|
239
|
+
"""检查当前光标位置是否需要显示补全菜单"""
|
|
240
|
+
global _completions_cache, _completion_start_cache, _showing_menu_cache
|
|
241
|
+
|
|
242
|
+
text_before = ''.join(buf[len(prompt_str):cursor_pos])
|
|
243
|
+
|
|
244
|
+
# 检查 @ 文件引用
|
|
245
|
+
at_ctx = _find_at_context(text_before)
|
|
246
|
+
if at_ctx:
|
|
247
|
+
at_pos_in_text, partial = at_ctx
|
|
248
|
+
actual_start = len(prompt_str) + at_pos_in_text
|
|
249
|
+
completions = _get_file_completions(partial)
|
|
250
|
+
if completions:
|
|
251
|
+
_completions_cache = completions
|
|
252
|
+
_completion_start_cache = actual_start
|
|
253
|
+
_showing_menu_cache = True
|
|
254
|
+
return
|
|
255
|
+
else:
|
|
256
|
+
_showing_menu_cache = False
|
|
257
|
+
_completions_cache = []
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
# 检查 / 命令
|
|
261
|
+
if text_before.startswith('/'):
|
|
262
|
+
completions = _get_command_completions(text_before)
|
|
263
|
+
if completions:
|
|
264
|
+
_completions_cache = completions
|
|
265
|
+
_completion_start_cache = len(prompt_str)
|
|
266
|
+
_showing_menu_cache = True
|
|
267
|
+
return
|
|
268
|
+
else:
|
|
269
|
+
_showing_menu_cache = False
|
|
270
|
+
_completions_cache = []
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
_showing_menu_cache = False
|
|
274
|
+
_completions_cache = []
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _update_completions_and_redraw(prompt_str, buf, cursor_pos,
|
|
278
|
+
completions, selected_idx,
|
|
279
|
+
max_visible, completion_start):
|
|
280
|
+
"""更新补全列表并重绘菜单"""
|
|
281
|
+
text_before = ''.join(buf[len(prompt_str):cursor_pos])
|
|
282
|
+
|
|
283
|
+
at_ctx = _find_at_context(text_before)
|
|
284
|
+
if at_ctx:
|
|
285
|
+
_, partial = at_ctx
|
|
286
|
+
new_completions = _get_file_completions(partial)
|
|
287
|
+
elif text_before.startswith('/'):
|
|
288
|
+
new_completions = _get_command_completions(text_before)
|
|
289
|
+
else:
|
|
290
|
+
new_completions = []
|
|
291
|
+
|
|
292
|
+
if not new_completions:
|
|
293
|
+
_erase_lines_below(max_visible + 1)
|
|
294
|
+
_showing_menu_cache = False
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
completions.clear()
|
|
298
|
+
completions.extend(new_completions)
|
|
299
|
+
selected_idx = 0
|
|
300
|
+
_redraw_menu(prompt_str, buf, cursor_pos, completions,
|
|
301
|
+
selected_idx, max_visible, completion_start)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# 全局状态
|
|
305
|
+
_completions_cache = []
|
|
306
|
+
_completion_start_cache = -1
|
|
307
|
+
_showing_menu_cache = False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _find_at_context(text_before_cursor: str):
|
|
311
|
+
"""查找最后一个有效的 @ 引用位置"""
|
|
312
|
+
at_positions = []
|
|
313
|
+
for i, ch in enumerate(text_before_cursor):
|
|
314
|
+
if ch == "@":
|
|
315
|
+
if i > 0 and text_before_cursor[i - 1] not in (" ", "\n", "\t"):
|
|
316
|
+
continue
|
|
317
|
+
at_positions.append(i)
|
|
318
|
+
|
|
319
|
+
if not at_positions:
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
last_at = at_positions[-1]
|
|
323
|
+
partial = text_before_cursor[last_at + 1:]
|
|
324
|
+
|
|
325
|
+
if " " in partial:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
return last_at, partial
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _redraw_line(prompt_str, buf, cursor_pos):
|
|
332
|
+
"""重绘输入行"""
|
|
333
|
+
sys.stdout.write('\r\x1b[K')
|
|
334
|
+
line = ''.join(buf)
|
|
335
|
+
sys.stdout.write(line)
|
|
336
|
+
# 移动光标到正确位置
|
|
337
|
+
target_col = cursor_pos
|
|
338
|
+
current_col = len(buf)
|
|
339
|
+
if target_col < current_col:
|
|
340
|
+
_move_cursor_left(current_col - target_col)
|
|
341
|
+
sys.stdout.flush()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _redraw_menu(prompt_str, buf, cursor_pos, completions,
|
|
345
|
+
selected_idx, max_visible, completion_start):
|
|
346
|
+
"""重绘补全菜单"""
|
|
347
|
+
global _showing_menu_cache
|
|
348
|
+
_showing_menu_cache = True
|
|
349
|
+
|
|
350
|
+
# 清除旧菜单
|
|
351
|
+
old_count = min(len(completions), max_visible) + 1
|
|
352
|
+
_erase_lines_below(old_count)
|
|
353
|
+
|
|
354
|
+
# 重新定位到输入行末尾
|
|
355
|
+
_redraw_line(prompt_str, buf, cursor_pos)
|
|
356
|
+
sys.stdout.write('\n')
|
|
357
|
+
|
|
358
|
+
# 绘制菜单项
|
|
359
|
+
visible_count = min(len(completions), max_visible)
|
|
360
|
+
start_idx = 0
|
|
361
|
+
if selected_idx >= max_visible:
|
|
362
|
+
start_idx = selected_idx - max_visible + 1
|
|
363
|
+
|
|
364
|
+
for i in range(visible_count):
|
|
365
|
+
idx = start_idx + i
|
|
366
|
+
if idx >= len(completions):
|
|
367
|
+
break
|
|
368
|
+
name, meta = completions[idx]
|
|
369
|
+
is_selected = (idx == selected_idx)
|
|
370
|
+
|
|
371
|
+
if is_selected:
|
|
372
|
+
# 选中项:白色粗体 + 灰色元信息
|
|
373
|
+
display = f" {_ansi_bold_white(name)} {_ansi_gray(meta)}"
|
|
374
|
+
else:
|
|
375
|
+
# 未选中项:白色 + 灰色元信息
|
|
376
|
+
display = f" {_ansi_white(name)} {_ansi_gray(meta)}"
|
|
377
|
+
|
|
378
|
+
sys.stdout.write(display + '\n')
|
|
379
|
+
|
|
380
|
+
# 移回输入行
|
|
381
|
+
_move_cursor_up(visible_count + 1)
|
|
382
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
扩展包管理模块
|
|
4
|
+
提供从 Git 仓库安装 Skill/MCP/Hook/Slash Command 的能力
|
|
5
|
+
"""
|
|
6
|
+
from .url_parser import PackageSource, parse_source_url
|
|
7
|
+
from .registry import (
|
|
8
|
+
ExtensionRegistry, InstalledExtension, ExtensionType,
|
|
9
|
+
create_default_registry,
|
|
10
|
+
)
|
|
11
|
+
from .installer import PackageInstaller
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PackageSource",
|
|
15
|
+
"parse_source_url",
|
|
16
|
+
"ExtensionRegistry",
|
|
17
|
+
"InstalledExtension",
|
|
18
|
+
"ExtensionType",
|
|
19
|
+
"create_default_registry",
|
|
20
|
+
"PackageInstaller",
|
|
21
|
+
]
|