aukeys-opscli 0.0.6__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.
- aukeys_opscli-0.0.6.dist-info/METADATA +15 -0
- aukeys_opscli-0.0.6.dist-info/RECORD +92 -0
- aukeys_opscli-0.0.6.dist-info/WHEEL +4 -0
- aukeys_opscli-0.0.6.dist-info/entry_points.txt +2 -0
- opscli/__init__.py +9 -0
- opscli/amazon/__init__.py +13 -0
- opscli/amazon/cli.py +5 -0
- opscli/amazon/client.py +5 -0
- opscli/amazon/commands/__init__.py +1 -0
- opscli/amazon/commands/cli.py +200 -0
- opscli/amazon/domain/__init__.py +25 -0
- opscli/amazon/domain/exceptions.py +73 -0
- opscli/amazon/domain/models.py +74 -0
- opscli/amazon/exceptions.py +21 -0
- opscli/amazon/manager.py +5 -0
- opscli/amazon/models.py +5 -0
- opscli/amazon/parser.py +5 -0
- opscli/amazon/scraper.py +5 -0
- opscli/amazon/scraping/__init__.py +12 -0
- opscli/amazon/scraping/parser.py +66 -0
- opscli/amazon/scraping/scraper.py +315 -0
- opscli/amazon/services/__init__.py +5 -0
- opscli/amazon/services/manager.py +105 -0
- opscli/amazon/transport/__init__.py +5 -0
- opscli/amazon/transport/client.py +77 -0
- opscli/auth/__init__.py +79 -0
- opscli/auth/cli.py +255 -0
- opscli/auth/commands/__init__.py +1 -0
- opscli/auth/commands/cli.py +5 -0
- opscli/auth/config.py +55 -0
- opscli/auth/core/__init__.py +1 -0
- opscli/auth/core/device_flow.py +80 -0
- opscli/auth/core/system_registry.py +89 -0
- opscli/auth/core/token_manager.py +167 -0
- opscli/auth/domain/__init__.py +23 -0
- opscli/auth/domain/exceptions.py +33 -0
- opscli/auth/exceptions.py +23 -0
- opscli/auth/storage/__init__.py +1 -0
- opscli/auth/storage/credential_store.py +163 -0
- opscli/auth/storage/crypto.py +67 -0
- opscli/cli.py +36 -0
- opscli/config.py +3 -0
- opscli/query/__init__.py +6 -0
- opscli/query/cli.py +5 -0
- opscli/query/client.py +5 -0
- opscli/query/commands/__init__.py +1 -0
- opscli/query/commands/cli.py +140 -0
- opscli/query/domain/__init__.py +23 -0
- opscli/query/domain/exceptions.py +73 -0
- opscli/query/domain/models.py +21 -0
- opscli/query/exceptions.py +21 -0
- opscli/query/manager.py +5 -0
- opscli/query/models.py +5 -0
- opscli/query/services/__init__.py +5 -0
- opscli/query/services/manager.py +373 -0
- opscli/query/transport/__init__.py +5 -0
- opscli/query/transport/client.py +57 -0
- opscli/skills/__init__.py +37 -0
- opscli/skills/cli.py +5 -0
- opscli/skills/commands/__init__.py +1 -0
- opscli/skills/commands/cli.py +389 -0
- opscli/skills/detector.py +5 -0
- opscli/skills/discovery/__init__.py +5 -0
- opscli/skills/discovery/detector.py +225 -0
- opscli/skills/domain/__init__.py +23 -0
- opscli/skills/domain/exceptions.py +51 -0
- opscli/skills/domain/models.py +144 -0
- opscli/skills/exceptions.py +5 -0
- opscli/skills/manager.py +5 -0
- opscli/skills/models.py +19 -0
- opscli/skills/services/__init__.py +5 -0
- opscli/skills/services/manager.py +276 -0
- opscli/skills/sync/__init__.py +5 -0
- opscli/skills/sync/updater.py +274 -0
- opscli/skills/templates/ops-amazon/SKILL.md +181 -0
- opscli/skills/templates/ops-amazon/data/VERSION.json +4 -0
- opscli/skills/templates/ops-auth/SKILL.md +466 -0
- opscli/skills/templates/ops-auth/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/SKILL.md +691 -0
- opscli/skills/templates/ops-dataset-query/data/VERSION.json +4 -0
- opscli/skills/templates/ops-dataset-query/data/dataset_fields.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/datasets.csv +1 -0
- opscli/skills/templates/ops-dataset-query/data/query_metadata.json +4 -0
- opscli/skills/templates/ops-dataset-query/references//346/225/260/346/215/256/346/237/245/350/257/242/346/234/215/345/212/241/345/274/200/345/217/221/350/257/264/346/230/216/346/226/207/346/241/243.md +1126 -0
- opscli/skills/templates/ops-dataset-query/scripts/core.py +140 -0
- opscli/skills/templates/ops-dataset-query/scripts/query.py +145 -0
- opscli/skills/templates/ops-dataset-query/scripts/search.py +36 -0
- opscli/skills/templates/ops-dataset-query/scripts/updater.py +106 -0
- opscli/skills/templates/ops-skills/SKILL.md +494 -0
- opscli/skills/templates/ops-skills/data/VERSION.json +4 -0
- opscli/skills/updater.py +5 -0
- opscli/version.py +19 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Skills CLI 子命令定义。
|
|
2
|
+
|
|
3
|
+
注册到 opscli 顶级命令下,提供 skills list/install/status/upgrade 四个子命令。
|
|
4
|
+
所有命令输出统一 JSON 格式,方便脚本解析。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from opscli.skills.domain.exceptions import error_to_dict
|
|
18
|
+
from opscli.skills.services.manager import SkillsManager
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Skill 生命周期管理")
|
|
21
|
+
_console = Console()
|
|
22
|
+
|
|
23
|
+
_TOOL_LABELS = {
|
|
24
|
+
"claude": "Claude Code",
|
|
25
|
+
"openclaw": "OpenClaw",
|
|
26
|
+
"codex": "Codex CLI",
|
|
27
|
+
"opencode": "OpenCode",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _emit(payload: dict, pretty: bool) -> None:
|
|
32
|
+
"""统一的 JSON 输出函数,控制是否美化格式。"""
|
|
33
|
+
if pretty:
|
|
34
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
35
|
+
else:
|
|
36
|
+
typer.echo(json.dumps(payload, ensure_ascii=False))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_multiselect(answer: str, total: int) -> list[int]:
|
|
40
|
+
"""将逗号分隔的编号字符串解析为 1-based 索引列表(空字符串返回全选)。"""
|
|
41
|
+
if not answer.strip():
|
|
42
|
+
return list(range(1, total + 1))
|
|
43
|
+
indexes: list[int] = []
|
|
44
|
+
for part in answer.split(","):
|
|
45
|
+
normalized = part.strip()
|
|
46
|
+
if not normalized:
|
|
47
|
+
continue
|
|
48
|
+
if not normalized.isdigit():
|
|
49
|
+
raise ValueError(f"无效的编号: {normalized!r}")
|
|
50
|
+
idx = int(normalized)
|
|
51
|
+
if idx < 1 or idx > total:
|
|
52
|
+
raise ValueError(f"编号 {idx} 超出范围(1~{total})")
|
|
53
|
+
indexes.append(idx)
|
|
54
|
+
return indexes or list(range(1, total + 1))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tui_select_skills(manager: SkillsManager) -> list[str]:
|
|
58
|
+
"""TUI:展示内置 Skill 列表,用户选择要安装哪些。返回选中的 skill 名称列表。"""
|
|
59
|
+
templates = manager.list_templates()
|
|
60
|
+
if not templates:
|
|
61
|
+
raise ValueError("未找到任何内置 Skill 模板")
|
|
62
|
+
|
|
63
|
+
table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
|
|
64
|
+
table.add_column("#", style="bold yellow", width=3)
|
|
65
|
+
table.add_column("名称", style="green", min_width=20)
|
|
66
|
+
table.add_column("版本", style="dim", width=8)
|
|
67
|
+
table.add_column("说明")
|
|
68
|
+
for idx, tmpl in enumerate(templates, start=1):
|
|
69
|
+
table.add_row(f"[{idx}]", tmpl["name"], tmpl["version"], tmpl["description"])
|
|
70
|
+
|
|
71
|
+
_console.print(Panel(table, title="[bold]可安装的 Skills[/bold]", border_style="blue"))
|
|
72
|
+
|
|
73
|
+
answer = typer.prompt(
|
|
74
|
+
"请选择要安装的 Skills(逗号分隔编号,直接回车安装全部)",
|
|
75
|
+
default="",
|
|
76
|
+
show_default=False,
|
|
77
|
+
)
|
|
78
|
+
selected_indexes = _parse_multiselect(answer, len(templates))
|
|
79
|
+
return [templates[i - 1]["name"] for i in selected_indexes]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _tui_select_targets(manager: SkillsManager) -> list[tuple[str, Path]] | None:
|
|
83
|
+
"""TUI:展示全局检测到的工具列表,用户选择安装到哪些工具。
|
|
84
|
+
|
|
85
|
+
使用 detect_global_install_targets() 按 ~/.claude/、which claude 等规则检测,
|
|
86
|
+
而非项目级 CWD 目录。
|
|
87
|
+
|
|
88
|
+
返回选中的 [(runtime, skills_dir), ...] 列表;未检测到任何工具时返回 None。
|
|
89
|
+
"""
|
|
90
|
+
targets = manager.detector.detect_global_install_targets()
|
|
91
|
+
if not targets:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
|
|
95
|
+
table.add_column("#", style="bold yellow", width=3)
|
|
96
|
+
table.add_column("工具", style="green", min_width=14)
|
|
97
|
+
table.add_column("安装路径", style="dim")
|
|
98
|
+
for idx, (runtime, path) in enumerate(targets, start=1):
|
|
99
|
+
label = _TOOL_LABELS.get(runtime, runtime)
|
|
100
|
+
table.add_row(f"[{idx}]", label, str(path))
|
|
101
|
+
|
|
102
|
+
_console.print(Panel(table, title="[bold]检测到的安装目标[/bold]", border_style="blue"))
|
|
103
|
+
|
|
104
|
+
answer = typer.prompt(
|
|
105
|
+
"请选择安装目标(逗号分隔编号,直接回车安装全部)",
|
|
106
|
+
default="",
|
|
107
|
+
show_default=False,
|
|
108
|
+
)
|
|
109
|
+
selected_indexes = _parse_multiselect(answer, len(targets))
|
|
110
|
+
return [targets[i - 1] for i in selected_indexes]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_install_runtime(
|
|
114
|
+
manager: SkillsManager,
|
|
115
|
+
*,
|
|
116
|
+
runtime: str | None,
|
|
117
|
+
skills_dir: str | None,
|
|
118
|
+
) -> str | list[str] | None:
|
|
119
|
+
"""在单 Skill 安装场景下解析目标 runtime(复用原有逻辑)。"""
|
|
120
|
+
if runtime or skills_dir:
|
|
121
|
+
return runtime
|
|
122
|
+
|
|
123
|
+
detector = getattr(manager, "detector", None)
|
|
124
|
+
if detector is None:
|
|
125
|
+
return runtime
|
|
126
|
+
targets = detector.detect_available_install_targets(cwd=Path.cwd())
|
|
127
|
+
if not targets:
|
|
128
|
+
return runtime
|
|
129
|
+
|
|
130
|
+
table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
|
|
131
|
+
table.add_column("#", style="bold yellow", width=3)
|
|
132
|
+
table.add_column("工具", style="green", min_width=14)
|
|
133
|
+
table.add_column("安装路径", style="dim")
|
|
134
|
+
for idx, (target_runtime, target_path) in enumerate(targets, start=1):
|
|
135
|
+
label = _TOOL_LABELS.get(target_runtime, target_runtime)
|
|
136
|
+
table.add_row(f"[{idx}]", label, str(target_path))
|
|
137
|
+
|
|
138
|
+
_console.print(Panel(table, title="[bold]检测到的安装目标[/bold]", border_style="blue"))
|
|
139
|
+
|
|
140
|
+
answer = typer.prompt(
|
|
141
|
+
"请选择安装目标(逗号分隔编号,直接回车安装全部)",
|
|
142
|
+
default="",
|
|
143
|
+
show_default=False,
|
|
144
|
+
)
|
|
145
|
+
selected_indexes = _parse_multiselect(answer, len(targets))
|
|
146
|
+
return [targets[i - 1][0] for i in selected_indexes]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("list")
|
|
150
|
+
def list_skills(
|
|
151
|
+
skills_dir: str | None = typer.Option(None, "--skills-dir", help="指定扫描目录"),
|
|
152
|
+
pretty: bool = typer.Option(False, "--pretty", help="格式化输出"),
|
|
153
|
+
):
|
|
154
|
+
"""列出所有已安装的 Skill。"""
|
|
155
|
+
manager = SkillsManager()
|
|
156
|
+
status_data = manager.status(skills_dir=skills_dir)
|
|
157
|
+
_emit(
|
|
158
|
+
{
|
|
159
|
+
"success": True,
|
|
160
|
+
"command": "skills list",
|
|
161
|
+
"data": {"skills": status_data["skills"]},
|
|
162
|
+
"error": None,
|
|
163
|
+
},
|
|
164
|
+
pretty,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("install")
|
|
169
|
+
def install_skill(
|
|
170
|
+
name: str | None = typer.Argument(None, help="Skill 名称,不指定则进入交互模式安装全部"),
|
|
171
|
+
skills_dir: str | None = typer.Option(None, "--skills-dir", help="指定安装目录"),
|
|
172
|
+
runtime: str | None = typer.Option(None, "--runtime", help="claude、openclaw、all,或逗号分隔多个值"),
|
|
173
|
+
force: bool = typer.Option(False, "--force", help="覆盖已存在目录"),
|
|
174
|
+
pretty: bool = typer.Option(False, "--pretty", help="格式化输出"),
|
|
175
|
+
):
|
|
176
|
+
"""从内置模板安装 Skill 到本地目录。
|
|
177
|
+
|
|
178
|
+
不指定 NAME 时进入 TUI 交互模式,可多选 Skills 和安装目标。
|
|
179
|
+
"""
|
|
180
|
+
manager = SkillsManager()
|
|
181
|
+
try:
|
|
182
|
+
if name is None:
|
|
183
|
+
# TUI 模式:交互选择 Skills 和安装目标
|
|
184
|
+
_install_interactive(manager, skills_dir=skills_dir, runtime=runtime, force=force, pretty=pretty)
|
|
185
|
+
return
|
|
186
|
+
# 单 Skill 安装(原有逻辑)
|
|
187
|
+
resolved_runtime = _resolve_install_runtime(
|
|
188
|
+
manager,
|
|
189
|
+
runtime=runtime,
|
|
190
|
+
skills_dir=skills_dir,
|
|
191
|
+
)
|
|
192
|
+
result = manager.install(
|
|
193
|
+
name,
|
|
194
|
+
skills_dir=skills_dir,
|
|
195
|
+
runtime=resolved_runtime,
|
|
196
|
+
force=force,
|
|
197
|
+
)
|
|
198
|
+
payload = {
|
|
199
|
+
"success": True,
|
|
200
|
+
"command": "skills install",
|
|
201
|
+
"data": result.to_dict(),
|
|
202
|
+
"error": None,
|
|
203
|
+
}
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
payload = {
|
|
206
|
+
"success": False,
|
|
207
|
+
"command": "skills install",
|
|
208
|
+
"data": None,
|
|
209
|
+
"error": error_to_dict(exc),
|
|
210
|
+
}
|
|
211
|
+
_emit(payload, pretty)
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
_emit(payload, pretty)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _install_interactive(
|
|
218
|
+
manager: SkillsManager,
|
|
219
|
+
*,
|
|
220
|
+
skills_dir: str | None,
|
|
221
|
+
runtime: str | None,
|
|
222
|
+
force: bool,
|
|
223
|
+
pretty: bool,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""TUI 交互安装:选择 Skills → 选择目标工具 → 批量安装。
|
|
226
|
+
|
|
227
|
+
交互模式默认 force=True(覆盖已有安装),无需手动传 --force。
|
|
228
|
+
"""
|
|
229
|
+
force = True # TUI 模式始终覆盖,避免因已安装而中断
|
|
230
|
+
# Step 1:选择要安装的 Skills
|
|
231
|
+
try:
|
|
232
|
+
skill_names = _tui_select_skills(manager)
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
_console.print(f"[red]错误:{exc}[/red]")
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
|
|
237
|
+
# Step 2:确定安装目标列表 —— [(runtime, Path), ...]
|
|
238
|
+
# 优先级:显式 skills_dir > 显式 runtime > TUI 全局检测
|
|
239
|
+
target_pairs: list[tuple[str, Path]] | None = None
|
|
240
|
+
if skills_dir:
|
|
241
|
+
# 用户显式指定目录:安装到唯一路径
|
|
242
|
+
target_pairs = [(runtime or "custom", Path(skills_dir).expanduser())]
|
|
243
|
+
elif runtime:
|
|
244
|
+
# 用户显式指定 runtime:通过 detector 解析为具体路径(保持原有行为)
|
|
245
|
+
runtime_list = [r.strip() for r in runtime.split(",") if r.strip()]
|
|
246
|
+
try:
|
|
247
|
+
target_pairs = manager.detector.detect_install_targets(preferred_runtimes=runtime_list)
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
_console.print(f"[red]错误:{exc}[/red]")
|
|
250
|
+
raise typer.Exit(1)
|
|
251
|
+
else:
|
|
252
|
+
# TUI 全局检测:使用 ~/.claude/、~/.openclaw/ 等全局路径
|
|
253
|
+
try:
|
|
254
|
+
target_pairs = _tui_select_targets(manager)
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
_console.print(f"[red]错误:{exc}[/red]")
|
|
257
|
+
raise typer.Exit(1)
|
|
258
|
+
|
|
259
|
+
# Step 3:批量安装(skill × target 二重循环)
|
|
260
|
+
_console.print()
|
|
261
|
+
all_results: list[dict] = []
|
|
262
|
+
errors: list[str] = []
|
|
263
|
+
|
|
264
|
+
for skill_name in skill_names:
|
|
265
|
+
if target_pairs:
|
|
266
|
+
# 有明确路径:逐目标安装,每次传入显式 skills_dir
|
|
267
|
+
for target_runtime, target_path in target_pairs:
|
|
268
|
+
try:
|
|
269
|
+
result = manager.install(
|
|
270
|
+
skill_name,
|
|
271
|
+
skills_dir=str(target_path),
|
|
272
|
+
runtime=target_runtime,
|
|
273
|
+
force=force,
|
|
274
|
+
)
|
|
275
|
+
all_results.append(result.to_dict())
|
|
276
|
+
for install in result.installs:
|
|
277
|
+
_print_install_line(install)
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
key = f"{skill_name}@{target_path}"
|
|
280
|
+
errors.append(f"{key}: {exc}")
|
|
281
|
+
_console.print(f" [red]✗[/red] [bold]{skill_name}[/bold] [red]{exc}[/red]")
|
|
282
|
+
else:
|
|
283
|
+
# 未检测到任何全局工具:交由 manager 使用默认逻辑
|
|
284
|
+
try:
|
|
285
|
+
result = manager.install(skill_name, force=force)
|
|
286
|
+
all_results.append(result.to_dict())
|
|
287
|
+
for install in result.installs:
|
|
288
|
+
_print_install_line(install)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
errors.append(f"{skill_name}: {exc}")
|
|
291
|
+
_console.print(f" [red]✗[/red] [bold]{skill_name}[/bold] [red]{exc}[/red]")
|
|
292
|
+
|
|
293
|
+
_console.print()
|
|
294
|
+
if errors:
|
|
295
|
+
_console.print(f"[yellow]完成:{len(all_results)} 个成功,{len(errors)} 个失败[/yellow]")
|
|
296
|
+
payload = {
|
|
297
|
+
"success": False,
|
|
298
|
+
"command": "skills install",
|
|
299
|
+
"data": {"results": all_results},
|
|
300
|
+
"error": {
|
|
301
|
+
"type": "BatchInstallError",
|
|
302
|
+
"message": "; ".join(errors),
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
_emit(payload, pretty)
|
|
306
|
+
raise typer.Exit(1)
|
|
307
|
+
else:
|
|
308
|
+
_console.print(f"[green]全部安装完成,共 {len(all_results)} 个 Skill[/green]")
|
|
309
|
+
payload = {
|
|
310
|
+
"success": True,
|
|
311
|
+
"command": "skills install",
|
|
312
|
+
"data": {"results": all_results},
|
|
313
|
+
"error": None,
|
|
314
|
+
}
|
|
315
|
+
_emit(payload, pretty)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _print_install_line(install: object) -> None:
|
|
319
|
+
"""打印单条安装结果行。"""
|
|
320
|
+
replaced = getattr(install, "replaced", False)
|
|
321
|
+
status_icon = "↻" if replaced else "✓"
|
|
322
|
+
name = getattr(install, "name", "")
|
|
323
|
+
version = getattr(install, "version", "")
|
|
324
|
+
runtime = getattr(install, "runtime", "")
|
|
325
|
+
target_dir = getattr(install, "target_dir", "")
|
|
326
|
+
tool_label = _TOOL_LABELS.get(runtime, runtime)
|
|
327
|
+
_console.print(
|
|
328
|
+
f" [green]{status_icon}[/green] [bold]{name}[/bold] "
|
|
329
|
+
f"[dim]{version}[/dim] → [cyan]{tool_label}[/cyan] "
|
|
330
|
+
f"[dim]({target_dir})[/dim]"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.command("status")
|
|
335
|
+
def status(
|
|
336
|
+
skills_dir: str | None = typer.Option(None, "--skills-dir", help="指定扫描目录"),
|
|
337
|
+
pretty: bool = typer.Option(False, "--pretty", help="格式化输出"),
|
|
338
|
+
):
|
|
339
|
+
"""查看 Skill 安装状态,包含远端版本对比。"""
|
|
340
|
+
manager = SkillsManager()
|
|
341
|
+
try:
|
|
342
|
+
data = manager.status(skills_dir=skills_dir)
|
|
343
|
+
payload = {
|
|
344
|
+
"success": True,
|
|
345
|
+
"command": "skills status",
|
|
346
|
+
"data": data,
|
|
347
|
+
"error": None,
|
|
348
|
+
}
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
payload = {
|
|
351
|
+
"success": False,
|
|
352
|
+
"command": "skills status",
|
|
353
|
+
"data": None,
|
|
354
|
+
"error": error_to_dict(exc),
|
|
355
|
+
}
|
|
356
|
+
_emit(payload, pretty)
|
|
357
|
+
raise typer.Exit(1)
|
|
358
|
+
|
|
359
|
+
_emit(payload, pretty)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.command("upgrade")
|
|
363
|
+
def upgrade(
|
|
364
|
+
name: str = typer.Argument("ops-dataset-query", help="Skill 名称"),
|
|
365
|
+
skills_dir: str | None = typer.Option(None, "--skills-dir", help="指定扫描目录"),
|
|
366
|
+
force: bool = typer.Option(False, "--force", help="强制覆盖本地版本"),
|
|
367
|
+
pretty: bool = typer.Option(False, "--pretty", help="格式化输出"),
|
|
368
|
+
):
|
|
369
|
+
"""升级 Skill 到远端最新版本。"""
|
|
370
|
+
manager = SkillsManager()
|
|
371
|
+
try:
|
|
372
|
+
result = manager.upgrade(name=name, skills_dir=skills_dir, force=force)
|
|
373
|
+
payload = {
|
|
374
|
+
"success": True,
|
|
375
|
+
"command": "skills upgrade",
|
|
376
|
+
"data": result.to_dict(),
|
|
377
|
+
"error": None,
|
|
378
|
+
}
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
payload = {
|
|
381
|
+
"success": False,
|
|
382
|
+
"command": "skills upgrade",
|
|
383
|
+
"data": None,
|
|
384
|
+
"error": error_to_dict(exc),
|
|
385
|
+
}
|
|
386
|
+
_emit(payload, pretty)
|
|
387
|
+
raise typer.Exit(1)
|
|
388
|
+
|
|
389
|
+
_emit(payload, pretty)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Skill 发现与检测模块。
|
|
2
|
+
|
|
3
|
+
负责扫描多个候选目录,发现已安装的 Skill 并生成 SkillRecord。
|
|
4
|
+
同时提供安装目标目录的检测逻辑,决定新 Skill 应安装到哪里。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from opscli.skills.domain.models import SkillRecord
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SkillDetector:
|
|
18
|
+
"""Skill 探测器,扫描磁盘目录发现已安装的 Skill。"""
|
|
19
|
+
|
|
20
|
+
def candidate_dirs(self, skills_dir: str | None = None, cwd: Path | None = None) -> list[Path]:
|
|
21
|
+
"""收集所有候选扫描目录,按优先级排列并去重。
|
|
22
|
+
|
|
23
|
+
优先级从高到低:
|
|
24
|
+
1. 调用方显式指定的 skills_dir 参数
|
|
25
|
+
2. 环境变量 OPSCLI_SKILLS_DIR
|
|
26
|
+
3. 当前工作目录下的 .claude/skills
|
|
27
|
+
4. 用户主目录下的 .claude/skills
|
|
28
|
+
5. 用户主目录下的 .openclaw/skills
|
|
29
|
+
"""
|
|
30
|
+
current = cwd or Path.cwd()
|
|
31
|
+
candidates: list[Path] = []
|
|
32
|
+
|
|
33
|
+
# 显式指定的目录优先级最高
|
|
34
|
+
if skills_dir:
|
|
35
|
+
candidates.append(Path(skills_dir).expanduser())
|
|
36
|
+
|
|
37
|
+
# 环境变量指定的目录次之
|
|
38
|
+
env_dir = os.getenv("OPSCLI_SKILLS_DIR")
|
|
39
|
+
if env_dir:
|
|
40
|
+
candidates.append(Path(env_dir).expanduser())
|
|
41
|
+
|
|
42
|
+
# 默认候选目录(按工具优先级排列)
|
|
43
|
+
home = Path.home()
|
|
44
|
+
candidates.extend(
|
|
45
|
+
[
|
|
46
|
+
current / ".claude" / "skills",
|
|
47
|
+
home / ".claude" / "skills",
|
|
48
|
+
home / ".openclaw" / "skills",
|
|
49
|
+
home / ".codex" / "skills",
|
|
50
|
+
self._opencode_skills_dir(),
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# 按路径去重,避免同一目录被重复扫描
|
|
55
|
+
seen: set[str] = set()
|
|
56
|
+
result: list[Path] = []
|
|
57
|
+
for item in candidates:
|
|
58
|
+
key = str(item.resolve()) if item.exists() else str(item.expanduser())
|
|
59
|
+
if key in seen:
|
|
60
|
+
continue
|
|
61
|
+
seen.add(key)
|
|
62
|
+
result.append(item)
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
def discover(self, skills_dir: str | None = None, cwd: Path | None = None) -> list[SkillRecord]:
|
|
66
|
+
"""扫描所有候选目录,发现已安装的 Skill。
|
|
67
|
+
|
|
68
|
+
判定条件:目录下必须存在 data/VERSION.json 文件。
|
|
69
|
+
解析失败(JSON 格式错误等)的目录会被静默跳过。
|
|
70
|
+
"""
|
|
71
|
+
records: list[SkillRecord] = []
|
|
72
|
+
for base_dir in self.candidate_dirs(skills_dir=skills_dir, cwd=cwd):
|
|
73
|
+
if not base_dir.exists():
|
|
74
|
+
continue
|
|
75
|
+
# 遍历子目录,按名称排序保证输出稳定
|
|
76
|
+
for skill_dir in sorted(p for p in base_dir.iterdir() if p.is_dir()):
|
|
77
|
+
version_file = skill_dir / "data" / "VERSION.json"
|
|
78
|
+
if not version_file.exists():
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
payload = json.loads(version_file.read_text(encoding="utf-8"))
|
|
82
|
+
except Exception:
|
|
83
|
+
continue
|
|
84
|
+
# 根据路径推断运行时类型
|
|
85
|
+
runtime = self._infer_runtime(skill_dir)
|
|
86
|
+
records.append(
|
|
87
|
+
SkillRecord(
|
|
88
|
+
name=skill_dir.name,
|
|
89
|
+
version=str(payload.get("version", "unknown")),
|
|
90
|
+
runtime=runtime,
|
|
91
|
+
root=skill_dir,
|
|
92
|
+
version_file=version_file,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return records
|
|
96
|
+
|
|
97
|
+
def detect_install_target(self, cwd: Path | None = None, preferred_runtime: str | None = None) -> tuple[str, Path]:
|
|
98
|
+
"""检测新 Skill 应该安装到哪个目录。
|
|
99
|
+
|
|
100
|
+
优先使用 preferred_runtime 参数,否则根据当前目录下是否存在对应运行时目录推断。
|
|
101
|
+
默认回退到 claude 运行时。
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
(runtime, target_path) 二元组
|
|
105
|
+
"""
|
|
106
|
+
current = cwd or Path.cwd()
|
|
107
|
+
# 显式指定运行时
|
|
108
|
+
runtime_dirs = {
|
|
109
|
+
"claude": current / ".claude" / "skills",
|
|
110
|
+
"openclaw": current / ".openclaw" / "skills",
|
|
111
|
+
"codex": current / ".codex" / "skills",
|
|
112
|
+
"opencode": current / ".opencode" / "skills",
|
|
113
|
+
}
|
|
114
|
+
if preferred_runtime and preferred_runtime in runtime_dirs:
|
|
115
|
+
return preferred_runtime, runtime_dirs[preferred_runtime]
|
|
116
|
+
|
|
117
|
+
# 自动检测:根据当前目录下已有的运行时目录推断
|
|
118
|
+
for runtime, path in runtime_dirs.items():
|
|
119
|
+
if path.parent.exists():
|
|
120
|
+
return runtime, path
|
|
121
|
+
# 默认使用 claude 运行时
|
|
122
|
+
return "claude", current / ".claude" / "skills"
|
|
123
|
+
|
|
124
|
+
def detect_install_targets(self, cwd: Path | None = None, preferred_runtimes: list[str] | None = None) -> list[tuple[str, Path]]:
|
|
125
|
+
"""检测多个安装目标。"""
|
|
126
|
+
current = cwd or Path.cwd()
|
|
127
|
+
|
|
128
|
+
if preferred_runtimes:
|
|
129
|
+
targets: list[tuple[str, Path]] = []
|
|
130
|
+
for runtime in preferred_runtimes:
|
|
131
|
+
normalized = runtime.strip().lower()
|
|
132
|
+
if normalized == "all":
|
|
133
|
+
return self.detect_available_install_targets(cwd=current)
|
|
134
|
+
_, path = self.detect_install_target(cwd=current, preferred_runtime=normalized)
|
|
135
|
+
targets.append((normalized, path))
|
|
136
|
+
return self._dedupe_targets(targets)
|
|
137
|
+
|
|
138
|
+
available = self.detect_available_install_targets(cwd=current)
|
|
139
|
+
if available:
|
|
140
|
+
return available
|
|
141
|
+
|
|
142
|
+
runtime, target = self.detect_install_target(cwd=current, preferred_runtime=None)
|
|
143
|
+
return [(runtime, target)]
|
|
144
|
+
|
|
145
|
+
def detect_available_install_targets(self, cwd: Path | None = None) -> list[tuple[str, Path]]:
|
|
146
|
+
"""根据当前目录已存在的运行时目录,返回可用目标列表(项目级)。"""
|
|
147
|
+
current = cwd or Path.cwd()
|
|
148
|
+
targets: list[tuple[str, Path]] = []
|
|
149
|
+
if (current / ".claude").exists():
|
|
150
|
+
targets.append(("claude", current / ".claude" / "skills"))
|
|
151
|
+
if (current / ".openclaw").exists():
|
|
152
|
+
targets.append(("openclaw", current / ".openclaw" / "skills"))
|
|
153
|
+
return self._dedupe_targets(targets)
|
|
154
|
+
|
|
155
|
+
def detect_global_install_targets(self) -> list[tuple[str, Path]]:
|
|
156
|
+
"""检测全局已安装的 AI 工具,返回对应的全局 Skills 安装目录。
|
|
157
|
+
|
|
158
|
+
检测规则(opscli 多工具 Skills 支持路径对照表):
|
|
159
|
+
|
|
160
|
+
工具 macOS/Linux Windows
|
|
161
|
+
Claude Code ~/.claude/skills/ %USERPROFILE%/.claude/skills/
|
|
162
|
+
OpenClaw ~/.openclaw/skills/ %USERPROFILE%/.openclaw/skills/
|
|
163
|
+
Codex CLI ~/.codex/skills/ %USERPROFILE%/.codex/skills/
|
|
164
|
+
OpenCode ~/.config/opencode/skills/ %LOCALAPPDATA%/opencode/skills/
|
|
165
|
+
|
|
166
|
+
检测条件:配置目录存在 或 对应命令可用(which)。
|
|
167
|
+
返回 [(runtime, skills_dir), ...] 列表,按检测顺序排列,已去重。
|
|
168
|
+
"""
|
|
169
|
+
home = Path.home()
|
|
170
|
+
targets: list[tuple[str, Path]] = []
|
|
171
|
+
|
|
172
|
+
# Claude Code:~/.claude/ 存在 或 `which claude` 可用
|
|
173
|
+
if (home / ".claude").exists() or shutil.which("claude") is not None:
|
|
174
|
+
targets.append(("claude", home / ".claude" / "skills"))
|
|
175
|
+
|
|
176
|
+
# OpenClaw:~/.openclaw/ 存在 或 `which openclaw` 可用
|
|
177
|
+
if (home / ".openclaw").exists() or shutil.which("openclaw") is not None:
|
|
178
|
+
targets.append(("openclaw", home / ".openclaw" / "skills"))
|
|
179
|
+
|
|
180
|
+
# Codex CLI:~/.codex/ 存在 或 `which codex` 可用
|
|
181
|
+
if (home / ".codex").exists() or shutil.which("codex") is not None:
|
|
182
|
+
targets.append(("codex", home / ".codex" / "skills"))
|
|
183
|
+
|
|
184
|
+
# OpenCode:~/.config/opencode/(macOS/Linux)或 %LOCALAPPDATA%\opencode\(Windows)存在,或 `which opencode` 可用
|
|
185
|
+
opencode_config = self._opencode_config_dir()
|
|
186
|
+
if opencode_config.exists() or shutil.which("opencode") is not None:
|
|
187
|
+
targets.append(("opencode", self._opencode_skills_dir()))
|
|
188
|
+
|
|
189
|
+
return self._dedupe_targets(targets)
|
|
190
|
+
|
|
191
|
+
def _opencode_config_dir(self) -> Path:
|
|
192
|
+
"""返回 OpenCode 的配置根目录(跨平台)。"""
|
|
193
|
+
if os.name == "nt":
|
|
194
|
+
localappdata = os.environ.get("LOCALAPPDATA", "")
|
|
195
|
+
return Path(localappdata) / "opencode" if localappdata else Path.home() / ".config" / "opencode"
|
|
196
|
+
return Path.home() / ".config" / "opencode"
|
|
197
|
+
|
|
198
|
+
def _opencode_skills_dir(self) -> Path:
|
|
199
|
+
"""返回 OpenCode 的 Skills 安装目录(跨平台)。"""
|
|
200
|
+
return self._opencode_config_dir() / "skills"
|
|
201
|
+
|
|
202
|
+
def _dedupe_targets(self, targets: list[tuple[str, Path]]) -> list[tuple[str, Path]]:
|
|
203
|
+
seen: set[tuple[str, str]] = set()
|
|
204
|
+
result: list[tuple[str, Path]] = []
|
|
205
|
+
for runtime, path in targets:
|
|
206
|
+
key = (runtime, str(path))
|
|
207
|
+
if key in seen:
|
|
208
|
+
continue
|
|
209
|
+
seen.add(key)
|
|
210
|
+
result.append((runtime, path))
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
def _infer_runtime(self, skill_dir: Path) -> str:
|
|
214
|
+
"""根据 Skill 目录路径推断运行时类型。
|
|
215
|
+
|
|
216
|
+
优先匹配最具体的特征路径段,无法识别时默认返回 claude。
|
|
217
|
+
"""
|
|
218
|
+
parts = {part.lower() for part in skill_dir.parts}
|
|
219
|
+
if ".openclaw" in parts:
|
|
220
|
+
return "openclaw"
|
|
221
|
+
if ".codex" in parts:
|
|
222
|
+
return "codex"
|
|
223
|
+
if "opencode" in parts:
|
|
224
|
+
return "opencode"
|
|
225
|
+
return "claude"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""skills 领域模型与异常。"""
|
|
2
|
+
|
|
3
|
+
from opscli.skills.domain.exceptions import SkillRemoteError, SkillsError, error_to_dict
|
|
4
|
+
from opscli.skills.domain.models import (
|
|
5
|
+
SkillBatchInstallResult,
|
|
6
|
+
SkillBatchUpgradeResult,
|
|
7
|
+
SkillInstallResult,
|
|
8
|
+
SkillRecord,
|
|
9
|
+
SkillUpgradeResult,
|
|
10
|
+
runtime_to_tool_name,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SkillsError",
|
|
15
|
+
"SkillRemoteError",
|
|
16
|
+
"error_to_dict",
|
|
17
|
+
"runtime_to_tool_name",
|
|
18
|
+
"SkillRecord",
|
|
19
|
+
"SkillInstallResult",
|
|
20
|
+
"SkillBatchInstallResult",
|
|
21
|
+
"SkillUpgradeResult",
|
|
22
|
+
"SkillBatchUpgradeResult",
|
|
23
|
+
]
|