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,119 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 扩展注册表
4
+ 跟踪所有通过 /extension install 安装的扩展,持久化到 ~/.abyss/registry.json。
5
+ 记录:ID、类型、来源 URL、subpath、ref、commit hash、安装时间。
6
+ 用于:/extension update(按 commit 判断有无更新)、/extension remove(精确清理)。
7
+ """
8
+ import json
9
+ import threading
10
+ from dataclasses import dataclass, field, asdict
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional
14
+
15
+
16
+ class ExtensionType(Enum):
17
+ """扩展类型。"""
18
+ SKILL = "skill"
19
+ COMMAND = "command"
20
+ HOOK = "hook"
21
+ MCP = "mcp"
22
+
23
+
24
+ @dataclass
25
+ class InstalledExtension:
26
+ """已安装的扩展记录。"""
27
+ id: str
28
+ type: ExtensionType
29
+ source_url: str
30
+ subpath: str
31
+ ref: str
32
+ commit: str
33
+ installed_at: str
34
+
35
+ def to_dict(self) -> dict:
36
+ d = asdict(self)
37
+ d["type"] = self.type.value
38
+ return d
39
+
40
+ @classmethod
41
+ def from_dict(cls, d: dict) -> "InstalledExtension":
42
+ return cls(
43
+ id=d["id"],
44
+ type=ExtensionType(d["type"]),
45
+ source_url=d["source_url"],
46
+ subpath=d.get("subpath", ""),
47
+ ref=d.get("ref", "HEAD"),
48
+ commit=d.get("commit", ""),
49
+ installed_at=d.get("installed_at", ""),
50
+ )
51
+
52
+
53
+ class ExtensionRegistry:
54
+ """已安装扩展的注册表,JSON 持久化。"""
55
+
56
+ def __init__(self, registry_path: Path):
57
+ self._path = registry_path
58
+ self._lock = threading.Lock()
59
+ self._items: Dict[str, InstalledExtension] = {}
60
+ self._load()
61
+
62
+ def _load(self) -> None:
63
+ """从文件加载,文件不存在则空注册表"""
64
+ if not self._path.exists():
65
+ return
66
+ try:
67
+ with open(self._path, "r", encoding="utf-8") as f:
68
+ data = json.load(f)
69
+ for ext_data in data.get("extensions", []):
70
+ ext = InstalledExtension.from_dict(ext_data)
71
+ self._items[ext.id] = ext
72
+ except (json.JSONDecodeError, OSError, KeyError):
73
+ self._items = {}
74
+
75
+ def _save(self) -> None:
76
+ """持久化到文件"""
77
+ self._path.parent.mkdir(parents=True, exist_ok=True)
78
+ data = {"extensions": [ext.to_dict() for ext in self._items.values()]}
79
+ with open(self._path, "w", encoding="utf-8") as f:
80
+ json.dump(data, f, indent=2, ensure_ascii=False)
81
+
82
+ def add(self, ext: InstalledExtension) -> None:
83
+ """添加或覆盖扩展记录"""
84
+ with self._lock:
85
+ self._items[ext.id] = ext
86
+ self._save()
87
+
88
+ def get(self, ext_id: str) -> Optional[InstalledExtension]:
89
+ """按 ID 获取扩展"""
90
+ return self._items.get(ext_id)
91
+
92
+ def list_all(self) -> List[InstalledExtension]:
93
+ """返回所有已安装扩展"""
94
+ return list(self._items.values())
95
+
96
+ def list_by_type(self, ext_type: ExtensionType) -> List[InstalledExtension]:
97
+ """按类型过滤"""
98
+ return [e for e in self._items.values() if e.type == ext_type]
99
+
100
+ def remove(self, ext_id: str) -> bool:
101
+ """按 ID 移除。返回是否存在并被移除"""
102
+ with self._lock:
103
+ if ext_id in self._items:
104
+ del self._items[ext_id]
105
+ self._save()
106
+ return True
107
+ return False
108
+
109
+ def update_commit(self, ext_id: str, new_commit: str) -> None:
110
+ """更新扩展的 commit hash,用于 update 子命令"""
111
+ with self._lock:
112
+ if ext_id in self._items:
113
+ self._items[ext_id].commit = new_commit
114
+ self._save()
115
+
116
+
117
+ def create_default_registry() -> ExtensionRegistry:
118
+ """创建指向 ~/.abyss/registry.json 的默认注册表"""
119
+ return ExtensionRegistry(registry_path=Path.home() / ".abyss" / "registry.json")
@@ -0,0 +1,86 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 包源 URL 解析器
4
+ 解析用户在 install/update 子命令中提供的 URL,提取 git clone 地址、子目录、ref。
5
+
6
+ 支持的 URL 格式:
7
+ https://github.com/user/repo
8
+ https://github.com/user/repo.git
9
+ https://github.com/user/repo?subdir=skills/python-test
10
+ https://github.com/user/repo?subdir=skills/py&ref=v1.0
11
+ https://github.com/user/repo/tree/main/skills/python-test
12
+ git@github.com:user/repo.git
13
+ """
14
+ import re
15
+ from dataclasses import dataclass
16
+ from urllib.parse import urlparse, parse_qs
17
+
18
+
19
+ # GitHub tree 路径格式:/tree/<ref>/<subpath>(可在 path 任意位置)
20
+ _GH_TREE_RE = re.compile(r"/tree/([^/]+)(?:/(.*))?$")
21
+
22
+
23
+ @dataclass
24
+ class PackageSource:
25
+ """解析后的包来源信息。"""
26
+ clone_url: str
27
+ subpath: str = ""
28
+ ref: str = "HEAD"
29
+
30
+ def __repr__(self):
31
+ return f"PackageSource({self.clone_url}, subpath={self.subpath!r}, ref={self.ref!r})"
32
+
33
+
34
+ def parse_source_url(url: str) -> PackageSource:
35
+ """解析用户输入的源 URL,返回 PackageSource。无法识别时抛 ValueError。
36
+
37
+ 参数:
38
+ url: 用户输入的 URL 或 SSH 路径
39
+
40
+ 返回:
41
+ PackageSource(克隆地址, 子目录, ref)
42
+ """
43
+ url = url.strip()
44
+ if not url:
45
+ raise ValueError("URL 不能为空")
46
+
47
+ # SSH 形式:git@host:user/repo.git
48
+ if url.startswith("git@") and ":" in url:
49
+ return PackageSource(clone_url=url)
50
+
51
+ # 必须含 scheme
52
+ if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", url):
53
+ raise ValueError(f"无法识别的 URL 格式: {url}")
54
+
55
+ parsed = urlparse(url)
56
+ if parsed.scheme not in ("http", "https", "git", "ssh"):
57
+ raise ValueError(f"不支持的协议: {parsed.scheme}")
58
+
59
+ # 提取 ?query
60
+ subpath_q = ""
61
+ ref = "HEAD"
62
+ if parsed.query:
63
+ qs = parse_qs(parsed.query)
64
+ if "subdir" in qs and qs["subdir"]:
65
+ subpath_q = qs["subdir"][0]
66
+ if "ref" in qs and qs["ref"]:
67
+ ref = qs["ref"][0]
68
+
69
+ # 解析 /tree/<ref>/<subpath>,在 path 任意位置匹配
70
+ subpath_tree = ""
71
+ tree_match = _GH_TREE_RE.search(parsed.path)
72
+ if tree_match:
73
+ ref = tree_match.group(1)
74
+ subpath_tree = (tree_match.group(2) or "").strip("/")
75
+ # 截掉 /tree/<ref>/<subpath> 及其前面的 /
76
+ base_path = parsed.path[: tree_match.start()].rstrip("/")
77
+ else:
78
+ base_path = parsed.path.rstrip("/")
79
+
80
+ subpath = (subpath_q or subpath_tree).strip("/")
81
+
82
+ clone_url = f"{parsed.scheme}://{parsed.netloc}{base_path}"
83
+ if not clone_url.endswith(".git"):
84
+ clone_url += ".git"
85
+
86
+ return PackageSource(clone_url=clone_url, subpath=subpath, ref=ref)
@@ -0,0 +1,12 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Hooks 模块
4
+ 提供生命周期事件钩子机制(PreToolUse/PostToolUse/PreLLMRequest 等)
5
+ """
6
+ from .runner import HookRunner, HookResult, create_default_runner
7
+
8
+ __all__ = [
9
+ "HookRunner",
10
+ "HookResult",
11
+ "create_default_runner",
12
+ ]
abyss/hooks/runner.py ADDED
@@ -0,0 +1,144 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Hook Runner 模块
4
+ 在 Agent 生命周期的特定节点(PreToolUse/PostToolUse/PreLLMRequest 等)
5
+ 自动触发用户配置的可执行脚本。
6
+
7
+ 目录结构:
8
+ hooks_dir/
9
+ PreToolUse/
10
+ 01_validate.bat # Windows
11
+ 01_validate.sh # Unix
12
+ PostToolUse/
13
+ log.bat
14
+
15
+ 环境变量传递:context 字典中的每个 key 会以 ABYSS_<KEY_UPPER> 形式注入子进程。
16
+ Pre* 事件中钩子退出码非 0 视为阻断信号(block=True)。
17
+ """
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+
25
+
26
+ # 可执行的脚本扩展名(按平台)
27
+ _WIN_EXTS = {".bat", ".cmd", ".ps1"}
28
+ _UNIX_EXTS = {".sh", ".bash", ".py", ""}
29
+
30
+
31
+ @dataclass
32
+ class HookResult:
33
+ """单个钩子执行结果。"""
34
+ name: str
35
+ success: bool
36
+ block: bool
37
+ stdout: str
38
+ stderr: str
39
+ returncode: int
40
+
41
+
42
+ class HookRunner:
43
+ """从目录加载并执行生命周期事件钩子。"""
44
+
45
+ def __init__(self, hooks_dir: Optional[Path] = None):
46
+ self._hooks_dir = hooks_dir
47
+ # 事件名 -> 钩子脚本路径列表(按文件名排序)
48
+ self._hooks: Dict[str, List[Path]] = {}
49
+ if hooks_dir is not None:
50
+ self._load_from_dir(hooks_dir)
51
+
52
+ def _load_from_dir(self, hooks_dir: Path) -> None:
53
+ """扫描 hooks_dir/<event>/ 下的可执行脚本"""
54
+ if not hooks_dir.exists() or not hooks_dir.is_dir():
55
+ return
56
+ for event_dir in hooks_dir.iterdir():
57
+ if not event_dir.is_dir():
58
+ continue
59
+ event_name = event_dir.name
60
+ scripts = []
61
+ for f in event_dir.iterdir():
62
+ if not f.is_file():
63
+ continue
64
+ if not self._is_executable(f):
65
+ continue
66
+ scripts.append(f)
67
+ scripts.sort(key=lambda p: p.name)
68
+ self._hooks[event_name] = scripts
69
+
70
+ @staticmethod
71
+ def _is_executable(path: Path) -> bool:
72
+ """判断文件是否为可执行脚本(按平台扩展名过滤)"""
73
+ ext = path.suffix.lower()
74
+ if sys.platform.startswith("win"):
75
+ return ext in _WIN_EXTS
76
+ return ext in _UNIX_EXTS or os.access(path, os.X_OK)
77
+
78
+ def list_hooks(self, event: str) -> List[Path]:
79
+ """返回指定事件的所有钩子脚本路径"""
80
+ return list(self._hooks.get(event, []))
81
+
82
+ def run(self, event: str, context: Dict[str, str]) -> List[HookResult]:
83
+ """执行指定事件的所有钩子,按文件名排序依次运行。
84
+ context 中的键值会以 ABYSS_<KEY_UPPER> 注入子进程环境变量。
85
+ 返回每个钩子的执行结果。
86
+ """
87
+ results = []
88
+ for script in self._hooks.get(event, []):
89
+ result = self._run_script(script, context)
90
+ results.append(result)
91
+ # Pre* 事件中阻断后不再执行后续钩子
92
+ if result.block and event.startswith("Pre"):
93
+ break
94
+ return results
95
+
96
+ @staticmethod
97
+ def _run_script(script: Path, context: Dict[str, str]) -> HookResult:
98
+ """执行单个钩子脚本"""
99
+ env = os.environ.copy()
100
+ for key, value in context.items():
101
+ env_key = f"ABYSS_{key.upper()}"
102
+ env[env_key] = str(value)
103
+
104
+ try:
105
+ if sys.platform.startswith("win"):
106
+ # Windows 用 cmd 执行 .bat/.cmd
107
+ proc = subprocess.run(
108
+ ["cmd", "/c", str(script)],
109
+ capture_output=True, text=True,
110
+ env=env, timeout=30, encoding="utf-8", errors="replace"
111
+ )
112
+ else:
113
+ proc = subprocess.run(
114
+ [str(script)],
115
+ capture_output=True, text=True,
116
+ env=env, timeout=30, encoding="utf-8", errors="replace"
117
+ )
118
+ returncode = proc.returncode
119
+ stdout = proc.stdout or ""
120
+ stderr = proc.stderr or ""
121
+ except subprocess.TimeoutExpired:
122
+ return HookResult(
123
+ name=script.name, success=False, block=False,
124
+ stdout="", stderr="timeout after 30s", returncode=-1
125
+ )
126
+ except Exception as e:
127
+ return HookResult(
128
+ name=script.name, success=False, block=False,
129
+ stdout="", stderr=str(e), returncode=-1
130
+ )
131
+
132
+ return HookResult(
133
+ name=script.name,
134
+ success=(returncode == 0),
135
+ block=(returncode != 0),
136
+ stdout=stdout,
137
+ stderr=stderr,
138
+ returncode=returncode,
139
+ )
140
+
141
+
142
+ def create_default_runner() -> HookRunner:
143
+ """创建指向 ~/.abyss/hooks/ 的默认 HookRunner"""
144
+ return HookRunner(hooks_dir=Path.home() / ".abyss" / "hooks")
abyss/logger.py ADDED
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 事件日志系统
4
+ 记录 abyss CLI 所有关键事件,包括 API 调用、工具执行、错误、卡顿等。
5
+ 日志存储在 ~/.abyss/logs/ 下,按天轮转,保留最近 30 天。
6
+ 格式: ISO时间 | 类别 | 事件 | 详情
7
+ """
8
+ import os
9
+ import json
10
+ import time
11
+ import threading
12
+ import glob as _glob
13
+ from datetime import datetime, timedelta
14
+ from pathlib import Path
15
+
16
+ LOG_DIR = Path.home() / ".abyss" / "logs"
17
+ LOG_RETENTION_DAYS = 30
18
+ MAX_PAYLOAD_LEN = 2000
19
+
20
+
21
+ class EventLogger:
22
+ """线程安全的事件日志记录器。每日自动轮转,自动清理过期日志。"""
23
+
24
+ def __init__(self):
25
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
26
+ self._lock = threading.Lock()
27
+ self._date = None
28
+ self._file = None
29
+
30
+ def _rotate_if_needed(self):
31
+ today = datetime.now().strftime("%Y-%m-%d")
32
+ if self._date == today and self._file:
33
+ return
34
+ if self._file:
35
+ self._file.close()
36
+ self._date = today
37
+ log_path = LOG_DIR / f"abyss_{today}.log"
38
+ self._file = open(str(log_path), "a", encoding="utf-8", buffering=1)
39
+ self._cleanup_old()
40
+
41
+ def _cleanup_old(self):
42
+ cutoff = datetime.now() - timedelta(days=LOG_RETENTION_DAYS)
43
+ for fp in _glob.glob(str(LOG_DIR / "abyss_*.log")):
44
+ try:
45
+ fname = os.path.basename(fp)
46
+ date_part = fname.replace("abyss_", "").replace(".log", "")
47
+ if datetime.strptime(date_part, "%Y-%m-%d") < cutoff:
48
+ os.remove(fp)
49
+ except (ValueError, OSError):
50
+ pass
51
+
52
+ def _write(self, category: str, event: str, payload: str):
53
+ self._rotate_if_needed()
54
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}"
55
+ line = f"{ts} | {category:11s} | {event:24s} | {payload}\n"
56
+ with self._lock:
57
+ self._file.write(line)
58
+ self._file.flush()
59
+
60
+ def _fmt(self, **kwargs) -> str:
61
+ parts = []
62
+ for k, v in kwargs.items():
63
+ if isinstance(v, str) and len(v) > MAX_PAYLOAD_LEN:
64
+ v = v[:MAX_PAYLOAD_LEN] + f"...[{len(v)}]"
65
+ elif isinstance(v, (dict, list)):
66
+ v = json.dumps(v, ensure_ascii=False, default=str)
67
+ if len(v) > MAX_PAYLOAD_LEN:
68
+ v = v[:MAX_PAYLOAD_LEN] + f"...[{len(v)}]"
69
+ parts.append(f"{k}={v}")
70
+ return " ".join(parts)
71
+
72
+ def session_start(self, **kwargs):
73
+ self._write("SESSION", "start", self._fmt(**kwargs))
74
+
75
+ def session_end(self, reason=""):
76
+ self._write("SESSION", "end", f"reason={reason}" if reason else "reason=normal")
77
+
78
+ def user_input(self, text: str):
79
+ self._write("USER_INPUT", "", f"text={text!r}")
80
+
81
+ def slash_command(self, cmd: str):
82
+ self._write("SLASH_CMD", "", f"cmd={cmd}")
83
+
84
+ def api_request(self, **kwargs):
85
+ self._write("API", "request_start", self._fmt(**kwargs))
86
+
87
+ def api_first_token(self, latency_ms: int):
88
+ self._write("API", "first_token", f"latency_ms={latency_ms}")
89
+
90
+ def api_response(self, **kwargs):
91
+ self._write("API", "response", self._fmt(**kwargs))
92
+
93
+ def api_error(self, error: str):
94
+ self._write("API", "error", f"error={error!r}")
95
+
96
+ def api_retry(self, attempt: int, wait_s: float, reason: str):
97
+ self._write("API", "retry", f"attempt={attempt} wait_s={wait_s:.2f} reason={reason}")
98
+
99
+ def tool_start(self, name: str, **kwargs):
100
+ self._write("TOOL", "start", self._fmt(name=name, **kwargs))
101
+
102
+ def tool_end(self, name: str, success: bool, duration_ms: int, **kwargs):
103
+ self._write("TOOL", "end", self._fmt(name=name, success=success, duration_ms=duration_ms, **kwargs))
104
+
105
+ def spinner_start(self, message: str):
106
+ self._write("SPINNER", "start", f"message={message}")
107
+
108
+ def spinner_stop(self, message: str = ""):
109
+ self._write("SPINNER", "stop", f"message={message}" if message else "")
110
+
111
+ def batch_header(self, round_num: int, total: int, max_rounds: int):
112
+ self._write("BATCH", "header", f"round={round_num} total={total} max={max_rounds}")
113
+
114
+ def stream_content_start(self):
115
+ self._write("STREAM", "content_start", "")
116
+
117
+ def stream_reasoning_start(self):
118
+ self._write("STREAM", "reasoning_start", "")
119
+
120
+ def keyboard_interrupt(self):
121
+ self._write("SIGNAL", "keyboard_interrupt", "")
122
+
123
+ def eof(self):
124
+ self._write("SIGNAL", "eof", "")
125
+
126
+ def error(self, message: str, exc_info: str = ""):
127
+ payload = f"message={message!r}"
128
+ if exc_info:
129
+ payload += f" traceback={exc_info!r}"
130
+ self._write("ERROR", "", payload)
131
+
132
+ def flush(self):
133
+ if self._file:
134
+ with self._lock:
135
+ self._file.flush()
136
+
137
+
138
+ _logger = EventLogger()
139
+
140
+
141
+ def get_logger() -> EventLogger:
142
+ return _logger
143
+
144
+
145
+ def session_start(**kwargs):
146
+ _logger.session_start(**kwargs)
147
+
148
+
149
+ def session_end(reason=""):
150
+ _logger.session_end(reason)
151
+
152
+
153
+ def user_input(text: str):
154
+ _logger.user_input(text)
155
+
156
+
157
+ def slash_command(cmd: str):
158
+ _logger.slash_command(cmd)
159
+
160
+
161
+ def api_request(**kwargs):
162
+ _logger.api_request(**kwargs)
163
+
164
+
165
+ def api_first_token(latency_ms: int):
166
+ _logger.api_first_token(latency_ms)
167
+
168
+
169
+ def api_response(**kwargs):
170
+ _logger.api_response(**kwargs)
171
+
172
+
173
+ def api_error(error: str):
174
+ _logger.api_error(error)
175
+
176
+
177
+ def api_retry(attempt: int, wait_s: float, reason: str):
178
+ _logger.api_retry(attempt, wait_s, reason)
179
+
180
+
181
+ def tool_start(name: str, **kwargs):
182
+ _logger.tool_start(name, **kwargs)
183
+
184
+
185
+ def tool_end(name: str, success: bool, duration_ms: int, **kwargs):
186
+ _logger.tool_end(name, success, duration_ms, **kwargs)
187
+
188
+
189
+ def spinner_start(message: str):
190
+ _logger.spinner_start(message)
191
+
192
+
193
+ def spinner_stop(message: str = ""):
194
+ _logger.spinner_stop(message)
195
+
196
+
197
+ def batch_header(round_num: int, total: int, max_rounds: int):
198
+ _logger.batch_header(round_num, total, max_rounds)
199
+
200
+
201
+ def stream_content_start():
202
+ _logger.stream_content_start()
203
+
204
+
205
+ def stream_reasoning_start():
206
+ _logger.stream_reasoning_start()
207
+
208
+
209
+ def keyboard_interrupt():
210
+ _logger.keyboard_interrupt()
211
+
212
+
213
+ def eof():
214
+ _logger.eof()
215
+
216
+
217
+ def error(message: str, exc_info: str = ""):
218
+ _logger.error(message, exc_info)