skillery-cli 0.4.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.
- skillery_cli-0.4.0.dist-info/METADATA +185 -0
- skillery_cli-0.4.0.dist-info/RECORD +59 -0
- skillery_cli-0.4.0.dist-info/WHEEL +4 -0
- skillery_cli-0.4.0.dist-info/entry_points.txt +3 -0
- skillery_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
- skills_hub_cli/__init__.py +2 -0
- skills_hub_cli/__main__.py +2969 -0
- skills_hub_cli/_deprecated_alias.py +22 -0
- skills_hub_cli/commands/__init__.py +6 -0
- skills_hub_cli/commands/_common.py +135 -0
- skills_hub_cli/commands/account.py +294 -0
- skills_hub_cli/commands/analytics.py +198 -0
- skills_hub_cli/commands/collection.py +917 -0
- skills_hub_cli/commands/comment.py +213 -0
- skills_hub_cli/commands/company.py +635 -0
- skills_hub_cli/commands/contrib.py +66 -0
- skills_hub_cli/commands/daemon.py +427 -0
- skills_hub_cli/commands/doctor.py +285 -0
- skills_hub_cli/commands/event.py +190 -0
- skills_hub_cli/commands/member.py +872 -0
- skills_hub_cli/commands/onboard.py +239 -0
- skills_hub_cli/commands/permission.py +180 -0
- skills_hub_cli/commands/rate.py +97 -0
- skills_hub_cli/commands/scaffold.py +326 -0
- skills_hub_cli/commands/suggest.py +393 -0
- skills_hub_cli/commands/ticket.py +332 -0
- skills_hub_cli/config.py +558 -0
- skills_hub_cli/core/__init__.py +0 -0
- skills_hub_cli/core/_kit_config.py +73 -0
- skills_hub_cli/core/agents/__init__.py +25 -0
- skills_hub_cli/core/agents/antigravity.py +4 -0
- skills_hub_cli/core/agents/base.py +4 -0
- skills_hub_cli/core/agents/claude_code.py +4 -0
- skills_hub_cli/core/agents/codex.py +4 -0
- skills_hub_cli/core/agents/detect.py +13 -0
- skills_hub_cli/core/deps_installer.py +16 -0
- skills_hub_cli/core/installer.py +77 -0
- skills_hub_cli/core/linker.py +16 -0
- skills_hub_cli/core/local_collections.py +26 -0
- skills_hub_cli/core/manifest_builder.py +18 -0
- skills_hub_cli/core/mcp_register.py +16 -0
- skills_hub_cli/core/onboarding.py +155 -0
- skills_hub_cli/core/path_store.py +29 -0
- skills_hub_cli/core/project_manifest.py +30 -0
- skills_hub_cli/core/secret_scan.py +345 -0
- skills_hub_cli/core/skill_filter.py +13 -0
- skills_hub_cli/core/suggest.py +131 -0
- skills_hub_cli/core/tooling_install.py +22 -0
- skills_hub_cli/core/transport.py +1448 -0
- skills_hub_cli/daemon/__init__.py +30 -0
- skills_hub_cli/daemon/autostart.py +357 -0
- skills_hub_cli/daemon/backoff.py +48 -0
- skills_hub_cli/daemon/daemon_runner.py +293 -0
- skills_hub_cli/daemon/event_collector.py +196 -0
- skills_hub_cli/daemon/event_guard.py +167 -0
- skills_hub_cli/daemon/event_sender.py +174 -0
- skills_hub_cli/daemon/instrumentation.py +104 -0
- skills_hub_cli/output.py +102 -0
- skills_hub_cli/templates/__init__.py +782 -0
|
@@ -0,0 +1,2969 @@
|
|
|
1
|
+
"""Skillery CLI — модульный typer entrypoint.
|
|
2
|
+
|
|
3
|
+
Команды видны в --help только если у залогиненного пользователя есть
|
|
4
|
+
соответствующий permission в JWT (получен от backend при login).
|
|
5
|
+
|
|
6
|
+
Базовый набор (без auth): login, set-tokens, status.
|
|
7
|
+
После login → добавляются list/show/install/update/report (по permissions).
|
|
8
|
+
Для skill-creator → publish.
|
|
9
|
+
Для hub-admin / company-admin → admin sub-app с подкомандами.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from clikit.command_kit import build_root_app, gated
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.json import JSON as RichJSON
|
|
25
|
+
from rich.prompt import Prompt
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
|
|
28
|
+
from skills_hub_cli.config import (
|
|
29
|
+
ClientConfig,
|
|
30
|
+
clear_tokens,
|
|
31
|
+
load_tokens,
|
|
32
|
+
populate_from_jwt,
|
|
33
|
+
save_tokens,
|
|
34
|
+
set_active_profile,
|
|
35
|
+
)
|
|
36
|
+
from skills_hub_cli.core import linker, project_manifest, tooling_install
|
|
37
|
+
from skills_hub_cli.core.agents import detect_agent, get_target
|
|
38
|
+
from skills_hub_cli.core.installer import SkillInstaller, read_meta
|
|
39
|
+
from skills_hub_cli.core.manifest_builder import build_manifest, git_commit_sha
|
|
40
|
+
from skills_hub_cli.core.secret_scan import scan_dir as secret_scan_dir
|
|
41
|
+
from skills_hub_cli.daemon.instrumentation import track_skill_event
|
|
42
|
+
from skills_hub_cli.core.transport import ApiError, HubClient
|
|
43
|
+
from skills_hub_cli.commands._common import hydrate_session_permissions
|
|
44
|
+
from skills_hub_cli.output import (
|
|
45
|
+
emit_data,
|
|
46
|
+
emit_error,
|
|
47
|
+
emit_message,
|
|
48
|
+
init_output_mode,
|
|
49
|
+
is_json,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
console = Console()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------- pre-pass argv: --profile / --json активируются ДО построения app ----------
|
|
56
|
+
def _parse_profile_arg() -> Optional[str]:
|
|
57
|
+
for i, a in enumerate(sys.argv):
|
|
58
|
+
if a in ("--profile", "-P") and i + 1 < len(sys.argv):
|
|
59
|
+
return sys.argv[i + 1]
|
|
60
|
+
if a.startswith("--profile="):
|
|
61
|
+
return a.split("=", 1)[1]
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_json_flag() -> bool:
|
|
66
|
+
return "--json" in sys.argv or "-J" in sys.argv
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
set_active_profile(_parse_profile_arg())
|
|
70
|
+
_cfg_for_output = ClientConfig.load()
|
|
71
|
+
init_output_mode(
|
|
72
|
+
json_flag=_parse_json_flag(),
|
|
73
|
+
config_format=_cfg_for_output.output_format,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------- helpers ----------
|
|
78
|
+
def _run(coro) -> None: # noqa: ANN001
|
|
79
|
+
"""Запуск async-команды с ЕДИНЫМ контрактом ошибок.
|
|
80
|
+
|
|
81
|
+
json-режим: ожидаемые ошибки (ApiError / RuntimeError) → emit_error =
|
|
82
|
+
{"event":"error","code":...,"message":...} одной JSON-строкой в stderr,
|
|
83
|
+
stdout не засоряется plain-текстом, traceback не печатается.
|
|
84
|
+
text-режим: прежнее читабельное «Ошибка API: ...» (ApiError) /
|
|
85
|
+
«RUNTIME: ...» (RuntimeError). В обоих случаях exit 1.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
asyncio.run(coro)
|
|
89
|
+
except ApiError as e:
|
|
90
|
+
if is_json():
|
|
91
|
+
emit_error(e.code or "API", e.message or str(e), status_code=e.status_code)
|
|
92
|
+
else:
|
|
93
|
+
console.print(f"[red]Ошибка API:[/] {e}")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
except (typer.Exit, typer.Abort):
|
|
96
|
+
# click.exceptions.Exit/Abort наследуют RuntimeError — это штатное
|
|
97
|
+
# завершение команды (emit_error уже сделан), пропускаем насквозь.
|
|
98
|
+
raise
|
|
99
|
+
except RuntimeError as e:
|
|
100
|
+
# Например installer._clone_version: RuntimeError('git clone failed: ...')
|
|
101
|
+
# — короткое сообщение вместо многоэкранного Rich-traceback.
|
|
102
|
+
emit_error("RUNTIME", str(e))
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _strip_invite_url(value: str) -> str:
|
|
107
|
+
m = re.match(r".*/(?:auth/)?invite/([A-Za-z0-9_\-]+)/?$", value)
|
|
108
|
+
if m:
|
|
109
|
+
return m.group(1)
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_access_token() -> str:
|
|
114
|
+
cfg = ClientConfig.load()
|
|
115
|
+
if not cfg.user_email:
|
|
116
|
+
console.print("[red]Не авторизован.[/] Сначала: skillery login <invite>")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
access, _ = load_tokens(cfg.user_email)
|
|
119
|
+
if not access:
|
|
120
|
+
console.print("[red]Локальный access-токен не найден.[/] Сделайте login заново.")
|
|
121
|
+
raise typer.Exit(1)
|
|
122
|
+
return access
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _make_refresh_callback(cfg: ClientConfig) -> object:
|
|
126
|
+
"""Возвращает callback который CLI передаёт в HubClient.
|
|
127
|
+
|
|
128
|
+
При 401 HubClient вызывает callback → callback дёргает /auth/refresh
|
|
129
|
+
с сохранённым refresh-токеном, получает новый pair, сохраняет в keyring,
|
|
130
|
+
возвращает (access, refresh). Если refresh не сработал — None и
|
|
131
|
+
пользователю показывается 401-ошибка как обычно.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
async def _refresh() -> tuple[str, str] | None:
|
|
135
|
+
if not cfg.user_email:
|
|
136
|
+
return None
|
|
137
|
+
_, refresh = load_tokens(cfg.user_email)
|
|
138
|
+
if not refresh:
|
|
139
|
+
return None
|
|
140
|
+
sub = HubClient(base_url=cfg.base_url, access_token=None)
|
|
141
|
+
try:
|
|
142
|
+
data = await sub.refresh(refresh)
|
|
143
|
+
except ApiError:
|
|
144
|
+
return None
|
|
145
|
+
finally:
|
|
146
|
+
await sub.close()
|
|
147
|
+
new_access = data["access_token"]
|
|
148
|
+
new_refresh = data["refresh_token"]
|
|
149
|
+
save_tokens(cfg.user_email, new_access, new_refresh)
|
|
150
|
+
populate_from_jwt(cfg, new_access)
|
|
151
|
+
cfg.save()
|
|
152
|
+
return (new_access, new_refresh)
|
|
153
|
+
|
|
154
|
+
return _refresh
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_version(raw: str) -> tuple[int, ...] | None:
|
|
158
|
+
"""Парсит semver-подобную строку в кортеж int-сегментов для сравнения.
|
|
159
|
+
|
|
160
|
+
Толерантно к:
|
|
161
|
+
- префиксу ``v``/``V`` (``v1.2.3`` → ``(1, 2, 3)``);
|
|
162
|
+
- нечисловым хвостам/pre-release (``1.2.0-rc1`` → ``(1, 2, 0)`` — берём
|
|
163
|
+
только ведущие числовые сегменты);
|
|
164
|
+
Возвращает ``None``, если ни одного числового сегмента распарсить нельзя
|
|
165
|
+
(вызывающий код тогда падает на строковое сравнение ``!=``).
|
|
166
|
+
"""
|
|
167
|
+
s = raw.strip()
|
|
168
|
+
if s[:1] in ("v", "V"):
|
|
169
|
+
s = s[1:]
|
|
170
|
+
parts: list[int] = []
|
|
171
|
+
for seg in s.split("."):
|
|
172
|
+
m = re.match(r"\d+", seg.strip())
|
|
173
|
+
if not m:
|
|
174
|
+
break # первый не-числовой сегмент обрывает разбор (хвост игнор)
|
|
175
|
+
parts.append(int(m.group()))
|
|
176
|
+
return tuple(parts) if parts else None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_newer(candidate: str, current: str) -> bool:
|
|
180
|
+
"""True, только если ``candidate`` СТРОГО новее ``current`` (semver-like).
|
|
181
|
+
|
|
182
|
+
Баг B8: раньше апдейт гейтился ``bundle["version"] != current`` — downgrade
|
|
183
|
+
воспринимался как апдейт. Сравниваем числовые сегменты; недостающие
|
|
184
|
+
сегменты добиваются нулями (``1.2`` == ``1.2.0``). Если хотя бы одна из
|
|
185
|
+
версий нераспарсиваема — fallback на строковое ``!=`` (не хуже прежнего
|
|
186
|
+
поведения, но и не лучше — зато не маскирует баг для валидных версий).
|
|
187
|
+
"""
|
|
188
|
+
cand = _parse_version(candidate)
|
|
189
|
+
cur = _parse_version(current)
|
|
190
|
+
if cand is None or cur is None:
|
|
191
|
+
return candidate != current
|
|
192
|
+
width = max(len(cand), len(cur))
|
|
193
|
+
cand_p = cand + (0,) * (width - len(cand))
|
|
194
|
+
cur_p = cur + (0,) * (width - len(cur))
|
|
195
|
+
return cand_p > cur_p
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _maybe_auto_update(cfg: ClientConfig) -> None:
|
|
199
|
+
"""Тихо обновляет установленные skills до latest published если cooldown прошёл."""
|
|
200
|
+
if not cfg.auto_update or not cfg.is_logged_in():
|
|
201
|
+
return
|
|
202
|
+
cooldown = timedelta(minutes=cfg.auto_update_cooldown_min)
|
|
203
|
+
if cfg.last_auto_update_at:
|
|
204
|
+
try:
|
|
205
|
+
last = datetime.fromisoformat(cfg.last_auto_update_at)
|
|
206
|
+
if datetime.now(UTC) - last < cooldown:
|
|
207
|
+
return
|
|
208
|
+
except ValueError:
|
|
209
|
+
pass
|
|
210
|
+
target = get_target(cfg.agent)
|
|
211
|
+
base = target.base_dir()
|
|
212
|
+
if not base.exists():
|
|
213
|
+
return
|
|
214
|
+
installed: list[tuple[str, str]] = []
|
|
215
|
+
for slug_dir in base.iterdir():
|
|
216
|
+
meta = read_meta(slug_dir)
|
|
217
|
+
if meta:
|
|
218
|
+
installed.append((meta["slug"], meta.get("version", "0.0.0")))
|
|
219
|
+
if not installed:
|
|
220
|
+
cfg.last_auto_update_at = datetime.now(UTC).isoformat()
|
|
221
|
+
cfg.save()
|
|
222
|
+
return
|
|
223
|
+
access, _ = load_tokens(cfg.user_email or "")
|
|
224
|
+
if not access:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
# Прогресс auto-update — ТОЛЬКО в stderr: stdout — машинный канал
|
|
228
|
+
# (--json), его нельзя засорять (живой факт: строка «↑ auto-update»
|
|
229
|
+
# ломала парсинг JSON-вывода).
|
|
230
|
+
err_console = Console(stderr=True)
|
|
231
|
+
|
|
232
|
+
async def _do() -> None:
|
|
233
|
+
client = HubClient(base_url=cfg.base_url, access_token=access, on_token_refresh=_make_refresh_callback(cfg))
|
|
234
|
+
try:
|
|
235
|
+
for slug, current in installed:
|
|
236
|
+
try:
|
|
237
|
+
bundle = await client.install_bundle(slug)
|
|
238
|
+
except Exception:
|
|
239
|
+
continue
|
|
240
|
+
# P0: bundle без repo_url = stub-источник — обновлять нечем
|
|
241
|
+
# (живой инцидент: такой «апдейт» затирал реальный контент
|
|
242
|
+
# 112-байтовым stub'ом). Пропускаем кандидата целиком.
|
|
243
|
+
if not bundle.get("repo_url"):
|
|
244
|
+
continue
|
|
245
|
+
# B8: апдейтим ТОЛЬКО если опубликованная версия строго новее
|
|
246
|
+
# установленной — downgrade/равные пропускаем.
|
|
247
|
+
if _is_newer(bundle["version"], current):
|
|
248
|
+
installer = SkillInstaller(target, cfg.effective_store_dir())
|
|
249
|
+
res = installer.install(
|
|
250
|
+
slug=slug,
|
|
251
|
+
version=bundle["version"],
|
|
252
|
+
commit_sha=bundle["commit_sha"],
|
|
253
|
+
repo_url=bundle.get("repo_url"),
|
|
254
|
+
skill_path=bundle.get("skill_path"),
|
|
255
|
+
manifest=bundle["manifest"],
|
|
256
|
+
)
|
|
257
|
+
if getattr(res, "skipped", False):
|
|
258
|
+
continue # guard отказал (stub-would-clobber и т.п.)
|
|
259
|
+
# gap A: контент обновили — обязаны переустановить tooling
|
|
260
|
+
# (runtime_deps/CLI/MCP) под манифест НОВОЙ версии, иначе
|
|
261
|
+
# шимы/зависимости остаются от старой. global scope →
|
|
262
|
+
# project=None; manifest = bundle["manifest"].
|
|
263
|
+
_apply_tooling(
|
|
264
|
+
res, bundle["manifest"], agent_target=target, project=None
|
|
265
|
+
)
|
|
266
|
+
err_console.print(
|
|
267
|
+
f"[dim cyan]↑ auto-update[/] {slug}: {current} → {bundle['version']}"
|
|
268
|
+
)
|
|
269
|
+
# Cooldown-таймстамп двигаем всегда после успешного прохода (даже
|
|
270
|
+
# если ничего не обновилось) — иначе фон-проверка зациклится без
|
|
271
|
+
# учёта cooldown. Раньше это маскировал мёртвый `if … or True`.
|
|
272
|
+
cfg.last_auto_update_at = datetime.now(UTC).isoformat()
|
|
273
|
+
cfg.save()
|
|
274
|
+
finally:
|
|
275
|
+
await client.close()
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
asyncio.run(_do())
|
|
279
|
+
except Exception:
|
|
280
|
+
pass # auto-update не должен ломать команду
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ======================================================
|
|
284
|
+
# COMMAND IMPLEMENTATIONS
|
|
285
|
+
# ======================================================
|
|
286
|
+
def cmd_login(
|
|
287
|
+
invite: Optional[str] = typer.Argument(
|
|
288
|
+
None,
|
|
289
|
+
help=(
|
|
290
|
+
"Invite-токен или URL (для invite-flow). Опускайте если хотите "
|
|
291
|
+
"залогиниться через --email/--password."
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
email: Optional[str] = typer.Option(None),
|
|
295
|
+
name: Optional[str] = typer.Option(None),
|
|
296
|
+
password: Optional[str] = typer.Option(
|
|
297
|
+
None,
|
|
298
|
+
"--password",
|
|
299
|
+
help=(
|
|
300
|
+
"Если указан и invite опущен → POST /auth/login (email + password). "
|
|
301
|
+
"Если только --email указан без --password — пароль запрошен интерактивно."
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
base_url: Optional[str] = typer.Option(None),
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Логин: либо invite-token (invite-flow), либо email + password.
|
|
307
|
+
|
|
308
|
+
Если передан positional `invite` — invite-flow (как раньше). Если invite
|
|
309
|
+
опущен — email+password flow (POST /auth/login).
|
|
310
|
+
"""
|
|
311
|
+
cfg = ClientConfig.load()
|
|
312
|
+
if base_url:
|
|
313
|
+
cfg.base_url = base_url
|
|
314
|
+
|
|
315
|
+
if invite is None:
|
|
316
|
+
# --- email + password flow ---
|
|
317
|
+
if email is None:
|
|
318
|
+
if is_json():
|
|
319
|
+
emit_error(
|
|
320
|
+
"VALIDATION",
|
|
321
|
+
"В json-режиме --email обязателен для password-flow",
|
|
322
|
+
)
|
|
323
|
+
raise typer.Exit(1)
|
|
324
|
+
email = Prompt.ask("Email")
|
|
325
|
+
if password is None:
|
|
326
|
+
if is_json():
|
|
327
|
+
emit_error(
|
|
328
|
+
"VALIDATION",
|
|
329
|
+
"В json-режиме --password обязателен",
|
|
330
|
+
)
|
|
331
|
+
raise typer.Exit(1)
|
|
332
|
+
password = typer.prompt("Пароль", hide_input=True)
|
|
333
|
+
_do_password_login(cfg, email=email, password=password)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
# --- invite-flow (как раньше) ---
|
|
337
|
+
if email is None:
|
|
338
|
+
email = "" if is_json() else Prompt.ask("Email")
|
|
339
|
+
if name is None:
|
|
340
|
+
name = "" if is_json() else Prompt.ask("Имя для отображения")
|
|
341
|
+
token = _strip_invite_url(invite)
|
|
342
|
+
|
|
343
|
+
async def _do() -> None:
|
|
344
|
+
client = HubClient(base_url=cfg.base_url)
|
|
345
|
+
try:
|
|
346
|
+
data = await client.login_invite(
|
|
347
|
+
invite_token=token, email=email, display_name=name
|
|
348
|
+
)
|
|
349
|
+
# JWT-slim: права — из /me/permissions (токен их не несёт).
|
|
350
|
+
await hydrate_session_permissions(client, cfg, data["access_token"])
|
|
351
|
+
finally:
|
|
352
|
+
await client.close()
|
|
353
|
+
save_tokens(email, data["access_token"], data["refresh_token"])
|
|
354
|
+
cfg.user_email = email
|
|
355
|
+
populate_from_jwt(cfg, data["access_token"])
|
|
356
|
+
cfg.save()
|
|
357
|
+
result = {
|
|
358
|
+
"event": "logged_in",
|
|
359
|
+
"user_email": email,
|
|
360
|
+
"is_new_user": bool(data.get("is_new_user")),
|
|
361
|
+
"is_hub_admin": cfg.is_hub_admin(),
|
|
362
|
+
"is_skill_creator": cfg.is_skill_creator(),
|
|
363
|
+
"permissions": cfg.permissions,
|
|
364
|
+
"company_id": cfg.company_id,
|
|
365
|
+
"role_id": cfg.role_id,
|
|
366
|
+
"access_expires_at": cfg.access_expires_at,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
def _render(_: dict) -> None:
|
|
370
|
+
console.print(f"[green]✓[/] Авторизован как {email}")
|
|
371
|
+
if data.get("is_new_user"):
|
|
372
|
+
console.print(" (новый пользователь, аккаунт создан)")
|
|
373
|
+
roles_descr = []
|
|
374
|
+
if cfg.is_hub_admin():
|
|
375
|
+
roles_descr.append("hub-admin")
|
|
376
|
+
if cfg.is_skill_creator():
|
|
377
|
+
roles_descr.append("skill-creator")
|
|
378
|
+
if cfg.permissions and not roles_descr:
|
|
379
|
+
roles_descr.append("member")
|
|
380
|
+
console.print(f" Роли: {', '.join(roles_descr) or '—'}")
|
|
381
|
+
console.print(f" Permissions: {len(cfg.permissions)} прав")
|
|
382
|
+
console.print(
|
|
383
|
+
"[dim]Доступные команды зависят от прав — `skillery --help`[/]"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
emit_data(result, text_renderer=_render)
|
|
387
|
+
|
|
388
|
+
_run(_do())
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _do_password_login(cfg: ClientConfig, *, email: str, password: str) -> None:
|
|
392
|
+
async def _do() -> None:
|
|
393
|
+
client = HubClient(base_url=cfg.base_url)
|
|
394
|
+
try:
|
|
395
|
+
data = await client.login_password(email=email, password=password)
|
|
396
|
+
# JWT-slim: токен не несёт прав — забираем эффективные из
|
|
397
|
+
# /me/permissions тем же (теперь авторизованным) клиентом.
|
|
398
|
+
await hydrate_session_permissions(client, cfg, data["access_token"])
|
|
399
|
+
finally:
|
|
400
|
+
await client.close()
|
|
401
|
+
save_tokens(email, data["access_token"], data["refresh_token"])
|
|
402
|
+
cfg.user_email = email
|
|
403
|
+
populate_from_jwt(cfg, data["access_token"])
|
|
404
|
+
cfg.save()
|
|
405
|
+
result = {
|
|
406
|
+
"event": "logged_in",
|
|
407
|
+
"method": "password",
|
|
408
|
+
"user_email": email,
|
|
409
|
+
"is_hub_admin": cfg.is_hub_admin(),
|
|
410
|
+
"is_skill_creator": cfg.is_skill_creator(),
|
|
411
|
+
"permissions": cfg.permissions,
|
|
412
|
+
"company_id": cfg.company_id,
|
|
413
|
+
"role_id": cfg.role_id,
|
|
414
|
+
"access_expires_at": cfg.access_expires_at,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
def _render(_: dict) -> None:
|
|
418
|
+
console.print(f"[green]✓[/] Авторизован как {email} (password)")
|
|
419
|
+
roles_descr = []
|
|
420
|
+
if cfg.is_hub_admin():
|
|
421
|
+
roles_descr.append("hub-admin")
|
|
422
|
+
if cfg.is_skill_creator():
|
|
423
|
+
roles_descr.append("skill-creator")
|
|
424
|
+
if cfg.permissions and not roles_descr:
|
|
425
|
+
roles_descr.append("member")
|
|
426
|
+
console.print(f" Роли: {', '.join(roles_descr) or '—'}")
|
|
427
|
+
console.print(f" Permissions: {len(cfg.permissions)} прав")
|
|
428
|
+
|
|
429
|
+
emit_data(result, text_renderer=_render)
|
|
430
|
+
|
|
431
|
+
_run(_do())
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cmd_passwd() -> None:
|
|
435
|
+
"""Сменить (или установить) пароль текущего user'а.
|
|
436
|
+
|
|
437
|
+
Требует залогиненную сессию. Запрашивает новый пароль интерактивно,
|
|
438
|
+
с подтверждением. В JSON-режиме — необходима env-переменная
|
|
439
|
+
SKILLS_HUB_NEW_PASSWORD (для скриптов).
|
|
440
|
+
"""
|
|
441
|
+
cfg = ClientConfig.load()
|
|
442
|
+
if not cfg.is_logged_in():
|
|
443
|
+
emit_error("NOT_LOGGED_IN", "Сначала залогиньтесь: skillery login")
|
|
444
|
+
raise typer.Exit(1)
|
|
445
|
+
access, _ = load_tokens(cfg.user_email or "")
|
|
446
|
+
if not access:
|
|
447
|
+
emit_error("NO_TOKEN", "Токен не найден. Сделайте login заново.")
|
|
448
|
+
raise typer.Exit(1)
|
|
449
|
+
|
|
450
|
+
if is_json():
|
|
451
|
+
new_pw = os.environ.get("SKILLS_HUB_NEW_PASSWORD")
|
|
452
|
+
if not new_pw:
|
|
453
|
+
emit_error(
|
|
454
|
+
"VALIDATION",
|
|
455
|
+
"В json-режиме новый пароль через env SKILLS_HUB_NEW_PASSWORD",
|
|
456
|
+
)
|
|
457
|
+
raise typer.Exit(1)
|
|
458
|
+
else:
|
|
459
|
+
new_pw = typer.prompt("Новый пароль", hide_input=True)
|
|
460
|
+
confirm = typer.prompt("Повторите пароль", hide_input=True)
|
|
461
|
+
if new_pw != confirm:
|
|
462
|
+
emit_error("VALIDATION", "Пароли не совпадают")
|
|
463
|
+
raise typer.Exit(1)
|
|
464
|
+
|
|
465
|
+
if len(new_pw) < 8:
|
|
466
|
+
emit_error("VALIDATION", "Пароль должен быть не короче 8 символов")
|
|
467
|
+
raise typer.Exit(1)
|
|
468
|
+
|
|
469
|
+
async def _do() -> None:
|
|
470
|
+
client = HubClient(
|
|
471
|
+
base_url=cfg.base_url,
|
|
472
|
+
access_token=access,
|
|
473
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
474
|
+
)
|
|
475
|
+
try:
|
|
476
|
+
await client.set_password(new_password=new_pw)
|
|
477
|
+
finally:
|
|
478
|
+
await client.close()
|
|
479
|
+
emit_data(
|
|
480
|
+
{"event": "password_changed"},
|
|
481
|
+
text_renderer=lambda _: console.print(
|
|
482
|
+
"[green]✓[/] Пароль обновлён. Теперь логин: "
|
|
483
|
+
f"`skillery login --email {cfg.user_email} --password ***`"
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
_run(_do())
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def cmd_logout() -> None:
|
|
491
|
+
"""Очистить локальные токены и permissions."""
|
|
492
|
+
cfg = ClientConfig.load()
|
|
493
|
+
if cfg.user_email:
|
|
494
|
+
clear_tokens(cfg.user_email)
|
|
495
|
+
cfg.user_email = None
|
|
496
|
+
cfg.permissions = []
|
|
497
|
+
cfg.company_id = None
|
|
498
|
+
cfg.role_id = None
|
|
499
|
+
cfg.access_expires_at = None
|
|
500
|
+
cfg.save()
|
|
501
|
+
emit_data(
|
|
502
|
+
{"event": "logged_out"},
|
|
503
|
+
text_renderer=lambda _: console.print(
|
|
504
|
+
"[green]✓[/] Вышли. Доступна только команда login."
|
|
505
|
+
),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def cmd_whoami() -> None:
|
|
510
|
+
"""Кто я и что доступно."""
|
|
511
|
+
cfg = ClientConfig.load()
|
|
512
|
+
if not cfg.user_email:
|
|
513
|
+
emit_error("NOT_AUTHENTICATED", "Не авторизован")
|
|
514
|
+
raise typer.Exit(1)
|
|
515
|
+
payload = {
|
|
516
|
+
"user_email": cfg.user_email,
|
|
517
|
+
"backend": cfg.base_url,
|
|
518
|
+
"agent": cfg.agent or detect_agent(),
|
|
519
|
+
"is_hub_admin": cfg.is_hub_admin(),
|
|
520
|
+
"is_skill_creator": cfg.is_skill_creator(),
|
|
521
|
+
"company_id": cfg.company_id,
|
|
522
|
+
"role_id": cfg.role_id,
|
|
523
|
+
"permissions": cfg.permissions,
|
|
524
|
+
"auto_update": cfg.auto_update,
|
|
525
|
+
"output_format": cfg.output_format,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
def _render(p: dict) -> None:
|
|
529
|
+
console.print(f"[bold]{p['user_email']}[/]")
|
|
530
|
+
console.print(f" Backend: {p['backend']}")
|
|
531
|
+
console.print(f" Agent: {p['agent']}")
|
|
532
|
+
roles = []
|
|
533
|
+
if p["is_hub_admin"]:
|
|
534
|
+
roles.append("hub-admin")
|
|
535
|
+
if p["is_skill_creator"]:
|
|
536
|
+
roles.append("skill-creator")
|
|
537
|
+
if p["company_id"]:
|
|
538
|
+
# PK-миграция: company_id теперь числовой id (строкой), обрезка
|
|
539
|
+
# бессмысленна — показываем полностью.
|
|
540
|
+
roles.append(f"company={p['company_id']}")
|
|
541
|
+
console.print(f" Роли: {', '.join(roles) or 'member'}")
|
|
542
|
+
console.print(
|
|
543
|
+
f" Permissions ({len(p['permissions'])}): {', '.join(p['permissions']) or '—'}"
|
|
544
|
+
)
|
|
545
|
+
console.print(f" Auto-update: {'on' if p['auto_update'] else 'off'}")
|
|
546
|
+
console.print(f" Output: {p['output_format']}")
|
|
547
|
+
|
|
548
|
+
emit_data(payload, text_renderer=_render)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _render_web_text(url: str, expires_at: str, no_browser: bool) -> None:
|
|
552
|
+
console.print(f"[bold green]Открываю Web UI:[/bold green] {url}")
|
|
553
|
+
console.print(f"[dim]Код действует до:[/dim] {expires_at}")
|
|
554
|
+
if no_browser:
|
|
555
|
+
console.print(
|
|
556
|
+
"[yellow]Браузер не открыт (флаг --no-browser). "
|
|
557
|
+
"Откройте URL вручную.[/yellow]"
|
|
558
|
+
)
|
|
559
|
+
else:
|
|
560
|
+
console.print(
|
|
561
|
+
"[dim]Если браузер не открылся автоматически — "
|
|
562
|
+
"скопируйте URL вручную.[/dim]"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def cmd_web(
|
|
567
|
+
no_browser: bool = typer.Option(
|
|
568
|
+
False, "--no-browser", help="Не открывать браузер, только напечатать URL"
|
|
569
|
+
),
|
|
570
|
+
) -> None:
|
|
571
|
+
"""Открыть Web UI в браузере с автоматической авторизацией (handoff из CLI).
|
|
572
|
+
|
|
573
|
+
В режиме `--json` браузер НЕ открывается автоматически — JSON режим
|
|
574
|
+
считается scripting-режимом (subagent / CI), вывод используется
|
|
575
|
+
программно. Чтобы всё-таки открыть из JSON-режима — пользуйся
|
|
576
|
+
`opened_browser` полем в payload и обработай его на стороне caller'а.
|
|
577
|
+
"""
|
|
578
|
+
cfg = ClientConfig.load()
|
|
579
|
+
if not cfg.is_logged_in():
|
|
580
|
+
emit_error(
|
|
581
|
+
"NOT_LOGGED_IN",
|
|
582
|
+
"Сначала залогиньтесь: skillery login <invite-token>",
|
|
583
|
+
)
|
|
584
|
+
raise typer.Exit(1)
|
|
585
|
+
access, _refresh = load_tokens(cfg.user_email or "")
|
|
586
|
+
if not access:
|
|
587
|
+
emit_error("NO_TOKEN", "Токен не найден. Сделайте login заново.")
|
|
588
|
+
raise typer.Exit(1)
|
|
589
|
+
|
|
590
|
+
async def _do() -> dict[str, str]:
|
|
591
|
+
client = HubClient(
|
|
592
|
+
base_url=cfg.base_url,
|
|
593
|
+
access_token=access,
|
|
594
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
595
|
+
)
|
|
596
|
+
try:
|
|
597
|
+
return await client.exchange_create()
|
|
598
|
+
finally:
|
|
599
|
+
await client.close()
|
|
600
|
+
|
|
601
|
+
result = asyncio.run(_do())
|
|
602
|
+
code = result["code"]
|
|
603
|
+
expires_at = result["expires_at"]
|
|
604
|
+
web_base = cfg.effective_web_ui_url().rstrip("/")
|
|
605
|
+
target_url = f"{web_base}/login?code={code}"
|
|
606
|
+
|
|
607
|
+
# Auto-suppress browser в JSON-режиме (scripting / subagent context).
|
|
608
|
+
effective_no_browser = no_browser or is_json()
|
|
609
|
+
|
|
610
|
+
emit_data(
|
|
611
|
+
{
|
|
612
|
+
"url": target_url,
|
|
613
|
+
"code": code,
|
|
614
|
+
"expires_at": expires_at,
|
|
615
|
+
"web_base_url": web_base,
|
|
616
|
+
"opened_browser": not effective_no_browser,
|
|
617
|
+
},
|
|
618
|
+
text_renderer=lambda _: _render_web_text(
|
|
619
|
+
target_url, expires_at, effective_no_browser
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
if not effective_no_browser:
|
|
624
|
+
import webbrowser
|
|
625
|
+
|
|
626
|
+
webbrowser.open(target_url)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def cmd_status(
|
|
630
|
+
project: Optional[Path] = typer.Option(None, "--project"),
|
|
631
|
+
) -> None:
|
|
632
|
+
"""Локальный статус: agent + что установлено в global + project scope."""
|
|
633
|
+
cfg = ClientConfig.load()
|
|
634
|
+
target = get_target(cfg.agent)
|
|
635
|
+
actual_project = (
|
|
636
|
+
project
|
|
637
|
+
or (Path(cfg.default_project_dir) if cfg.default_project_dir else None)
|
|
638
|
+
or Path.cwd()
|
|
639
|
+
)
|
|
640
|
+
global_items = _scan_installed(target, project=None)
|
|
641
|
+
project_items = _scan_installed(target, project=actual_project)
|
|
642
|
+
payload = {
|
|
643
|
+
"agent": target.name,
|
|
644
|
+
"global_skills_dir": str(target.base_dir()),
|
|
645
|
+
"project_skills_dir": str(target.base_dir(project=actual_project)),
|
|
646
|
+
"project_root": str(actual_project),
|
|
647
|
+
"user_email": cfg.user_email,
|
|
648
|
+
"logged_in": bool(cfg.user_email),
|
|
649
|
+
"default_install_scope": cfg.default_install_scope,
|
|
650
|
+
"installed_global": global_items,
|
|
651
|
+
"installed_project": project_items,
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
def _render(p: dict) -> None:
|
|
655
|
+
console.print(f"Agent: [bold]{p['agent']}[/]")
|
|
656
|
+
console.print(f"Global dir: {p['global_skills_dir']}")
|
|
657
|
+
console.print(f"Project root: {p['project_root']}")
|
|
658
|
+
console.print(f"Project dir: {p['project_skills_dir']}")
|
|
659
|
+
console.print(f"Default scope: {p['default_install_scope']}")
|
|
660
|
+
if p["user_email"]:
|
|
661
|
+
console.print(f"User: {p['user_email']}")
|
|
662
|
+
else:
|
|
663
|
+
console.print("[yellow]Не авторизован[/]")
|
|
664
|
+
console.print(
|
|
665
|
+
f"Installed: global={len(p['installed_global'])} "
|
|
666
|
+
f"project={len(p['installed_project'])}"
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
emit_data(payload, text_renderer=_render)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def cmd_set_tokens(
|
|
673
|
+
email: str = typer.Argument(...),
|
|
674
|
+
access: str = typer.Option(..., help="Access JWT"),
|
|
675
|
+
refresh: str = typer.Option(..., help="Refresh token"),
|
|
676
|
+
base_url: Optional[str] = typer.Option(None),
|
|
677
|
+
) -> None:
|
|
678
|
+
"""[ADVANCED] Положить токены напрямую (минуя backend login)."""
|
|
679
|
+
cfg = ClientConfig.load()
|
|
680
|
+
if base_url:
|
|
681
|
+
cfg.base_url = base_url
|
|
682
|
+
save_tokens(email, access, refresh)
|
|
683
|
+
cfg.user_email = email
|
|
684
|
+
populate_from_jwt(cfg, access)
|
|
685
|
+
|
|
686
|
+
# JWT-slim: права не в токене — забираем из /me/permissions. [ADVANCED]-
|
|
687
|
+
# команда offline-толерантна: недостижимый backend → пустые права (не валим).
|
|
688
|
+
async def _hydrate() -> None:
|
|
689
|
+
client = HubClient(base_url=cfg.base_url)
|
|
690
|
+
try:
|
|
691
|
+
await hydrate_session_permissions(client, cfg, access)
|
|
692
|
+
finally:
|
|
693
|
+
await client.close()
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
asyncio.run(_hydrate())
|
|
697
|
+
except Exception: # noqa: BLE001 — offline-tolerant advanced command
|
|
698
|
+
cfg.permissions = []
|
|
699
|
+
|
|
700
|
+
cfg.save()
|
|
701
|
+
emit_data(
|
|
702
|
+
{
|
|
703
|
+
"event": "tokens_saved",
|
|
704
|
+
"user_email": email,
|
|
705
|
+
"permissions": cfg.permissions,
|
|
706
|
+
},
|
|
707
|
+
text_renderer=lambda p: console.print(
|
|
708
|
+
f"[green]✓[/] Токены и permissions сохранены для {p['user_email']}"
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _scan_installed(
|
|
714
|
+
target, # type: ignore[no-untyped-def]
|
|
715
|
+
*,
|
|
716
|
+
project: Path | None,
|
|
717
|
+
) -> list[dict]:
|
|
718
|
+
"""Сканирует scope-папку, возвращает meta + признак ссылки (linked/copied)."""
|
|
719
|
+
base = target.base_dir(project=project)
|
|
720
|
+
items: list[dict] = []
|
|
721
|
+
if base.exists():
|
|
722
|
+
for d in base.iterdir():
|
|
723
|
+
meta = read_meta(d)
|
|
724
|
+
if meta:
|
|
725
|
+
ref = meta.get("slug") or meta.get("skill_id") or d.name
|
|
726
|
+
linked = linker.is_link(d)
|
|
727
|
+
tgt = linker.link_target(d) if linked else None
|
|
728
|
+
items.append({
|
|
729
|
+
"slug": meta.get("slug"),
|
|
730
|
+
"skill_id": meta.get("skill_id"),
|
|
731
|
+
"ref": ref,
|
|
732
|
+
"version": meta.get("version"),
|
|
733
|
+
"commit_sha": meta.get("commit_sha"),
|
|
734
|
+
"agent": meta.get("agent"),
|
|
735
|
+
"scope": "project" if project else "global",
|
|
736
|
+
"project": str(project) if project else None,
|
|
737
|
+
"path": str(d),
|
|
738
|
+
"linked": linked,
|
|
739
|
+
"link_target": str(tgt) if tgt else None,
|
|
740
|
+
})
|
|
741
|
+
return items
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _unwrap_list_payload(payload: object) -> list:
|
|
745
|
+
"""Развернуть ответ листинга в список строк, устойчиво к форме.
|
|
746
|
+
|
|
747
|
+
Backend может отдавать envelope ``{"items": [...]}`` (W5-пагинация) или,
|
|
748
|
+
исторически, голый ``list``. Любая другая/грязная форма (``items: null``,
|
|
749
|
+
не-список) → ``[]``, чтобы текстовый рендер не падал ``TypeError``.
|
|
750
|
+
"""
|
|
751
|
+
rows = payload.get("items") if isinstance(payload, dict) else payload
|
|
752
|
+
return rows if isinstance(rows, list) else []
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _fmt_tag_labels(raw: object) -> str:
|
|
756
|
+
"""«label, label» из tag-ref'ов. Робастно к ``None`` и не-dict-элементам."""
|
|
757
|
+
items = raw if isinstance(raw, list) else []
|
|
758
|
+
return ", ".join(
|
|
759
|
+
t.get("label", "") if isinstance(t, dict) else str(t) for t in items
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _fmt_version_semvers(raw: object) -> str:
|
|
764
|
+
"""«semver, semver» из версий. Робастно к ``None`` и не-dict-элементам."""
|
|
765
|
+
items = raw if isinstance(raw, list) else []
|
|
766
|
+
return ", ".join(
|
|
767
|
+
str(v.get("semver", "")) if isinstance(v, dict) else str(v) for v in items
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def cmd_list(
|
|
772
|
+
channel: str = typer.Option("published"),
|
|
773
|
+
installed: bool = typer.Option(False, "--installed"),
|
|
774
|
+
project: Optional[Path] = typer.Option(
|
|
775
|
+
None, "--project", help="Project root для скана project-scope установок"
|
|
776
|
+
),
|
|
777
|
+
scope: Optional[str] = typer.Option(
|
|
778
|
+
None, "--scope", help="global | project | all (default: all для --installed)"
|
|
779
|
+
),
|
|
780
|
+
) -> None:
|
|
781
|
+
"""Список доступных skills (RBAC), либо --installed (global + project)."""
|
|
782
|
+
cfg = ClientConfig.load()
|
|
783
|
+
if installed:
|
|
784
|
+
target = get_target(cfg.agent)
|
|
785
|
+
wanted_scope = scope or "all"
|
|
786
|
+
actual_project = (
|
|
787
|
+
project
|
|
788
|
+
or (Path(cfg.default_project_dir) if cfg.default_project_dir else None)
|
|
789
|
+
or Path.cwd()
|
|
790
|
+
)
|
|
791
|
+
items: list[dict] = []
|
|
792
|
+
if wanted_scope in ("global", "all"):
|
|
793
|
+
items.extend(_scan_installed(target, project=None))
|
|
794
|
+
if wanted_scope in ("project", "all"):
|
|
795
|
+
items.extend(_scan_installed(target, project=actual_project))
|
|
796
|
+
|
|
797
|
+
def _render(rows: list) -> None:
|
|
798
|
+
if not rows:
|
|
799
|
+
console.print(
|
|
800
|
+
f"[yellow]Ничего не установлено[/] "
|
|
801
|
+
f"(scope={wanted_scope}, project={actual_project})"
|
|
802
|
+
)
|
|
803
|
+
return
|
|
804
|
+
table = Table(title=f"Установленные ({target.name})")
|
|
805
|
+
table.add_column("id-или-slug")
|
|
806
|
+
table.add_column("version")
|
|
807
|
+
table.add_column("scope")
|
|
808
|
+
table.add_column("mount")
|
|
809
|
+
table.add_column("path", overflow="fold")
|
|
810
|
+
for s in rows:
|
|
811
|
+
table.add_row(
|
|
812
|
+
# slug может быть None у slug-less skill — показываем ref (id).
|
|
813
|
+
s.get("slug") or s.get("ref") or "—",
|
|
814
|
+
s["version"] or "—",
|
|
815
|
+
s["scope"],
|
|
816
|
+
"📎 link" if s.get("linked") else "📄 copy",
|
|
817
|
+
s["path"],
|
|
818
|
+
)
|
|
819
|
+
console.print(table)
|
|
820
|
+
|
|
821
|
+
emit_data(items, text_renderer=_render)
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
_maybe_auto_update(cfg)
|
|
825
|
+
access = _get_access_token()
|
|
826
|
+
|
|
827
|
+
async def _do() -> None:
|
|
828
|
+
client = HubClient(
|
|
829
|
+
base_url=cfg.base_url,
|
|
830
|
+
access_token=access,
|
|
831
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
832
|
+
)
|
|
833
|
+
try:
|
|
834
|
+
skills = await client.list_skills(channel=channel)
|
|
835
|
+
finally:
|
|
836
|
+
await client.close()
|
|
837
|
+
|
|
838
|
+
def _render(payload) -> None:
|
|
839
|
+
# backend отдаёт envelope {"items": [...]} (+ pagination); разворачиваем
|
|
840
|
+
rows = _unwrap_list_payload(payload)
|
|
841
|
+
table = Table(title=f"Доступные skills (channel={channel})")
|
|
842
|
+
table.add_column("slug")
|
|
843
|
+
table.add_column("title")
|
|
844
|
+
table.add_column("tags")
|
|
845
|
+
table.add_column("versions")
|
|
846
|
+
for s in rows:
|
|
847
|
+
if not isinstance(s, dict):
|
|
848
|
+
continue
|
|
849
|
+
table.add_row(
|
|
850
|
+
str(s.get("slug") or "—"),
|
|
851
|
+
str(s.get("title") or "—"),
|
|
852
|
+
# tags — объекты {id,label,...}, не строки
|
|
853
|
+
_fmt_tag_labels(s.get("tags")),
|
|
854
|
+
_fmt_version_semvers(s.get("versions")),
|
|
855
|
+
)
|
|
856
|
+
if not rows:
|
|
857
|
+
console.print(
|
|
858
|
+
"[yellow]Ничего доступного.[/] Скиллы видны только если "
|
|
859
|
+
"у вас есть доступ через группы; админ должен опубликовать "
|
|
860
|
+
"skill или Sync с GitLab."
|
|
861
|
+
)
|
|
862
|
+
else:
|
|
863
|
+
console.print(table)
|
|
864
|
+
|
|
865
|
+
emit_data(skills, text_renderer=_render)
|
|
866
|
+
|
|
867
|
+
_run(_do())
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def cmd_show(
|
|
871
|
+
slug: str = typer.Argument(
|
|
872
|
+
..., metavar="ID_ИЛИ_SLUG", help="id-или-slug скилла (backend принимает оба)"
|
|
873
|
+
),
|
|
874
|
+
) -> None:
|
|
875
|
+
"""Детали skill'а (по id-или-slug)."""
|
|
876
|
+
cfg = ClientConfig.load()
|
|
877
|
+
_maybe_auto_update(cfg)
|
|
878
|
+
access = _get_access_token()
|
|
879
|
+
|
|
880
|
+
async def _do() -> None:
|
|
881
|
+
client = HubClient(
|
|
882
|
+
base_url=cfg.base_url,
|
|
883
|
+
access_token=access,
|
|
884
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
885
|
+
)
|
|
886
|
+
try:
|
|
887
|
+
data = await client.get_skill(slug)
|
|
888
|
+
finally:
|
|
889
|
+
await client.close()
|
|
890
|
+
|
|
891
|
+
def _render(d: dict) -> None:
|
|
892
|
+
console.print(f"[bold]{d['title']}[/] ({d['slug']})")
|
|
893
|
+
console.print(f" description: {d['description']}")
|
|
894
|
+
console.print(f" tags: {_fmt_tag_labels(d.get('tags')) or '—'}")
|
|
895
|
+
console.print(f" repo: {d.get('repo_url') or '—'}")
|
|
896
|
+
console.print(f" is_super: {d.get('is_super')}")
|
|
897
|
+
console.print(" versions:")
|
|
898
|
+
for v in d.get("versions") or []:
|
|
899
|
+
if not isinstance(v, dict):
|
|
900
|
+
console.print(f" • {v}")
|
|
901
|
+
continue
|
|
902
|
+
semver = str(v.get("semver") or "—")
|
|
903
|
+
channel_v = str(v.get("channel") or "—")
|
|
904
|
+
commit = str(v.get("commit_sha") or "")[:8] or "—"
|
|
905
|
+
console.print(f" • {semver:10} channel={channel_v:10} commit={commit}")
|
|
906
|
+
|
|
907
|
+
emit_data(data, text_renderer=_render)
|
|
908
|
+
|
|
909
|
+
_run(_do())
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _resolve_install_scope(
|
|
913
|
+
cfg: ClientConfig,
|
|
914
|
+
scope: Optional[str],
|
|
915
|
+
project: Optional[Path],
|
|
916
|
+
) -> tuple[str, Optional[Path]]:
|
|
917
|
+
actual_scope = scope or cfg.default_install_scope
|
|
918
|
+
if actual_scope not in ("global", "project"):
|
|
919
|
+
emit_error("VALIDATION", f"scope должен быть global|project, получено: {actual_scope}")
|
|
920
|
+
raise typer.Exit(1)
|
|
921
|
+
if actual_scope == "global":
|
|
922
|
+
return "global", None
|
|
923
|
+
actual_project = (
|
|
924
|
+
project
|
|
925
|
+
or (Path(cfg.default_project_dir) if cfg.default_project_dir else None)
|
|
926
|
+
or Path.cwd()
|
|
927
|
+
)
|
|
928
|
+
return "project", actual_project.resolve()
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _read_skill_md_version(skill_dir: Path) -> str:
|
|
932
|
+
"""Версия локального навыка: из SKILL.md frontmatter (`version:`) или
|
|
933
|
+
_skill_meta.toml (`version`), иначе "0.0.0-local"."""
|
|
934
|
+
from skills_hub_cli.core.manifest_builder import (
|
|
935
|
+
_read_frontmatter,
|
|
936
|
+
_read_meta_toml,
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
meta_toml = _read_meta_toml(skill_dir)
|
|
940
|
+
ver = meta_toml.get("version")
|
|
941
|
+
if not ver:
|
|
942
|
+
fm = _read_frontmatter(skill_dir / "SKILL.md")
|
|
943
|
+
ver = fm.get("version")
|
|
944
|
+
ver = str(ver).strip() if ver else ""
|
|
945
|
+
return ver or "0.0.0-local"
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _has_skill_md(skill_dir: Path) -> bool:
|
|
949
|
+
"""True если папка похожа на навык (есть SKILL.md либо его frontmatter)."""
|
|
950
|
+
md = skill_dir / "SKILL.md"
|
|
951
|
+
return md.is_file()
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _apply_tooling(result, manifest: dict | None, *, agent_target, project) -> None:
|
|
955
|
+
"""поставить CLI/MCP/runtime-deps навыка (если он tooling). Graceful.
|
|
956
|
+
|
|
957
|
+
Ошибка установки артефактов НЕ ломает установку самого навыка (warn,
|
|
958
|
+
продолжаем — degradation как в reverse-factory). Печатает короткую сводку
|
|
959
|
+
о поставленных CLI/MCP и подсказку про PATH, если bin-каталог добавлен.
|
|
960
|
+
"""
|
|
961
|
+
try:
|
|
962
|
+
report = tooling_install.apply_tooling_artifacts(
|
|
963
|
+
result, agent_target=agent_target, project=project, manifest=manifest
|
|
964
|
+
)
|
|
965
|
+
except Exception as exc: # noqa: BLE001 — артефакты не валят install навыка
|
|
966
|
+
emit_message(
|
|
967
|
+
f"Не удалось доустановить CLI/MCP/зависимости навыка: {exc}",
|
|
968
|
+
level="warn",
|
|
969
|
+
)
|
|
970
|
+
return
|
|
971
|
+
_report_tooling(report)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _report_tooling(report: dict) -> None:
|
|
975
|
+
"""Человекочитаемая сводка отчёта apply_tooling_artifacts (text + warn)."""
|
|
976
|
+
for cli in report.get("cli") or []:
|
|
977
|
+
name = cli.get("command_name", "?")
|
|
978
|
+
status = cli.get("status")
|
|
979
|
+
if status == "installed":
|
|
980
|
+
emit_message(f"CLI «{name}» доступен из любой директории.", level="info")
|
|
981
|
+
path_info = cli.get("path") or {}
|
|
982
|
+
if path_info.get("status") == "manual-needed":
|
|
983
|
+
emit_message(
|
|
984
|
+
"Каталог CLI не в PATH. Добавьте его: "
|
|
985
|
+
f"{path_info.get('instruction', '')} (или `skillery doctor --fix-path`).",
|
|
986
|
+
level="warn",
|
|
987
|
+
)
|
|
988
|
+
elif status in ("error", "skipped"):
|
|
989
|
+
emit_message(
|
|
990
|
+
f"CLI «{name}» не поставлен ({status}): {cli.get('reason', '')}",
|
|
991
|
+
level="warn",
|
|
992
|
+
)
|
|
993
|
+
for mcp in report.get("mcp") or []:
|
|
994
|
+
name = mcp.get("server_name", "?")
|
|
995
|
+
status = mcp.get("status")
|
|
996
|
+
if status == "registered":
|
|
997
|
+
emit_message(f"MCP-сервер «{name}» зарегистрирован в агенте.", level="info")
|
|
998
|
+
elif status == "manual":
|
|
999
|
+
emit_message(mcp.get("instruction", f"MCP «{name}»: см. инструкцию."), level="warn")
|
|
1000
|
+
elif status == "error":
|
|
1001
|
+
emit_message(
|
|
1002
|
+
f"MCP «{name}» не зарегистрирован: {mcp.get('reason', '')}", level="warn"
|
|
1003
|
+
)
|
|
1004
|
+
deps = report.get("deps") or {}
|
|
1005
|
+
for d in deps.get("skipped") or []:
|
|
1006
|
+
emit_message(
|
|
1007
|
+
f"Зависимость {d.get('spec')} ({d.get('kind')}) не поставлена: "
|
|
1008
|
+
f"{d.get('instruction', d.get('reason', ''))}",
|
|
1009
|
+
level="warn",
|
|
1010
|
+
)
|
|
1011
|
+
for d in deps.get("failed") or []:
|
|
1012
|
+
emit_message(
|
|
1013
|
+
f"Зависимость {d.get('spec')} ({d.get('kind')}) — ошибка установки: "
|
|
1014
|
+
f"{d.get('reason', '')}",
|
|
1015
|
+
level="warn",
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _revert_tooling(slug: str, *, agent_target, project, store_dir: Path) -> None:
|
|
1020
|
+
"""снять CLI/MCP навыка при disable/remove (по манифесту из стора).
|
|
1021
|
+
|
|
1022
|
+
Манифест читается из ``_skill_meta.json`` стора (он ещё на месте на момент
|
|
1023
|
+
снятия ссылки / до purge). Никогда не бросает — снятие навыка важнее.
|
|
1024
|
+
"""
|
|
1025
|
+
try:
|
|
1026
|
+
meta = read_meta(store_dir) or {}
|
|
1027
|
+
manifest = meta.get("manifest")
|
|
1028
|
+
tooling_install.revert_tooling_artifacts(
|
|
1029
|
+
manifest, agent_target=agent_target, project=project
|
|
1030
|
+
)
|
|
1031
|
+
except Exception as exc: # noqa: BLE001 — откат артефактов не валит снятие
|
|
1032
|
+
emit_message(f"Не удалось снять CLI/MCP навыка «{slug}»: {exc}", level="warn")
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
async def _install_local_source(
|
|
1036
|
+
cfg: ClientConfig,
|
|
1037
|
+
*,
|
|
1038
|
+
source: dict,
|
|
1039
|
+
scope: str,
|
|
1040
|
+
project_path: Path | None,
|
|
1041
|
+
force: bool,
|
|
1042
|
+
agent_target, # IAgentTarget
|
|
1043
|
+
) -> list[dict]:
|
|
1044
|
+
"""Материализует навык из локального источника (path / git-url) БЕЗ сети.
|
|
1045
|
+
|
|
1046
|
+
source: {"kind":"path","slug":..,"path":Path}
|
|
1047
|
+
| {"kind":"git","slug":..,"url":str,"ref":str|None}
|
|
1048
|
+
Возвращает installed_chain того же формата, что и hub-режим.
|
|
1049
|
+
"""
|
|
1050
|
+
installer = SkillInstaller(agent_target, cfg.effective_store_dir())
|
|
1051
|
+
slug = source["slug"]
|
|
1052
|
+
if source["kind"] == "path":
|
|
1053
|
+
skill_dir = Path(source["path"]).expanduser().resolve()
|
|
1054
|
+
if not skill_dir.is_dir():
|
|
1055
|
+
emit_error("VALIDATION", f"Папка не найдена: {skill_dir}")
|
|
1056
|
+
raise typer.Exit(1)
|
|
1057
|
+
if not _has_skill_md(skill_dir):
|
|
1058
|
+
emit_error(
|
|
1059
|
+
"VALIDATION",
|
|
1060
|
+
f"В папке нет SKILL.md — это не похоже на навык: {skill_dir}",
|
|
1061
|
+
)
|
|
1062
|
+
raise typer.Exit(1)
|
|
1063
|
+
version = _read_skill_md_version(skill_dir)
|
|
1064
|
+
# tags/description из frontmatter → в manifest меты: по ним onboard
|
|
1065
|
+
# матчит локально установленные навыки (live-smoke bug Phase E).
|
|
1066
|
+
from skills_hub_cli.core.manifest_builder import (
|
|
1067
|
+
_read_frontmatter,
|
|
1068
|
+
_read_meta_toml,
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
fm = _read_frontmatter(skill_dir / "SKILL.md")
|
|
1072
|
+
manifest: dict[str, object] = {"version": version, "files": []}
|
|
1073
|
+
# локальный tooling-навык несёт cli/mcp/runtime_deps + kind в
|
|
1074
|
+
# _skill_meta.toml — прокидываем их в manifest, чтобы _apply_tooling
|
|
1075
|
+
# поставил CLI/MCP/зависимости (frontmatter их не содержит).
|
|
1076
|
+
meta_toml = _read_meta_toml(skill_dir)
|
|
1077
|
+
for key in ("kind", "cli", "mcp", "runtime_dependencies"):
|
|
1078
|
+
if meta_toml.get(key):
|
|
1079
|
+
manifest[key] = meta_toml[key]
|
|
1080
|
+
if fm.get("tags"):
|
|
1081
|
+
tags = fm["tags"]
|
|
1082
|
+
if isinstance(tags, list):
|
|
1083
|
+
manifest["tags"] = [str(t).strip() for t in tags]
|
|
1084
|
+
else:
|
|
1085
|
+
# Самописный frontmatter-парсер отдаёт inline-список строкой
|
|
1086
|
+
# "[a, b]" — срезаем скобки и сплитим по запятой.
|
|
1087
|
+
raw = str(tags).strip().strip("[]")
|
|
1088
|
+
manifest["tags"] = [
|
|
1089
|
+
s.strip().strip("\"'") for s in raw.split(",") if s.strip()
|
|
1090
|
+
]
|
|
1091
|
+
if fm.get("description"):
|
|
1092
|
+
manifest["description"] = str(fm["description"]).strip()
|
|
1093
|
+
result = installer.install(
|
|
1094
|
+
slug=slug, version=version, commit_sha="",
|
|
1095
|
+
repo_url=None, local_src=skill_dir, manifest=manifest,
|
|
1096
|
+
project=project_path, force=force,
|
|
1097
|
+
)
|
|
1098
|
+
else: # git
|
|
1099
|
+
version = "0.0.0-local"
|
|
1100
|
+
# "" → дефолтная ветка репо (без --ref); иначе явный ref.
|
|
1101
|
+
ref = source.get("ref") or ""
|
|
1102
|
+
manifest = {"version": version, "files": []}
|
|
1103
|
+
result = installer.install(
|
|
1104
|
+
slug=slug, version=version, commit_sha="",
|
|
1105
|
+
repo_url=source["url"], git_ref=ref,
|
|
1106
|
+
manifest=manifest,
|
|
1107
|
+
project=project_path, force=force,
|
|
1108
|
+
)
|
|
1109
|
+
# tooling-навык (локальный источник) → CLI/MCP/runtime-deps.
|
|
1110
|
+
# git-url источник: cli/mcp лежат в клонированном _skill_meta.toml стора —
|
|
1111
|
+
# читаем оттуда (в локальном manifest их нет). path-источник: уже в manifest.
|
|
1112
|
+
tooling_manifest = dict(manifest)
|
|
1113
|
+
if source["kind"] == "git" and result.store_dir is not None:
|
|
1114
|
+
from skills_hub_cli.core.manifest_builder import _read_meta_toml
|
|
1115
|
+
|
|
1116
|
+
cloned_meta = _read_meta_toml(result.store_dir)
|
|
1117
|
+
for key in ("kind", "cli", "mcp", "runtime_dependencies"):
|
|
1118
|
+
if cloned_meta.get(key):
|
|
1119
|
+
tooling_manifest[key] = cloned_meta[key]
|
|
1120
|
+
_apply_tooling(
|
|
1121
|
+
result, tooling_manifest, agent_target=agent_target, project=project_path
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
# Источник установки для аналитики (ось «source»): path → local-path,
|
|
1125
|
+
# git → git-url (зеркалит installer'ский meta.source).
|
|
1126
|
+
src_source = "local-path" if source["kind"] == "path" else "git-url"
|
|
1127
|
+
agent_name = agent_target.name
|
|
1128
|
+
if result.is_update:
|
|
1129
|
+
track_skill_event(
|
|
1130
|
+
"skill.update", slug=slug, version=result.version,
|
|
1131
|
+
scope=result.scope, source=src_source, agent=agent_name,
|
|
1132
|
+
)
|
|
1133
|
+
else:
|
|
1134
|
+
# Материализация в стор.
|
|
1135
|
+
track_skill_event(
|
|
1136
|
+
"skill.install", slug=slug, version=result.version,
|
|
1137
|
+
scope=result.scope, source=src_source, agent=agent_name,
|
|
1138
|
+
)
|
|
1139
|
+
# Включение-в-проект (project-линк) — отдельное событие skill.enable.
|
|
1140
|
+
if result.scope == "project":
|
|
1141
|
+
track_skill_event(
|
|
1142
|
+
"skill.enable", slug=slug, version=result.version,
|
|
1143
|
+
scope="project", source=src_source, agent=agent_name,
|
|
1144
|
+
)
|
|
1145
|
+
return [{
|
|
1146
|
+
"slug": slug, "skill_id": result.skill_id,
|
|
1147
|
+
"version": result.version, "is_update": result.is_update,
|
|
1148
|
+
"target_dir": str(result.target_dir), "scope": result.scope,
|
|
1149
|
+
"linked": result.linked, "link_kind": result.link_kind,
|
|
1150
|
+
"source": source["kind"],
|
|
1151
|
+
}]
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
async def _install_chain(
|
|
1155
|
+
cfg: ClientConfig,
|
|
1156
|
+
access: str,
|
|
1157
|
+
*,
|
|
1158
|
+
slug: str,
|
|
1159
|
+
channel: str,
|
|
1160
|
+
scope: str,
|
|
1161
|
+
project_path: Optional[Path],
|
|
1162
|
+
force: bool,
|
|
1163
|
+
agent_target, # IAgentTarget
|
|
1164
|
+
source: dict | None = None,
|
|
1165
|
+
) -> list[dict]:
|
|
1166
|
+
"""Качает bundle (+deps), материализует в стор, линкует в scope.
|
|
1167
|
+
|
|
1168
|
+
source (стратегия источника):
|
|
1169
|
+
- None / {"kind":"hub"} → backend bundle + git (как раньше; нужен access);
|
|
1170
|
+
- {"kind":"path",...} / {"kind":"git",...} → локальная материализация без
|
|
1171
|
+
сети (делегирует в _install_local_source; access игнорируется).
|
|
1172
|
+
|
|
1173
|
+
Возвращает installed_chain (list dict с slug/version/scope/linked/...).
|
|
1174
|
+
Используется и cmd_install, и cmd_enable.
|
|
1175
|
+
"""
|
|
1176
|
+
if source is not None and source.get("kind") in ("path", "git"):
|
|
1177
|
+
return await _install_local_source(
|
|
1178
|
+
cfg, source=source, scope=scope, project_path=project_path,
|
|
1179
|
+
force=force, agent_target=agent_target,
|
|
1180
|
+
)
|
|
1181
|
+
client = HubClient(
|
|
1182
|
+
base_url=cfg.base_url, access_token=access,
|
|
1183
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
1184
|
+
)
|
|
1185
|
+
try:
|
|
1186
|
+
bundle = await client.install_bundle(slug, channel=channel)
|
|
1187
|
+
installer = SkillInstaller(agent_target, cfg.effective_store_dir())
|
|
1188
|
+
chain = bundle.get(
|
|
1189
|
+
"dependencies_chain",
|
|
1190
|
+
[[bundle["skill_slug"], bundle["version"], bundle.get("repo_url")]],
|
|
1191
|
+
)
|
|
1192
|
+
installed_chain: list[dict] = []
|
|
1193
|
+
for dep_slug, dep_version, dep_repo in chain:
|
|
1194
|
+
if dep_slug == bundle["skill_slug"]:
|
|
1195
|
+
dep_bundle = bundle
|
|
1196
|
+
else:
|
|
1197
|
+
sub = HubClient(
|
|
1198
|
+
base_url=cfg.base_url, access_token=access,
|
|
1199
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
1200
|
+
)
|
|
1201
|
+
try:
|
|
1202
|
+
dep_bundle = await sub.install_bundle(dep_slug, channel=channel)
|
|
1203
|
+
finally:
|
|
1204
|
+
await sub.close()
|
|
1205
|
+
dep_id = dep_bundle.get("skill_id")
|
|
1206
|
+
result = installer.install(
|
|
1207
|
+
slug=dep_slug or None, version=dep_version,
|
|
1208
|
+
commit_sha=dep_bundle["commit_sha"],
|
|
1209
|
+
repo_url=dep_repo or dep_bundle.get("repo_url"),
|
|
1210
|
+
manifest=dep_bundle["manifest"], project=project_path,
|
|
1211
|
+
force=force, skill_id=dep_id,
|
|
1212
|
+
)
|
|
1213
|
+
entry = {
|
|
1214
|
+
"slug": dep_slug, "skill_id": result.skill_id,
|
|
1215
|
+
"version": dep_version, "is_update": result.is_update,
|
|
1216
|
+
"target_dir": str(result.target_dir), "scope": result.scope,
|
|
1217
|
+
"linked": result.linked, "link_kind": result.link_kind,
|
|
1218
|
+
}
|
|
1219
|
+
# Фикс 5в: stub-установка помечается в JSON-ответе явно.
|
|
1220
|
+
if result.content == "stub":
|
|
1221
|
+
entry["content"] = "stub"
|
|
1222
|
+
if result.skipped:
|
|
1223
|
+
entry["skipped"] = True
|
|
1224
|
+
entry["skip_reason"] = result.skip_reason
|
|
1225
|
+
installed_chain.append(entry)
|
|
1226
|
+
if not result.skipped:
|
|
1227
|
+
# tooling-навык → CLI в PATH-стор + MCP в конфиг агента +
|
|
1228
|
+
# runtime-deps. Манифест — из bundle (cli/mcp/runtime_deps).
|
|
1229
|
+
_apply_tooling(
|
|
1230
|
+
result, dep_bundle.get("manifest"),
|
|
1231
|
+
agent_target=agent_target, project=project_path,
|
|
1232
|
+
)
|
|
1233
|
+
ref = dep_slug or (str(dep_id) if dep_id is not None else "")
|
|
1234
|
+
# Источник = hub (бэкенд-bundle + git clone).
|
|
1235
|
+
track_skill_event(
|
|
1236
|
+
"skill.update" if result.is_update else "skill.install",
|
|
1237
|
+
slug=ref, version=dep_version, scope=result.scope,
|
|
1238
|
+
source="hub", agent=agent_target.name,
|
|
1239
|
+
)
|
|
1240
|
+
# Включение-в-проект (project-линк) → отдельное skill.enable.
|
|
1241
|
+
if result.scope == "project":
|
|
1242
|
+
track_skill_event(
|
|
1243
|
+
"skill.enable", slug=ref, version=dep_version,
|
|
1244
|
+
scope="project", source="hub", agent=agent_target.name,
|
|
1245
|
+
)
|
|
1246
|
+
return installed_chain
|
|
1247
|
+
finally:
|
|
1248
|
+
await client.close()
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def cmd_install(
|
|
1252
|
+
slug: str = typer.Argument(
|
|
1253
|
+
..., metavar="ID_ИЛИ_SLUG", help="id-или-slug скилла (backend принимает оба)"
|
|
1254
|
+
),
|
|
1255
|
+
channel: str = typer.Option("published"),
|
|
1256
|
+
agent: Optional[str] = typer.Option(None),
|
|
1257
|
+
scope: Optional[str] = typer.Option(
|
|
1258
|
+
None, "--scope", help="global | project (default из config.default_install_scope)"
|
|
1259
|
+
),
|
|
1260
|
+
project: Optional[Path] = typer.Option(
|
|
1261
|
+
None, "--project", help="Если scope=project — путь к корню проекта (default: cwd)"
|
|
1262
|
+
),
|
|
1263
|
+
force: bool = typer.Option(
|
|
1264
|
+
False, "--force",
|
|
1265
|
+
help="Перезаписать существующую папку (если в ней нет _skill_meta.json — например, старая ручная установка)",
|
|
1266
|
+
),
|
|
1267
|
+
path: Optional[Path] = typer.Option(
|
|
1268
|
+
None, "--path",
|
|
1269
|
+
help="Локальная папка-источник навыка (автономно, без хаба и сети). "
|
|
1270
|
+
"Должна содержать SKILL.md.",
|
|
1271
|
+
),
|
|
1272
|
+
from_git: Optional[str] = typer.Option(
|
|
1273
|
+
None, "--from-git",
|
|
1274
|
+
help="URL git-репозитория навыка (автономно, без backend bundle). "
|
|
1275
|
+
"Ветку/тег задаёт --ref.",
|
|
1276
|
+
),
|
|
1277
|
+
ref: Optional[str] = typer.Option(
|
|
1278
|
+
None, "--ref",
|
|
1279
|
+
help="Git-ref (ветка/тег/sha) для --from-git (default: HEAD репозитория).",
|
|
1280
|
+
),
|
|
1281
|
+
) -> None:
|
|
1282
|
+
"""Установить skill из хаба, локальной папки (--path) или git-url (--from-git).
|
|
1283
|
+
|
|
1284
|
+
Источники (взаимоисключающие):
|
|
1285
|
+
- по умолчанию (без --path/--from-git) — из хаба по id-или-slug (нужен login);
|
|
1286
|
+
- `--path ./skill` — скопировать локальную папку как навык (БЕЗ login/сети);
|
|
1287
|
+
- `--from-git <url> [--ref <branch/tag>]` — clone произвольного репо (БЕЗ хаба).
|
|
1288
|
+
|
|
1289
|
+
global → ~/.claude/skills/<id-или-slug>/ (видны во всех Claude Code сессиях)
|
|
1290
|
+
project → <project>/.claude/skills/<id-или-slug>/ (только в данном проекте)
|
|
1291
|
+
"""
|
|
1292
|
+
cfg = ClientConfig.load()
|
|
1293
|
+
actual_scope, project_path = _resolve_install_scope(cfg, scope, project)
|
|
1294
|
+
|
|
1295
|
+
# --- разбор источника + взаимоисключение флагов ---
|
|
1296
|
+
if path is not None and from_git is not None:
|
|
1297
|
+
emit_error("VALIDATION", "--path и --from-git взаимоисключающие")
|
|
1298
|
+
raise typer.Exit(1)
|
|
1299
|
+
if ref is not None and from_git is None:
|
|
1300
|
+
emit_error("VALIDATION", "--ref имеет смысл только вместе с --from-git")
|
|
1301
|
+
raise typer.Exit(1)
|
|
1302
|
+
|
|
1303
|
+
source: dict | None = None
|
|
1304
|
+
if path is not None:
|
|
1305
|
+
source = {"kind": "path", "slug": slug, "path": path}
|
|
1306
|
+
elif from_git is not None:
|
|
1307
|
+
source = {"kind": "git", "slug": slug, "url": from_git, "ref": ref}
|
|
1308
|
+
|
|
1309
|
+
target = get_target(agent or cfg.agent)
|
|
1310
|
+
_ = actual_scope # передаётся через project_path
|
|
1311
|
+
|
|
1312
|
+
# Hub-режим (источник не задан) требует login+токен; локальные — нет.
|
|
1313
|
+
access = ""
|
|
1314
|
+
if source is None:
|
|
1315
|
+
_maybe_auto_update(cfg)
|
|
1316
|
+
access = _get_access_token()
|
|
1317
|
+
|
|
1318
|
+
async def _do() -> None:
|
|
1319
|
+
installed_chain = await _install_chain(
|
|
1320
|
+
cfg, access, slug=slug, channel=channel, scope=actual_scope,
|
|
1321
|
+
project_path=project_path, force=force, agent_target=target,
|
|
1322
|
+
source=source,
|
|
1323
|
+
)
|
|
1324
|
+
# project scope → фиксируем набор в манифесте проекта.
|
|
1325
|
+
if project_path is not None:
|
|
1326
|
+
for item in installed_chain:
|
|
1327
|
+
ref = item["slug"] or item.get("skill_id")
|
|
1328
|
+
if ref:
|
|
1329
|
+
project_manifest.add(project_path, str(ref))
|
|
1330
|
+
|
|
1331
|
+
# Фикс 5в/1: stub и skip — явные предупреждения (json-режим: stderr,
|
|
1332
|
+
# stdout остаётся чистым машинным каналом).
|
|
1333
|
+
for item in installed_chain:
|
|
1334
|
+
label = item.get("slug") or item.get("skill_id") or "?"
|
|
1335
|
+
if item.get("skipped"):
|
|
1336
|
+
emit_message(
|
|
1337
|
+
f"«{label}»: установка пропущена ({item.get('skip_reason')}) — "
|
|
1338
|
+
"stub не может заменить существующую непустую установку.",
|
|
1339
|
+
level="warn",
|
|
1340
|
+
)
|
|
1341
|
+
elif item.get("content") == "stub":
|
|
1342
|
+
emit_message(
|
|
1343
|
+
f"«{label}» установлен как stub: у скилла в хабе нет "
|
|
1344
|
+
"git-репозитория, контент — заглушка SKILL.md.",
|
|
1345
|
+
level="warn",
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
def _render(items: list) -> None:
|
|
1349
|
+
for item in items:
|
|
1350
|
+
if item.get("skipped"):
|
|
1351
|
+
console.print(
|
|
1352
|
+
f"[yellow]→ Пропущен[/] ({item['scope']}) "
|
|
1353
|
+
f"{item['slug']}@{item['version']}: {item.get('skip_reason')}"
|
|
1354
|
+
)
|
|
1355
|
+
continue
|
|
1356
|
+
action = "Обновлён" if item["is_update"] else "Установлен"
|
|
1357
|
+
mount = "📎" if item["linked"] else "📄"
|
|
1358
|
+
console.print(
|
|
1359
|
+
f"[green]✓[/] {action} ({item['scope']}) {mount} "
|
|
1360
|
+
f"{item['slug']}@{item['version']} → {item['target_dir']}"
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
emit_data(installed_chain, text_renderer=_render)
|
|
1364
|
+
|
|
1365
|
+
_run(_do())
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _resolve_project(cfg: ClientConfig, project: Optional[Path]) -> Path:
|
|
1369
|
+
return (
|
|
1370
|
+
project
|
|
1371
|
+
or (Path(cfg.default_project_dir) if cfg.default_project_dir else None)
|
|
1372
|
+
or Path.cwd()
|
|
1373
|
+
).resolve()
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _path_within(base: Path, candidate: Path) -> bool:
|
|
1377
|
+
try:
|
|
1378
|
+
base_abs = os.path.abspath(base)
|
|
1379
|
+
cand_abs = os.path.abspath(candidate)
|
|
1380
|
+
return os.path.commonpath([base_abs, cand_abs]) == base_abs
|
|
1381
|
+
except ValueError:
|
|
1382
|
+
return False
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def cmd_enable(
|
|
1386
|
+
slug: str = typer.Argument(..., metavar="ID_ИЛИ_SLUG"),
|
|
1387
|
+
project: Optional[Path] = typer.Option(None, "--project", help="Корень проекта (default: cwd)"),
|
|
1388
|
+
agent: Optional[str] = typer.Option(None),
|
|
1389
|
+
force: bool = typer.Option(False, "--force"),
|
|
1390
|
+
channel: str = typer.Option("published"),
|
|
1391
|
+
) -> None:
|
|
1392
|
+
"""Включить навык в наборе проекта: стор + ссылка в project scope + манифест.
|
|
1393
|
+
|
|
1394
|
+
Store-first: если навык уже материализован в сторе — локальный re-link
|
|
1395
|
+
БЕЗ сети и логина (работает оффлайн для любого источника: hub /
|
|
1396
|
+
local-path / git-url). Докачка из хаба нужна только когда навыка в сторе
|
|
1397
|
+
нет (требует login).
|
|
1398
|
+
"""
|
|
1399
|
+
cfg = ClientConfig.load()
|
|
1400
|
+
project_path = _resolve_project(cfg, project)
|
|
1401
|
+
target = get_target(agent or cfg.agent)
|
|
1402
|
+
|
|
1403
|
+
# --- store-first: навык уже в сторе → re-link + манифест, без сети ---
|
|
1404
|
+
installer = SkillInstaller(target, cfg.effective_store_dir())
|
|
1405
|
+
local = installer.link_existing(slug, project=project_path, force=force)
|
|
1406
|
+
if local is not None:
|
|
1407
|
+
linked, link_kind = local
|
|
1408
|
+
store_dir = cfg.effective_store_dir() / slug
|
|
1409
|
+
store_meta = read_meta(store_dir) or {}
|
|
1410
|
+
project_manifest.add(project_path, slug)
|
|
1411
|
+
# повторное включение tooling-навыка в проект → CLI/MCP/deps
|
|
1412
|
+
# (idempotent). Манифест из меты стора; result-подобие через legacy-link.
|
|
1413
|
+
_apply_tooling(
|
|
1414
|
+
type("_R", (), {"slug": slug, "skill_id": store_meta.get("skill_id"),
|
|
1415
|
+
"store_dir": store_dir})(),
|
|
1416
|
+
store_meta.get("manifest"),
|
|
1417
|
+
agent_target=target, project=project_path,
|
|
1418
|
+
)
|
|
1419
|
+
# Включение-в-проект (навык уже в сторе) → skill.enable; source берём
|
|
1420
|
+
# из meta стора (навык пришёл из hub/local-path/git-url).
|
|
1421
|
+
track_skill_event(
|
|
1422
|
+
"skill.enable", slug=slug,
|
|
1423
|
+
version=store_meta.get("version") or "", scope="project",
|
|
1424
|
+
source=store_meta.get("source"), agent=target.name,
|
|
1425
|
+
)
|
|
1426
|
+
item = {
|
|
1427
|
+
"slug": store_meta.get("slug") or slug,
|
|
1428
|
+
"skill_id": store_meta.get("skill_id"),
|
|
1429
|
+
"version": store_meta.get("version"),
|
|
1430
|
+
"is_update": False,
|
|
1431
|
+
"target_dir": str(target.slug_dir(slug, project=project_path)),
|
|
1432
|
+
"scope": "project", "linked": linked, "link_kind": link_kind,
|
|
1433
|
+
"source": "store",
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
def _render_local(_: dict) -> None:
|
|
1437
|
+
mount = "📎" if item["linked"] else "📄"
|
|
1438
|
+
console.print(
|
|
1439
|
+
f"[green]✓[/] Включён в проект {mount} "
|
|
1440
|
+
f"{item['slug']}@{item['version']} → {item['target_dir']} "
|
|
1441
|
+
"[dim](из стора, без сети)[/]"
|
|
1442
|
+
)
|
|
1443
|
+
console.print(f"[dim]Манифест: {project_manifest.manifest_path(project_path)}[/]")
|
|
1444
|
+
|
|
1445
|
+
emit_data(
|
|
1446
|
+
{"event": "enabled", "project": str(project_path), "skills": [item]},
|
|
1447
|
+
text_renderer=_render_local,
|
|
1448
|
+
)
|
|
1449
|
+
return
|
|
1450
|
+
|
|
1451
|
+
# --- в сторе нет → докачка из хаба (нужен login) ---
|
|
1452
|
+
if not cfg.is_logged_in():
|
|
1453
|
+
emit_error(
|
|
1454
|
+
"NOT_LOGGED_IN",
|
|
1455
|
+
f"Навыка «{slug}» нет в локальном сторе; для докачки из хаба "
|
|
1456
|
+
"залогиньтесь: skillery login",
|
|
1457
|
+
)
|
|
1458
|
+
raise typer.Exit(1)
|
|
1459
|
+
access = _get_access_token()
|
|
1460
|
+
|
|
1461
|
+
async def _do() -> None:
|
|
1462
|
+
installed_chain = await _install_chain(
|
|
1463
|
+
cfg, access, slug=slug, channel=channel, scope="project",
|
|
1464
|
+
project_path=project_path, force=force, agent_target=target,
|
|
1465
|
+
)
|
|
1466
|
+
for item in installed_chain:
|
|
1467
|
+
ref = item["slug"] or item.get("skill_id")
|
|
1468
|
+
if ref:
|
|
1469
|
+
project_manifest.add(project_path, str(ref))
|
|
1470
|
+
|
|
1471
|
+
def _render(_: dict) -> None:
|
|
1472
|
+
for item in installed_chain:
|
|
1473
|
+
mount = "📎" if item["linked"] else "📄"
|
|
1474
|
+
console.print(
|
|
1475
|
+
f"[green]✓[/] Включён в проект {mount} "
|
|
1476
|
+
f"{item['slug']}@{item['version']} → {item['target_dir']}"
|
|
1477
|
+
)
|
|
1478
|
+
console.print(f"[dim]Манифест: {project_manifest.manifest_path(project_path)}[/]")
|
|
1479
|
+
|
|
1480
|
+
emit_data(
|
|
1481
|
+
{"event": "enabled", "project": str(project_path), "skills": installed_chain},
|
|
1482
|
+
text_renderer=_render,
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
_run(_do())
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def cmd_disable(
|
|
1489
|
+
slug: str = typer.Argument(..., metavar="ID_ИЛИ_SLUG"),
|
|
1490
|
+
project: Optional[Path] = typer.Option(None, "--project"),
|
|
1491
|
+
agent: Optional[str] = typer.Option(None),
|
|
1492
|
+
) -> None:
|
|
1493
|
+
"""Выключить навык из набора проекта: снять ссылку + убрать из манифеста (стор цел)."""
|
|
1494
|
+
cfg = ClientConfig.load()
|
|
1495
|
+
project_path = _resolve_project(cfg, project)
|
|
1496
|
+
target = get_target(agent or cfg.agent)
|
|
1497
|
+
installer = SkillInstaller(target, cfg.effective_store_dir())
|
|
1498
|
+
result = installer.remove(slug=slug, project=project_path)
|
|
1499
|
+
in_manifest = project_manifest.remove(project_path, slug)
|
|
1500
|
+
# снять CLI/MCP навыка из конфига агента (стор цел → манифест есть).
|
|
1501
|
+
_revert_tooling(
|
|
1502
|
+
slug, agent_target=target, project=project_path,
|
|
1503
|
+
store_dir=cfg.effective_store_dir() / slug,
|
|
1504
|
+
)
|
|
1505
|
+
# Снятие PROJECT-ссылки (стор цел) → skill.disable, НЕ uninstall.
|
|
1506
|
+
track_skill_event(
|
|
1507
|
+
"skill.disable", slug=slug, scope="project", agent=target.name
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
def _render(_: dict) -> None:
|
|
1511
|
+
if result.removed:
|
|
1512
|
+
console.print(f"[green]✓[/] Выключен из проекта: {slug} [dim](стор сохранён)[/]")
|
|
1513
|
+
else:
|
|
1514
|
+
console.print(f"[yellow]Не был включён[/]: {slug}")
|
|
1515
|
+
if in_manifest:
|
|
1516
|
+
console.print("[dim]Убран из .skills-hub/skills.toml[/]")
|
|
1517
|
+
|
|
1518
|
+
emit_data(
|
|
1519
|
+
{"event": "disabled", "slug": slug, "project": str(project_path),
|
|
1520
|
+
"unlinked": result.removed, "manifest_removed": in_manifest},
|
|
1521
|
+
text_renderer=_render,
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def cmd_sync(
|
|
1526
|
+
project: Optional[Path] = typer.Option(None, "--project", help="Корень проекта (default: cwd)"),
|
|
1527
|
+
prune: bool = typer.Option(True, "--prune/--no-prune",
|
|
1528
|
+
help="Удалять наши ссылки, которых нет в манифесте"),
|
|
1529
|
+
agent: Optional[str] = typer.Option(None),
|
|
1530
|
+
channel: str = typer.Option("published"),
|
|
1531
|
+
) -> None:
|
|
1532
|
+
"""Привести project scope в соответствие .skills-hub/skills.toml.
|
|
1533
|
+
|
|
1534
|
+
Линкует навыки из стора; отсутствующие в сторе — докачивает; --prune убирает
|
|
1535
|
+
наши (на стор) ссылки, которых нет в манифесте. Чужие папки и внешние ссылки
|
|
1536
|
+
не трогаются.
|
|
1537
|
+
"""
|
|
1538
|
+
cfg = ClientConfig.load()
|
|
1539
|
+
project_path = _resolve_project(cfg, project)
|
|
1540
|
+
target = get_target(agent or cfg.agent)
|
|
1541
|
+
installer = SkillInstaller(target, cfg.effective_store_dir())
|
|
1542
|
+
manifest = project_manifest.load(project_path)
|
|
1543
|
+
store_root = cfg.effective_store_dir()
|
|
1544
|
+
report: dict[str, list] = {
|
|
1545
|
+
"linked": [], "downloaded": [], "pruned": [], "missing": [],
|
|
1546
|
+
}
|
|
1547
|
+
access_holder: dict[str, str] = {}
|
|
1548
|
+
|
|
1549
|
+
async def _do() -> None:
|
|
1550
|
+
for slug in sorted(manifest):
|
|
1551
|
+
out = installer.link_existing(slug, project=project_path, force=True)
|
|
1552
|
+
if out is not None:
|
|
1553
|
+
report["linked"].append(slug)
|
|
1554
|
+
# Массовый re-link включает навык в проект → skill.enable на
|
|
1555
|
+
# каждый (раньше sync вообще не трекался — дыра аналитики).
|
|
1556
|
+
# source читаем из meta стора (откуда навык изначально пришёл).
|
|
1557
|
+
store_meta = read_meta(store_root / slug) or {}
|
|
1558
|
+
track_skill_event(
|
|
1559
|
+
"skill.enable", slug=slug,
|
|
1560
|
+
version=store_meta.get("version") or "",
|
|
1561
|
+
scope="project", source=store_meta.get("source"),
|
|
1562
|
+
agent=target.name,
|
|
1563
|
+
)
|
|
1564
|
+
continue
|
|
1565
|
+
# Нет в сторе → докачать из хаба (ленивый access). Без логина НЕ
|
|
1566
|
+
# падаем целиком: что есть в сторе — уже слинковано, недостающее
|
|
1567
|
+
# уходит в missing с подсказкой залогиниться (фикс 3).
|
|
1568
|
+
if "tok" not in access_holder:
|
|
1569
|
+
if not cfg.is_logged_in():
|
|
1570
|
+
report["missing"].append(slug)
|
|
1571
|
+
continue
|
|
1572
|
+
try:
|
|
1573
|
+
access_holder["tok"] = _get_access_token()
|
|
1574
|
+
except typer.Exit:
|
|
1575
|
+
report["missing"].append(slug)
|
|
1576
|
+
continue
|
|
1577
|
+
try:
|
|
1578
|
+
await _install_chain(
|
|
1579
|
+
cfg, access_holder["tok"], slug=slug, channel=channel,
|
|
1580
|
+
scope="project", project_path=project_path, force=True,
|
|
1581
|
+
agent_target=target,
|
|
1582
|
+
)
|
|
1583
|
+
report["downloaded"].append(slug)
|
|
1584
|
+
except Exception:
|
|
1585
|
+
report["missing"].append(slug)
|
|
1586
|
+
|
|
1587
|
+
if prune:
|
|
1588
|
+
base = target.base_dir(project=project_path)
|
|
1589
|
+
if base.exists():
|
|
1590
|
+
for d in list(base.iterdir()):
|
|
1591
|
+
if d.name in manifest:
|
|
1592
|
+
continue
|
|
1593
|
+
if not linker.is_link(d):
|
|
1594
|
+
continue # чужая папка-копия — не трогаем
|
|
1595
|
+
tgt = linker.link_target(d)
|
|
1596
|
+
if tgt is not None and _path_within(store_root, tgt):
|
|
1597
|
+
linker.remove_link(d) # только НАШИ (на стор) ссылки
|
|
1598
|
+
report["pruned"].append(d.name)
|
|
1599
|
+
|
|
1600
|
+
payload: dict = {**report}
|
|
1601
|
+
if report["missing"] and not cfg.is_logged_in():
|
|
1602
|
+
payload["hint"] = (
|
|
1603
|
+
"вы не залогинены — докачка из хаба недоступна; что уже в "
|
|
1604
|
+
"сторе — слинковано. Для докачки: skillery login"
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
def _render(r: dict) -> None:
|
|
1608
|
+
console.print(
|
|
1609
|
+
f"[green]sync[/] {project_path}: "
|
|
1610
|
+
f"+linked {len(r['linked'])} ↓downloaded {len(r['downloaded'])} "
|
|
1611
|
+
f"-pruned {len(r['pruned'])} ?missing {len(r['missing'])}"
|
|
1612
|
+
)
|
|
1613
|
+
for s in r["missing"]:
|
|
1614
|
+
console.print(f" [yellow]✗ не удалось получить:[/] {s}")
|
|
1615
|
+
if r.get("hint"):
|
|
1616
|
+
console.print(f" [dim]{r['hint']}[/]")
|
|
1617
|
+
|
|
1618
|
+
emit_data(payload, text_renderer=_render)
|
|
1619
|
+
|
|
1620
|
+
_run(_do())
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
async def _reconcile_hub_installs(
|
|
1624
|
+
cfg: ClientConfig,
|
|
1625
|
+
access: str,
|
|
1626
|
+
*,
|
|
1627
|
+
channel: str,
|
|
1628
|
+
agent_target, # IAgentTarget
|
|
1629
|
+
force: bool = False,
|
|
1630
|
+
) -> dict[str, list]:
|
|
1631
|
+
"""«Нажал Установить в вебе → CLI скачал»: подтянуть /me/installs в стор.
|
|
1632
|
+
|
|
1633
|
+
Идемпотентно: для каждого навыка из ``/me/installs`` сравниваем версию с
|
|
1634
|
+
локальным стором (``read_meta``). Отсутствующий или устаревший → качаем
|
|
1635
|
+
через :func:`_install_chain` (GLOBAL scope, как ``skillery install`` без
|
|
1636
|
+
``--project``). Уже актуальный — пропускаем.
|
|
1637
|
+
|
|
1638
|
+
Возвращает report ``{downloaded, updated, skipped, failed}`` (списки имён).
|
|
1639
|
+
Используется и ``cmd_pull``, и best-effort reconcile в демоне.
|
|
1640
|
+
"""
|
|
1641
|
+
store_root = cfg.effective_store_dir()
|
|
1642
|
+
client = HubClient(
|
|
1643
|
+
base_url=cfg.base_url, access_token=access,
|
|
1644
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
1645
|
+
)
|
|
1646
|
+
report: dict[str, list] = {
|
|
1647
|
+
"downloaded": [], "updated": [], "skipped": [], "failed": [],
|
|
1648
|
+
}
|
|
1649
|
+
try:
|
|
1650
|
+
installs = await client.list_my_installs()
|
|
1651
|
+
finally:
|
|
1652
|
+
await client.close()
|
|
1653
|
+
|
|
1654
|
+
for entry in installs:
|
|
1655
|
+
slug = entry.get("slug")
|
|
1656
|
+
skill_id = entry.get("skill_id")
|
|
1657
|
+
remote_version = entry.get("installed_version") or ""
|
|
1658
|
+
# Адресуем навык по slug, иначе по id (slug-less). Это же — имя папки
|
|
1659
|
+
# стора (installer.install кладёт slug-less под str(id)).
|
|
1660
|
+
ref = slug or (str(skill_id) if skill_id is not None else None)
|
|
1661
|
+
if not ref:
|
|
1662
|
+
continue
|
|
1663
|
+
local_meta = read_meta(store_root / ref) or {}
|
|
1664
|
+
local_version = local_meta.get("version")
|
|
1665
|
+
if local_meta and local_version and not force:
|
|
1666
|
+
# Уже в сторе: качаем только если remote СТРОГО новее.
|
|
1667
|
+
if not remote_version or not _is_newer(remote_version, local_version):
|
|
1668
|
+
report["skipped"].append(ref)
|
|
1669
|
+
continue
|
|
1670
|
+
bucket = "updated"
|
|
1671
|
+
else:
|
|
1672
|
+
bucket = "downloaded"
|
|
1673
|
+
try:
|
|
1674
|
+
await _install_chain(
|
|
1675
|
+
cfg, access, slug=str(ref), channel=channel, scope="global",
|
|
1676
|
+
project_path=None, force=force, agent_target=agent_target,
|
|
1677
|
+
)
|
|
1678
|
+
report[bucket].append(ref)
|
|
1679
|
+
except Exception:
|
|
1680
|
+
report["failed"].append(ref)
|
|
1681
|
+
return report
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def cmd_pull(
|
|
1685
|
+
agent: Optional[str] = typer.Option(None),
|
|
1686
|
+
channel: str = typer.Option("published"),
|
|
1687
|
+
force: bool = typer.Option(
|
|
1688
|
+
False, "--force", help="Перекачать даже если локальная версия актуальна",
|
|
1689
|
+
),
|
|
1690
|
+
) -> None:
|
|
1691
|
+
"""Скачать навыки, помеченные установленными в вебе («нажал Установить»).
|
|
1692
|
+
|
|
1693
|
+
Тянет ``/me/installs`` и докачивает отсутствующие/устаревшие в глобальный
|
|
1694
|
+
стор (как ``skillery install`` без ``--project``). Уже актуальные —
|
|
1695
|
+
пропускает. Это вторая половина потока «установка из веба»: веб помечает
|
|
1696
|
+
навык установленным, CLI ``pull`` приносит файлы. Нужен login.
|
|
1697
|
+
"""
|
|
1698
|
+
cfg = ClientConfig.load()
|
|
1699
|
+
if not cfg.is_logged_in():
|
|
1700
|
+
emit_error("NOT_LOGGED_IN", "Сначала: skillery login <invite>")
|
|
1701
|
+
raise typer.Exit(1)
|
|
1702
|
+
access = _get_access_token()
|
|
1703
|
+
target = get_target(agent or cfg.agent)
|
|
1704
|
+
|
|
1705
|
+
async def _do() -> None:
|
|
1706
|
+
report = await _reconcile_hub_installs(
|
|
1707
|
+
cfg, access, channel=channel, agent_target=target, force=force,
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
def _render(r: dict) -> None:
|
|
1711
|
+
console.print(
|
|
1712
|
+
f"[green]pull[/]: ↓downloaded {len(r['downloaded'])} "
|
|
1713
|
+
f"↑updated {len(r['updated'])} ={len(r['skipped'])} skipped "
|
|
1714
|
+
f"✗{len(r['failed'])} failed"
|
|
1715
|
+
)
|
|
1716
|
+
for s in r["failed"]:
|
|
1717
|
+
console.print(f" [yellow]✗ не удалось получить:[/] {s}")
|
|
1718
|
+
|
|
1719
|
+
emit_data(report, text_renderer=_render)
|
|
1720
|
+
|
|
1721
|
+
_run(_do())
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def cmd_migrate(
|
|
1725
|
+
scope: str = typer.Option("all", "--scope", help="all | global | project"),
|
|
1726
|
+
project: Optional[Path] = typer.Option(None, "--project"),
|
|
1727
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Показать план, ничего не меняя"),
|
|
1728
|
+
agent: Optional[str] = typer.Option(None),
|
|
1729
|
+
) -> None:
|
|
1730
|
+
"""Перевести существующие copy-установки на модель стор+ссылка.
|
|
1731
|
+
|
|
1732
|
+
Чужие папки (без _skill_meta.json) и внешние ссылки не трогаются.
|
|
1733
|
+
"""
|
|
1734
|
+
cfg = ClientConfig.load()
|
|
1735
|
+
target = get_target(agent or cfg.agent)
|
|
1736
|
+
installer = SkillInstaller(target, cfg.effective_store_dir())
|
|
1737
|
+
project_path = _resolve_project(cfg, project)
|
|
1738
|
+
|
|
1739
|
+
reports: dict[str, dict] = {}
|
|
1740
|
+
if scope in ("global", "all"):
|
|
1741
|
+
reports["global"] = installer.migrate_scope(project=None, dry_run=dry_run)
|
|
1742
|
+
if scope in ("project", "all"):
|
|
1743
|
+
reports["project"] = installer.migrate_scope(project=project_path, dry_run=dry_run)
|
|
1744
|
+
|
|
1745
|
+
# Фикс 5б: мигрированный в project scope навык обязан попасть в
|
|
1746
|
+
# .skills-hub/skills.toml — иначе следующий `sync --prune` снимет его
|
|
1747
|
+
# ссылку как «не из манифеста» (живой факт).
|
|
1748
|
+
manifest_added: list[str] = []
|
|
1749
|
+
if not dry_run:
|
|
1750
|
+
for name in reports.get("project", {}).get("migrated", []):
|
|
1751
|
+
project_manifest.add(project_path, name)
|
|
1752
|
+
manifest_added.append(name)
|
|
1753
|
+
|
|
1754
|
+
def _render(payload: dict) -> None:
|
|
1755
|
+
prefix = "[yellow]dry-run[/] " if dry_run else ""
|
|
1756
|
+
for sc, r in payload["reports"].items():
|
|
1757
|
+
console.print(
|
|
1758
|
+
f"{prefix}migrate {sc}: "
|
|
1759
|
+
f"→стор {len(r['migrated'])} пропущено(чужое) {len(r['skipped_foreign'])} "
|
|
1760
|
+
f"пропущено(ссылки) {len(r['skipped_linked'])} ошибок {len(r['failed'])}"
|
|
1761
|
+
)
|
|
1762
|
+
for name in r["migrated"]:
|
|
1763
|
+
console.print(f" [green]→[/] {name}")
|
|
1764
|
+
for f in r["failed"]:
|
|
1765
|
+
console.print(f" [red]✗[/] {f['name']}: {f['error']}")
|
|
1766
|
+
if payload["manifest_added"]:
|
|
1767
|
+
console.print(
|
|
1768
|
+
f"[dim]Дописано в {project_manifest.manifest_path(project_path)}: "
|
|
1769
|
+
f"{', '.join(payload['manifest_added'])}[/]"
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
emit_data(
|
|
1773
|
+
{"dry_run": dry_run, "reports": reports, "manifest_added": manifest_added},
|
|
1774
|
+
text_renderer=_render,
|
|
1775
|
+
)
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def cmd_store_list() -> None:
|
|
1779
|
+
"""Что лежит в центральном сторе (имя, версия, путь)."""
|
|
1780
|
+
cfg = ClientConfig.load()
|
|
1781
|
+
store_root = cfg.effective_store_dir()
|
|
1782
|
+
items: list[dict] = []
|
|
1783
|
+
if store_root.exists():
|
|
1784
|
+
for d in sorted(store_root.iterdir(), key=lambda p: p.name):
|
|
1785
|
+
if not d.is_dir():
|
|
1786
|
+
continue
|
|
1787
|
+
meta = read_meta(d) or {}
|
|
1788
|
+
items.append({
|
|
1789
|
+
"name": d.name, "slug": meta.get("slug"),
|
|
1790
|
+
"version": meta.get("version"), "path": str(d),
|
|
1791
|
+
})
|
|
1792
|
+
|
|
1793
|
+
def _render(rows: list) -> None:
|
|
1794
|
+
if not rows:
|
|
1795
|
+
console.print(f"[yellow]Стор пуст[/] ({store_root})")
|
|
1796
|
+
return
|
|
1797
|
+
table = Table(title=f"Стор ({store_root})")
|
|
1798
|
+
table.add_column("навык")
|
|
1799
|
+
table.add_column("version")
|
|
1800
|
+
table.add_column("path", overflow="fold")
|
|
1801
|
+
for s in rows:
|
|
1802
|
+
table.add_row(s["name"], s["version"] or "—", s["path"])
|
|
1803
|
+
console.print(table)
|
|
1804
|
+
|
|
1805
|
+
emit_data(items, text_renderer=_render)
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def cmd_store_path() -> None:
|
|
1809
|
+
"""Печатает путь центрального стора."""
|
|
1810
|
+
cfg = ClientConfig.load()
|
|
1811
|
+
emit_data(
|
|
1812
|
+
{"store_dir": str(cfg.effective_store_dir())},
|
|
1813
|
+
text_renderer=lambda d: console.print(d["store_dir"]),
|
|
1814
|
+
)
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
def cmd_store_gc(
|
|
1818
|
+
dry_run: bool = typer.Option(
|
|
1819
|
+
False, "--dry-run",
|
|
1820
|
+
help="[deprecated] Алиас дефолта: только показать кандидатов "
|
|
1821
|
+
"(дефолт и так ничего не удаляет).",
|
|
1822
|
+
),
|
|
1823
|
+
force: bool = typer.Option(
|
|
1824
|
+
False, "--force",
|
|
1825
|
+
help="РЕАЛЬНО удалить кандидатов из стора. Без --force gc только "
|
|
1826
|
+
"показывает список.",
|
|
1827
|
+
),
|
|
1828
|
+
) -> None:
|
|
1829
|
+
"""Показать (и под --force удалить) навыки стора без ссылок в GLOBAL scope.
|
|
1830
|
+
|
|
1831
|
+
По умолчанию НИЧЕГО не удаляет — только список кандидатов (фикс B9:
|
|
1832
|
+
дефолтный gc удалял скиллы, на которые ссылались project-junction'ы).
|
|
1833
|
+
|
|
1834
|
+
ВНИМАНИЕ: project-scope ссылки НЕ сканируются (реестр проектов не ведётся) —
|
|
1835
|
+
навык, на который ссылается только проект, будет сочтён orphan. Удаление —
|
|
1836
|
+
ТОЛЬКО осознанно через --force.
|
|
1837
|
+
"""
|
|
1838
|
+
from skills_hub_cli.core.installer import _force_rmtree
|
|
1839
|
+
|
|
1840
|
+
# Прямые вызовы (тесты/скрипты) могут передать OptionInfo-дефолты typer —
|
|
1841
|
+
# они truthy; нормализуем, чтобы это НИКОГДА не включило удаление.
|
|
1842
|
+
if not isinstance(dry_run, bool):
|
|
1843
|
+
dry_run = False
|
|
1844
|
+
if not isinstance(force, bool):
|
|
1845
|
+
force = False
|
|
1846
|
+
do_delete = force and not dry_run # явный --dry-run сильнее --force
|
|
1847
|
+
|
|
1848
|
+
cfg = ClientConfig.load()
|
|
1849
|
+
target = get_target(cfg.agent)
|
|
1850
|
+
store_root = cfg.effective_store_dir()
|
|
1851
|
+
|
|
1852
|
+
referenced: set[str] = set()
|
|
1853
|
+
base = target.base_dir() # global
|
|
1854
|
+
if base.exists():
|
|
1855
|
+
for d in base.iterdir():
|
|
1856
|
+
if linker.is_link(d):
|
|
1857
|
+
tgt = linker.link_target(d)
|
|
1858
|
+
if tgt is not None:
|
|
1859
|
+
referenced.add(os.path.normcase(str(tgt)))
|
|
1860
|
+
|
|
1861
|
+
candidates: list[str] = []
|
|
1862
|
+
if store_root.exists():
|
|
1863
|
+
for d in sorted(store_root.iterdir(), key=lambda p: p.name):
|
|
1864
|
+
if not d.is_dir():
|
|
1865
|
+
continue
|
|
1866
|
+
key = os.path.normcase(os.path.abspath(d))
|
|
1867
|
+
if key not in referenced:
|
|
1868
|
+
candidates.append(d.name)
|
|
1869
|
+
if do_delete:
|
|
1870
|
+
_force_rmtree(d)
|
|
1871
|
+
|
|
1872
|
+
def _render(p: dict) -> None:
|
|
1873
|
+
verb = "Удалено из стора" if p["deleted"] else "Кандидаты на удаление"
|
|
1874
|
+
console.print(f"[yellow]{verb}[/] ({len(p['candidates'])}): {', '.join(p['candidates']) or '—'}")
|
|
1875
|
+
if p["deleted"]:
|
|
1876
|
+
console.print("[dim]project-scope ссылки не учитывались — проверьте проекты.[/]")
|
|
1877
|
+
else:
|
|
1878
|
+
console.print(
|
|
1879
|
+
"[dim]project-scope ссылки не учитываются; ничего не удалено — "
|
|
1880
|
+
"для удаления используйте --force.[/]"
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
emit_data(
|
|
1884
|
+
{"candidates": candidates, "dry_run": not do_delete, "deleted": do_delete},
|
|
1885
|
+
text_renderer=_render,
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def cmd_update(
|
|
1890
|
+
slug: Optional[str] = typer.Argument(
|
|
1891
|
+
None, metavar="[ID_ИЛИ_SLUG]", help="id-или-slug скилла; без аргумента — все"
|
|
1892
|
+
),
|
|
1893
|
+
all_: bool = typer.Option(False, "--all"),
|
|
1894
|
+
channel: str = typer.Option("published"),
|
|
1895
|
+
project: Optional[Path] = typer.Option(None, "--project"),
|
|
1896
|
+
scope: Optional[str] = typer.Option(
|
|
1897
|
+
None, "--scope", help="global | project | all (default: all)"
|
|
1898
|
+
),
|
|
1899
|
+
) -> None:
|
|
1900
|
+
"""Обновить установленные skills (по умолчанию во всех scope: global + project)."""
|
|
1901
|
+
cfg = ClientConfig.load()
|
|
1902
|
+
target = get_target(cfg.agent)
|
|
1903
|
+
wanted_scope = scope or "all"
|
|
1904
|
+
actual_project = (
|
|
1905
|
+
project
|
|
1906
|
+
or (Path(cfg.default_project_dir) if cfg.default_project_dir else None)
|
|
1907
|
+
or Path.cwd()
|
|
1908
|
+
)
|
|
1909
|
+
|
|
1910
|
+
# Собираем список (ref, project | None) для апдейта. `ref` — id-или-slug,
|
|
1911
|
+
# совпадает с именем папки на диске (PK-миграция §3.E: slug может быть None,
|
|
1912
|
+
# тогда папка/ref = числовой id).
|
|
1913
|
+
targets: list[tuple[str, Path | None]] = []
|
|
1914
|
+
if slug and not all_:
|
|
1915
|
+
# Если ref передан явно — обновим в указанном scope (или auto-detect)
|
|
1916
|
+
if wanted_scope in ("global", "all"):
|
|
1917
|
+
if target.slug_dir(slug).exists():
|
|
1918
|
+
targets.append((slug, None))
|
|
1919
|
+
if wanted_scope in ("project", "all"):
|
|
1920
|
+
if target.slug_dir(slug, project=actual_project).exists():
|
|
1921
|
+
targets.append((slug, actual_project))
|
|
1922
|
+
else:
|
|
1923
|
+
if wanted_scope in ("global", "all"):
|
|
1924
|
+
for s in _scan_installed(target, project=None):
|
|
1925
|
+
targets.append((s["ref"], None))
|
|
1926
|
+
if wanted_scope in ("project", "all"):
|
|
1927
|
+
for s in _scan_installed(target, project=actual_project):
|
|
1928
|
+
targets.append((s["ref"], actual_project))
|
|
1929
|
+
|
|
1930
|
+
if not targets:
|
|
1931
|
+
emit_data(
|
|
1932
|
+
[],
|
|
1933
|
+
text_renderer=lambda _: console.print("[yellow]Нечего обновлять[/]"),
|
|
1934
|
+
)
|
|
1935
|
+
return
|
|
1936
|
+
access = _get_access_token()
|
|
1937
|
+
|
|
1938
|
+
async def _do() -> None:
|
|
1939
|
+
client = HubClient(
|
|
1940
|
+
base_url=cfg.base_url,
|
|
1941
|
+
access_token=access,
|
|
1942
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
1943
|
+
)
|
|
1944
|
+
results: list[dict] = []
|
|
1945
|
+
try:
|
|
1946
|
+
installer = SkillInstaller(target, cfg.effective_store_dir())
|
|
1947
|
+
for ref, proj in targets:
|
|
1948
|
+
meta = read_meta(target.slug_dir(ref, project=proj))
|
|
1949
|
+
current_version = (meta or {}).get("version", "0.0.0")
|
|
1950
|
+
# На диске identity = slug ?? skill_id; reconstruct, чтобы папка
|
|
1951
|
+
# совпала (slug-less skill хранится под id).
|
|
1952
|
+
meta_slug = (meta or {}).get("slug")
|
|
1953
|
+
meta_skill_id = (meta or {}).get("skill_id")
|
|
1954
|
+
bundle = await client.install_bundle(ref, channel=channel)
|
|
1955
|
+
scope_label = "project" if proj else "global"
|
|
1956
|
+
if bundle["version"] == current_version:
|
|
1957
|
+
results.append(
|
|
1958
|
+
{
|
|
1959
|
+
"slug": meta_slug,
|
|
1960
|
+
"skill_id": meta_skill_id,
|
|
1961
|
+
"ref": ref,
|
|
1962
|
+
"scope": scope_label,
|
|
1963
|
+
"project": str(proj) if proj else None,
|
|
1964
|
+
"from": current_version,
|
|
1965
|
+
"to": current_version,
|
|
1966
|
+
"updated": False,
|
|
1967
|
+
}
|
|
1968
|
+
)
|
|
1969
|
+
continue
|
|
1970
|
+
up = installer.install(
|
|
1971
|
+
slug=meta_slug,
|
|
1972
|
+
skill_id=meta_skill_id,
|
|
1973
|
+
version=bundle["version"],
|
|
1974
|
+
commit_sha=bundle["commit_sha"],
|
|
1975
|
+
repo_url=bundle.get("repo_url"),
|
|
1976
|
+
skill_path=bundle.get("skill_path"),
|
|
1977
|
+
manifest=bundle["manifest"],
|
|
1978
|
+
project=proj,
|
|
1979
|
+
)
|
|
1980
|
+
if up.skipped:
|
|
1981
|
+
# Stub-guard: installer отказался затирать живой контент —
|
|
1982
|
+
# честно рапортуем пропуск, событие skill.update не шлём.
|
|
1983
|
+
results.append(
|
|
1984
|
+
{
|
|
1985
|
+
"slug": meta_slug,
|
|
1986
|
+
"skill_id": up.skill_id or meta_skill_id,
|
|
1987
|
+
"ref": ref,
|
|
1988
|
+
"scope": scope_label,
|
|
1989
|
+
"project": str(proj) if proj else None,
|
|
1990
|
+
"from": current_version,
|
|
1991
|
+
"to": current_version,
|
|
1992
|
+
"updated": False,
|
|
1993
|
+
"skipped": True,
|
|
1994
|
+
"skip_reason": up.skip_reason,
|
|
1995
|
+
}
|
|
1996
|
+
)
|
|
1997
|
+
continue
|
|
1998
|
+
# gap A: контент навыка обновлён — переустановить tooling
|
|
1999
|
+
# (runtime_deps/CLI/MCP) под манифест НОВОЙ версии. manifest и
|
|
2000
|
+
# project берём из контекста апдейта (как в install).
|
|
2001
|
+
_apply_tooling(
|
|
2002
|
+
up, bundle["manifest"], agent_target=target, project=proj
|
|
2003
|
+
)
|
|
2004
|
+
results.append(
|
|
2005
|
+
{
|
|
2006
|
+
"slug": meta_slug,
|
|
2007
|
+
"skill_id": up.skill_id or meta_skill_id,
|
|
2008
|
+
"ref": ref,
|
|
2009
|
+
"scope": scope_label,
|
|
2010
|
+
"project": str(proj) if proj else None,
|
|
2011
|
+
"from": current_version,
|
|
2012
|
+
"to": bundle["version"],
|
|
2013
|
+
"updated": True,
|
|
2014
|
+
"diff": up.update_diff,
|
|
2015
|
+
}
|
|
2016
|
+
)
|
|
2017
|
+
# track skill.update event.
|
|
2018
|
+
track_skill_event(
|
|
2019
|
+
"skill.update",
|
|
2020
|
+
slug=ref,
|
|
2021
|
+
version=bundle["version"],
|
|
2022
|
+
scope=scope_label,
|
|
2023
|
+
agent=target.name,
|
|
2024
|
+
)
|
|
2025
|
+
finally:
|
|
2026
|
+
await client.close()
|
|
2027
|
+
cfg.last_auto_update_at = datetime.now(UTC).isoformat()
|
|
2028
|
+
cfg.save()
|
|
2029
|
+
|
|
2030
|
+
def _render(rows: list) -> None:
|
|
2031
|
+
for r in rows:
|
|
2032
|
+
# slug может быть None у slug-less skill — показываем ref (id).
|
|
2033
|
+
label = r.get("slug") or r.get("ref")
|
|
2034
|
+
if r.get("skipped"):
|
|
2035
|
+
console.print(
|
|
2036
|
+
f"[yellow]⚠ {label} ({r['scope']}): обновление пропущено "
|
|
2037
|
+
f"({r.get('skip_reason')})[/]"
|
|
2038
|
+
)
|
|
2039
|
+
elif r["updated"]:
|
|
2040
|
+
d = r.get("diff")
|
|
2041
|
+
diff_suffix = ""
|
|
2042
|
+
if d:
|
|
2043
|
+
diff_suffix = (
|
|
2044
|
+
f" [dim](+{d['added']} ~{d['changed']} -{d['removed']})[/]"
|
|
2045
|
+
)
|
|
2046
|
+
console.print(
|
|
2047
|
+
f"[green]↑[/] {label} ({r['scope']}): "
|
|
2048
|
+
f"{r['from']} → {r['to']}{diff_suffix}"
|
|
2049
|
+
)
|
|
2050
|
+
else:
|
|
2051
|
+
console.print(
|
|
2052
|
+
f"[dim]= {label}@{r['from']} ({r['scope']}, актуально)[/]"
|
|
2053
|
+
)
|
|
2054
|
+
|
|
2055
|
+
emit_data(results, text_renderer=_render)
|
|
2056
|
+
|
|
2057
|
+
_run(_do())
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def cmd_remove(
|
|
2061
|
+
slug: str = typer.Argument(
|
|
2062
|
+
...,
|
|
2063
|
+
metavar="ID_ИЛИ_SLUG",
|
|
2064
|
+
help="id-или-slug установленного скилла (= имя папки на диске)",
|
|
2065
|
+
),
|
|
2066
|
+
scope: Optional[str] = typer.Option(
|
|
2067
|
+
None, "--scope", help="global | project (default из config.default_install_scope)"
|
|
2068
|
+
),
|
|
2069
|
+
project: Optional[Path] = typer.Option(
|
|
2070
|
+
None, "--project", help="Если scope=project — путь к корню проекта (default: cwd)"
|
|
2071
|
+
),
|
|
2072
|
+
keep_local: bool = typer.Option(
|
|
2073
|
+
False, "--keep-local",
|
|
2074
|
+
help="Сохранить _local/ и прочие preserved_paths (пользовательский state).",
|
|
2075
|
+
),
|
|
2076
|
+
purge: bool = typer.Option(
|
|
2077
|
+
False, "--purge",
|
|
2078
|
+
help="Удалить навык и из центрального стора (а не только ссылку из scope).",
|
|
2079
|
+
),
|
|
2080
|
+
agent: Optional[str] = typer.Option(None),
|
|
2081
|
+
) -> None:
|
|
2082
|
+
"""Удалить установленный skill (global или project scope).
|
|
2083
|
+
|
|
2084
|
+
Аргумент — id-или-slug, совпадает с именем папки на диске (slug, либо
|
|
2085
|
+
числовой id для slug-less skill). По умолчанию удаляет всю папку.
|
|
2086
|
+
`--keep-local` сохраняет preserved-пути (`_local/`, `browser_profiles/`,
|
|
2087
|
+
...) — например, чтобы не потерять накопленный state при переустановке.
|
|
2088
|
+
`--purge` дополнительно удаляет навык из центрального стора.
|
|
2089
|
+
"""
|
|
2090
|
+
cfg = ClientConfig.load()
|
|
2091
|
+
actual_scope, project_path = _resolve_install_scope(cfg, scope, project)
|
|
2092
|
+
_ = actual_scope # передаётся через project_path
|
|
2093
|
+
target = get_target(agent or cfg.agent)
|
|
2094
|
+
installer = SkillInstaller(target, cfg.effective_store_dir())
|
|
2095
|
+
# снять CLI/MCP навыка ДО remove — при --purge стор (и его манифест)
|
|
2096
|
+
# удаляется, поэтому revert читает манифест из стора, пока он на месте.
|
|
2097
|
+
_revert_tooling(
|
|
2098
|
+
slug, agent_target=target, project=project_path,
|
|
2099
|
+
store_dir=cfg.effective_store_dir() / slug,
|
|
2100
|
+
)
|
|
2101
|
+
result = installer.remove(
|
|
2102
|
+
slug=slug, project=project_path, keep_local=keep_local, purge=purge
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
# Фикс 5а: симметрия с disable — снятый из project scope навык убираем и
|
|
2106
|
+
# из .skills-hub/skills.toml, иначе следующий sync вернёт его обратно.
|
|
2107
|
+
manifest_removed = False
|
|
2108
|
+
if project_path is not None:
|
|
2109
|
+
manifest_removed = project_manifest.remove(project_path, slug)
|
|
2110
|
+
|
|
2111
|
+
if not result.removed:
|
|
2112
|
+
emit_data(
|
|
2113
|
+
{
|
|
2114
|
+
"slug": slug,
|
|
2115
|
+
"scope": result.scope,
|
|
2116
|
+
"removed": False,
|
|
2117
|
+
"kept_local": False,
|
|
2118
|
+
"purged": result.purged,
|
|
2119
|
+
"manifest_removed": manifest_removed,
|
|
2120
|
+
"path": str(result.target_dir),
|
|
2121
|
+
},
|
|
2122
|
+
text_renderer=lambda _: console.print(
|
|
2123
|
+
f"[yellow]Не установлен[/] ({result.scope}): {slug} "
|
|
2124
|
+
f"(нет папки {result.target_dir})"
|
|
2125
|
+
),
|
|
2126
|
+
)
|
|
2127
|
+
return
|
|
2128
|
+
|
|
2129
|
+
# + аналитика-эпик: разводим disable vs uninstall.
|
|
2130
|
+
# project-scope без --purge = только снята ссылка (стор цел) → skill.disable.
|
|
2131
|
+
# --purge ИЛИ global-scope = навык удалён из стора → skill.uninstall.
|
|
2132
|
+
if result.scope == "project" and not result.purged:
|
|
2133
|
+
track_skill_event(
|
|
2134
|
+
"skill.disable", slug=slug, scope="project", agent=target.name
|
|
2135
|
+
)
|
|
2136
|
+
else:
|
|
2137
|
+
track_skill_event(
|
|
2138
|
+
"skill.uninstall",
|
|
2139
|
+
slug=slug,
|
|
2140
|
+
scope=result.scope,
|
|
2141
|
+
agent=target.name,
|
|
2142
|
+
extra={"kept_local": result.kept_local},
|
|
2143
|
+
)
|
|
2144
|
+
|
|
2145
|
+
def _render(_: dict) -> None:
|
|
2146
|
+
if result.kept_local:
|
|
2147
|
+
console.print(
|
|
2148
|
+
f"[green]✓[/] Удалён ({result.scope}): {slug} "
|
|
2149
|
+
f"[dim](preserved_paths сохранены в {result.target_dir})[/]"
|
|
2150
|
+
)
|
|
2151
|
+
else:
|
|
2152
|
+
console.print(f"[green]✓[/] Удалён ({result.scope}): {slug}")
|
|
2153
|
+
if manifest_removed:
|
|
2154
|
+
console.print("[dim]Убран из .skills-hub/skills.toml[/]")
|
|
2155
|
+
|
|
2156
|
+
emit_data(
|
|
2157
|
+
{
|
|
2158
|
+
"slug": slug,
|
|
2159
|
+
"scope": result.scope,
|
|
2160
|
+
"removed": True,
|
|
2161
|
+
"kept_local": result.kept_local,
|
|
2162
|
+
"purged": result.purged,
|
|
2163
|
+
"manifest_removed": manifest_removed,
|
|
2164
|
+
"path": str(result.target_dir),
|
|
2165
|
+
},
|
|
2166
|
+
text_renderer=_render,
|
|
2167
|
+
)
|
|
2168
|
+
|
|
2169
|
+
|
|
2170
|
+
def cmd_report(
|
|
2171
|
+
slug: str = typer.Argument(
|
|
2172
|
+
..., metavar="ID_ИЛИ_SLUG", help="id-или-slug скилла (backend принимает оба)"
|
|
2173
|
+
),
|
|
2174
|
+
kind: str = typer.Option("bug"),
|
|
2175
|
+
title: str = typer.Option(...),
|
|
2176
|
+
description: str = typer.Option(...),
|
|
2177
|
+
version: Optional[str] = typer.Option(None),
|
|
2178
|
+
) -> None:
|
|
2179
|
+
"""Отправить bug-report / feature-request создателю skill'а."""
|
|
2180
|
+
cfg = ClientConfig.load()
|
|
2181
|
+
access = _get_access_token()
|
|
2182
|
+
|
|
2183
|
+
async def _do() -> None:
|
|
2184
|
+
client = HubClient(
|
|
2185
|
+
base_url=cfg.base_url,
|
|
2186
|
+
access_token=access,
|
|
2187
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
2188
|
+
)
|
|
2189
|
+
try:
|
|
2190
|
+
result = await client.submit_issue(
|
|
2191
|
+
slug, kind=kind, title=title, description=description, version=version
|
|
2192
|
+
)
|
|
2193
|
+
finally:
|
|
2194
|
+
await client.close()
|
|
2195
|
+
emit_data(
|
|
2196
|
+
result,
|
|
2197
|
+
text_renderer=lambda r: console.print(
|
|
2198
|
+
f"[green]✓[/] Issue: {r['issue_id']}"
|
|
2199
|
+
),
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
_run(_do())
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _run_publish_secret_scan(
|
|
2206
|
+
skill_dir: Path, *, force: bool, strict: bool
|
|
2207
|
+
) -> None:
|
|
2208
|
+
"""ТЗ §10: скан секретов перед publish.
|
|
2209
|
+
|
|
2210
|
+
- находки → печать (file:line + rule, маскированный snippet) + abort,
|
|
2211
|
+
кроме `--force` (тогда warning + продолжаем);
|
|
2212
|
+
- gitleaks нет → warning «regex fallback», в `--strict` — fatal.
|
|
2213
|
+
Печатает в text-режиме через rich, в json-режиме — structured в stderr.
|
|
2214
|
+
"""
|
|
2215
|
+
result = secret_scan_dir(skill_dir)
|
|
2216
|
+
|
|
2217
|
+
# gitleaks отсутствует → деградация на regex.
|
|
2218
|
+
if not result.gitleaks_available:
|
|
2219
|
+
if strict:
|
|
2220
|
+
emit_error(
|
|
2221
|
+
"secret_scan_strict",
|
|
2222
|
+
"gitleaks не установлен, а указан --strict — abort.",
|
|
2223
|
+
backend=result.backend,
|
|
2224
|
+
)
|
|
2225
|
+
raise typer.Exit(1)
|
|
2226
|
+
emit_message(
|
|
2227
|
+
"secret-scan via regex fallback (gitleaks not installed)",
|
|
2228
|
+
level="warn",
|
|
2229
|
+
backend=result.backend,
|
|
2230
|
+
)
|
|
2231
|
+
|
|
2232
|
+
if not result.findings:
|
|
2233
|
+
emit_message(
|
|
2234
|
+
f"secret-scan: чисто ({result.backend}, 0 находок)",
|
|
2235
|
+
level="info",
|
|
2236
|
+
backend=result.backend,
|
|
2237
|
+
findings=0,
|
|
2238
|
+
)
|
|
2239
|
+
return
|
|
2240
|
+
|
|
2241
|
+
# Есть находки — печатаем (маскированно) и решаем abort/override.
|
|
2242
|
+
findings_payload = [
|
|
2243
|
+
{"file": f.file, "line": f.line, "rule": f.rule, "snippet": f.snippet}
|
|
2244
|
+
for f in result.findings
|
|
2245
|
+
]
|
|
2246
|
+
if is_json():
|
|
2247
|
+
emit_error(
|
|
2248
|
+
"secret_scan_failed" if not force else "secret_scan_override",
|
|
2249
|
+
f"Обнаружено секретов: {len(result.findings)}",
|
|
2250
|
+
backend=result.backend,
|
|
2251
|
+
forced=force,
|
|
2252
|
+
findings=findings_payload,
|
|
2253
|
+
)
|
|
2254
|
+
else:
|
|
2255
|
+
console.print(
|
|
2256
|
+
f"[red]✗ secret-scan ({result.backend}): "
|
|
2257
|
+
f"найдено {len(result.findings)} потенциальных секрет(ов)[/]"
|
|
2258
|
+
)
|
|
2259
|
+
for f in result.findings:
|
|
2260
|
+
console.print(
|
|
2261
|
+
f" [yellow]{f.file}:{f.line}[/] "
|
|
2262
|
+
f"[dim]({f.rule})[/] {f.snippet}"
|
|
2263
|
+
)
|
|
2264
|
+
|
|
2265
|
+
if force:
|
|
2266
|
+
emit_message(
|
|
2267
|
+
"publish продолжен несмотря на находки (--force)",
|
|
2268
|
+
level="warn",
|
|
2269
|
+
forced=True,
|
|
2270
|
+
)
|
|
2271
|
+
return
|
|
2272
|
+
|
|
2273
|
+
if not is_json():
|
|
2274
|
+
console.print(
|
|
2275
|
+
"[red]Publish прерван.[/] Удалите секреты или используйте "
|
|
2276
|
+
"[bold]--force[/] для override."
|
|
2277
|
+
)
|
|
2278
|
+
raise typer.Exit(1)
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def cmd_publish(
|
|
2282
|
+
slug: str = typer.Argument(
|
|
2283
|
+
...,
|
|
2284
|
+
metavar="ID_ИЛИ_SLUG",
|
|
2285
|
+
help="id-или-slug скилла (backend принимает оба; slug при создании задаёт hub-admin)",
|
|
2286
|
+
),
|
|
2287
|
+
tag: str = typer.Option(..., "--tag"),
|
|
2288
|
+
path: Optional[Path] = typer.Option(None, "--path"),
|
|
2289
|
+
channel: str = typer.Option("published"),
|
|
2290
|
+
title: Optional[str] = typer.Option(None),
|
|
2291
|
+
description: Optional[str] = typer.Option(None),
|
|
2292
|
+
tags: Optional[str] = typer.Option(None),
|
|
2293
|
+
repo_url: Optional[str] = typer.Option(None),
|
|
2294
|
+
skill_path: Optional[str] = typer.Option(
|
|
2295
|
+
None, "--skill-path",
|
|
2296
|
+
help="Подпапка навыка в репо (skills/<name>/), #268. По умолчанию — корень.",
|
|
2297
|
+
),
|
|
2298
|
+
is_super: bool = typer.Option(False),
|
|
2299
|
+
commit_sha: Optional[str] = typer.Option(None, "--commit-sha"),
|
|
2300
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
2301
|
+
force: bool = typer.Option(
|
|
2302
|
+
False, "--force",
|
|
2303
|
+
help="Опубликовать несмотря на найденные секреты (с warning).",
|
|
2304
|
+
),
|
|
2305
|
+
strict: bool = typer.Option(
|
|
2306
|
+
False, "--strict",
|
|
2307
|
+
help="Считать отсутствие gitleaks fatal (а не warning).",
|
|
2308
|
+
),
|
|
2309
|
+
skip_secret_scan: bool = typer.Option(
|
|
2310
|
+
False, "--skip-secret-scan",
|
|
2311
|
+
help="Полностью пропустить скан секретов (не рекомендуется).",
|
|
2312
|
+
),
|
|
2313
|
+
) -> None:
|
|
2314
|
+
"""Опубликовать новую версию skill'а (skill.publish required).
|
|
2315
|
+
|
|
2316
|
+
ТЗ §10: перед сборкой manifest скан секретов (gitleaks --no-git +
|
|
2317
|
+
regex-fallback). Находки → abort (exit 1), `--force` для override.
|
|
2318
|
+
gitleaks нет → warning; `--strict` делает это fatal.
|
|
2319
|
+
"""
|
|
2320
|
+
cfg = ClientConfig.load()
|
|
2321
|
+
access = _get_access_token()
|
|
2322
|
+
target = get_target(cfg.agent)
|
|
2323
|
+
skill_dir = path or target.slug_dir(slug)
|
|
2324
|
+
if not skill_dir.exists():
|
|
2325
|
+
console.print(f"[red]Папка skill не найдена:[/] {skill_dir}")
|
|
2326
|
+
raise typer.Exit(1)
|
|
2327
|
+
|
|
2328
|
+
# ТЗ §10: secret-scan ПЕРЕД сборкой/отправкой.
|
|
2329
|
+
if not skip_secret_scan:
|
|
2330
|
+
_run_publish_secret_scan(skill_dir, force=force, strict=strict)
|
|
2331
|
+
|
|
2332
|
+
version = tag.lstrip("v")
|
|
2333
|
+
manifest = build_manifest(skill_dir, version=version)
|
|
2334
|
+
actual_commit = commit_sha or git_commit_sha(skill_dir) or ("0" * 7)
|
|
2335
|
+
|
|
2336
|
+
# Парсинг тегов: очищаем скобки [ ] { }, дробим по запятой.
|
|
2337
|
+
def _parse_tags(tags_str: str) -> list[str]:
|
|
2338
|
+
# Убираем квадратные и фигурные скобки со скобок
|
|
2339
|
+
clean = tags_str.strip()
|
|
2340
|
+
clean = clean.lstrip("[{").rstrip("]}")
|
|
2341
|
+
# Дробим по запятой, стриппим каждый
|
|
2342
|
+
return [t.strip() for t in clean.split(",") if t.strip()]
|
|
2343
|
+
|
|
2344
|
+
payload = {
|
|
2345
|
+
"slug": slug,
|
|
2346
|
+
"title": title or manifest.description.split("\n", 1)[0][:255] or slug,
|
|
2347
|
+
"description": description or manifest.description or slug,
|
|
2348
|
+
"semver": version,
|
|
2349
|
+
"channel": channel,
|
|
2350
|
+
"commit_sha": actual_commit,
|
|
2351
|
+
"tags": (
|
|
2352
|
+
_parse_tags(tags)
|
|
2353
|
+
if tags else manifest.tags
|
|
2354
|
+
),
|
|
2355
|
+
"repo_url": repo_url,
|
|
2356
|
+
"skill_path": skill_path,
|
|
2357
|
+
"is_super": is_super,
|
|
2358
|
+
"manifest": {
|
|
2359
|
+
"version": manifest.version,
|
|
2360
|
+
"description": manifest.description,
|
|
2361
|
+
"triggers": manifest.triggers,
|
|
2362
|
+
"tags": manifest.tags,
|
|
2363
|
+
"files": manifest.files,
|
|
2364
|
+
"dependencies": manifest.dependencies,
|
|
2365
|
+
"preserved_paths": manifest.preserved_paths,
|
|
2366
|
+
# Tooling-поля (kind=tooling): без них backend сохранил бы пустой
|
|
2367
|
+
# cli/runtime_dependencies → install не поставил бы сам CLI-инструмент.
|
|
2368
|
+
"kind": manifest.kind,
|
|
2369
|
+
"cli": manifest.cli,
|
|
2370
|
+
"mcp": manifest.mcp,
|
|
2371
|
+
"runtime_dependencies": manifest.runtime_dependencies,
|
|
2372
|
+
},
|
|
2373
|
+
}
|
|
2374
|
+
if dry_run:
|
|
2375
|
+
emit_data(
|
|
2376
|
+
{"event": "publish_dry_run", "files_count": len(manifest.files), "payload": payload},
|
|
2377
|
+
text_renderer=lambda p: (
|
|
2378
|
+
console.print(f"[yellow]Dry-run[/]: {p['files_count']} файлов"),
|
|
2379
|
+
console.print(RichJSON.from_data(p["payload"])),
|
|
2380
|
+
),
|
|
2381
|
+
)
|
|
2382
|
+
return
|
|
2383
|
+
|
|
2384
|
+
async def _do() -> None:
|
|
2385
|
+
client = HubClient(
|
|
2386
|
+
base_url=cfg.base_url,
|
|
2387
|
+
access_token=access,
|
|
2388
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
2389
|
+
)
|
|
2390
|
+
try:
|
|
2391
|
+
result = await client.publish_skill(payload)
|
|
2392
|
+
finally:
|
|
2393
|
+
await client.close()
|
|
2394
|
+
emit_data(
|
|
2395
|
+
{**result, "files_count": len(manifest.files), "slug": slug, "version": version},
|
|
2396
|
+
text_renderer=lambda r: console.print(
|
|
2397
|
+
f"[green]✓[/] {r['slug']}@{r['version']} опубликован "
|
|
2398
|
+
f"(is_new={r['is_new_skill']}, files={r['files_count']})"
|
|
2399
|
+
),
|
|
2400
|
+
)
|
|
2401
|
+
|
|
2402
|
+
_run(_do())
|
|
2403
|
+
|
|
2404
|
+
|
|
2405
|
+
def cmd_admin_sync(
|
|
2406
|
+
slug: str = typer.Argument(
|
|
2407
|
+
..., metavar="ID_ИЛИ_SLUG", help="id-или-slug скилла (backend принимает оба)"
|
|
2408
|
+
),
|
|
2409
|
+
channel: str = typer.Option("published"),
|
|
2410
|
+
) -> None:
|
|
2411
|
+
"""[hub.admin] Backend сам подтягивает новые GitLab tags (по id-или-slug)."""
|
|
2412
|
+
cfg = ClientConfig.load()
|
|
2413
|
+
access = _get_access_token()
|
|
2414
|
+
|
|
2415
|
+
async def _do() -> None:
|
|
2416
|
+
client = HubClient(
|
|
2417
|
+
base_url=cfg.base_url,
|
|
2418
|
+
access_token=access,
|
|
2419
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
2420
|
+
)
|
|
2421
|
+
try:
|
|
2422
|
+
r = await client.sync_skill(slug, channel=channel)
|
|
2423
|
+
finally:
|
|
2424
|
+
await client.close()
|
|
2425
|
+
|
|
2426
|
+
def _render(p: dict) -> None:
|
|
2427
|
+
console.print(f"[green]✓[/] Sync {p['skill_slug']}:")
|
|
2428
|
+
console.print(f" Новые: {p['new_versions'] or '—'}")
|
|
2429
|
+
console.print(f" Существовали: {p['existing_versions'] or '—'}")
|
|
2430
|
+
|
|
2431
|
+
emit_data(r, text_renderer=_render)
|
|
2432
|
+
|
|
2433
|
+
_run(_do())
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
def cmd_admin_yank(
|
|
2437
|
+
slug: str = typer.Argument(
|
|
2438
|
+
..., metavar="ID_ИЛИ_SLUG", help="id-или-slug навыка"
|
|
2439
|
+
),
|
|
2440
|
+
version: str = typer.Argument(..., metavar="SEMVER", help="версия, напр. 1.0.0"),
|
|
2441
|
+
unyank: bool = typer.Option(
|
|
2442
|
+
False, "--unyank", help="Вернуть ранее снятую версию"
|
|
2443
|
+
),
|
|
2444
|
+
) -> None:
|
|
2445
|
+
"""[skill.manage] Снять (yank) версию навыка — исключить из latest/install.
|
|
2446
|
+
|
|
2447
|
+
Снятая версия остаётся в истории; `--unyank` возвращает её обратно (#340).
|
|
2448
|
+
"""
|
|
2449
|
+
cfg = ClientConfig.load()
|
|
2450
|
+
access = _get_access_token()
|
|
2451
|
+
|
|
2452
|
+
async def _do() -> None:
|
|
2453
|
+
client = HubClient(
|
|
2454
|
+
base_url=cfg.base_url,
|
|
2455
|
+
access_token=access,
|
|
2456
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
2457
|
+
)
|
|
2458
|
+
try:
|
|
2459
|
+
await client.yank_skill_version(
|
|
2460
|
+
slug=slug, semver=version, yank=not unyank
|
|
2461
|
+
)
|
|
2462
|
+
finally:
|
|
2463
|
+
await client.close()
|
|
2464
|
+
verb = "Возвращена" if unyank else "Снята"
|
|
2465
|
+
emit_data(
|
|
2466
|
+
{"slug": slug, "version": version, "yanked": not unyank},
|
|
2467
|
+
text_renderer=lambda _p: console.print(
|
|
2468
|
+
f"[green]✓[/] {verb} версия {slug}@{version}"
|
|
2469
|
+
),
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
_run(_do())
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
def cmd_admin_company_create(
|
|
2476
|
+
name: str = typer.Option(...),
|
|
2477
|
+
owner_email: str = typer.Option(..., "--owner-email"),
|
|
2478
|
+
owner_name: str = typer.Option(..., "--owner-name"),
|
|
2479
|
+
slug: Optional[str] = typer.Option(
|
|
2480
|
+
None,
|
|
2481
|
+
"--slug",
|
|
2482
|
+
help="Slug компании (требует hub.slug_manage; опусти → backend создаст slug-less)",
|
|
2483
|
+
),
|
|
2484
|
+
) -> None:
|
|
2485
|
+
"""[hub.company_create] Создать новую компанию + invite owner'у."""
|
|
2486
|
+
cfg = ClientConfig.load()
|
|
2487
|
+
access = _get_access_token()
|
|
2488
|
+
|
|
2489
|
+
async def _do() -> None:
|
|
2490
|
+
client = HubClient(
|
|
2491
|
+
base_url=cfg.base_url,
|
|
2492
|
+
access_token=access,
|
|
2493
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
2494
|
+
)
|
|
2495
|
+
payload: dict[str, object] = {
|
|
2496
|
+
"name": name,
|
|
2497
|
+
"owner_email": owner_email,
|
|
2498
|
+
"owner_display_name": owner_name,
|
|
2499
|
+
}
|
|
2500
|
+
if slug:
|
|
2501
|
+
payload["slug"] = slug # пустой slug не шлём → backend сделает slug-less
|
|
2502
|
+
try:
|
|
2503
|
+
r = await client.create_company(payload)
|
|
2504
|
+
finally:
|
|
2505
|
+
await client.close()
|
|
2506
|
+
|
|
2507
|
+
def _render(p: dict) -> None:
|
|
2508
|
+
label = slug or p.get("company_id")
|
|
2509
|
+
console.print(f"[green]✓[/] Компания {label} (id={p['company_id']})")
|
|
2510
|
+
console.print(f" Owner invite: {p['owner_invite_token']}")
|
|
2511
|
+
console.print(f" URL: {p['owner_invite_url']}")
|
|
2512
|
+
|
|
2513
|
+
emit_data(r, text_renderer=_render)
|
|
2514
|
+
|
|
2515
|
+
_run(_do())
|
|
2516
|
+
|
|
2517
|
+
|
|
2518
|
+
def cmd_admin_invite(
|
|
2519
|
+
company_id: str = typer.Option(..., "--company-id"),
|
|
2520
|
+
role_id: str = typer.Option(..., "--role-id"),
|
|
2521
|
+
email: Optional[str] = typer.Option(
|
|
2522
|
+
None, "--email", help="Email приглашаемого (pre-emptive User+Membership)"
|
|
2523
|
+
),
|
|
2524
|
+
name: Optional[str] = typer.Option(
|
|
2525
|
+
None, "--name", help="Display-name приглашаемого (вместе с --email)"
|
|
2526
|
+
),
|
|
2527
|
+
) -> None:
|
|
2528
|
+
"""[invite.manage] Выдать invite member/manager'у в свою компанию.
|
|
2529
|
+
|
|
2530
|
+
Flat POST /invites: nested /companies/{id}/invites удалён. ``--email``
|
|
2531
|
+
+ ``--name`` опциональны — если заданы, backend сразу заводит
|
|
2532
|
+
User(invited)+Membership (invitee виден в списке пользователей).
|
|
2533
|
+
"""
|
|
2534
|
+
cfg = ClientConfig.load()
|
|
2535
|
+
access = _get_access_token()
|
|
2536
|
+
|
|
2537
|
+
async def _do() -> None:
|
|
2538
|
+
client = HubClient(
|
|
2539
|
+
base_url=cfg.base_url,
|
|
2540
|
+
access_token=access,
|
|
2541
|
+
on_token_refresh=_make_refresh_callback(cfg),
|
|
2542
|
+
)
|
|
2543
|
+
try:
|
|
2544
|
+
r = await client.issue_invite(
|
|
2545
|
+
company_id, role_id, email=email, display_name=name
|
|
2546
|
+
)
|
|
2547
|
+
finally:
|
|
2548
|
+
await client.close()
|
|
2549
|
+
|
|
2550
|
+
def _render(p: dict) -> None:
|
|
2551
|
+
console.print(f"[green]✓[/] Invite: {p['invite_token']}")
|
|
2552
|
+
console.print(f" URL: {p['invite_url']}")
|
|
2553
|
+
|
|
2554
|
+
emit_data(r, text_renderer=_render)
|
|
2555
|
+
|
|
2556
|
+
_run(_do())
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
def cmd_config(
|
|
2560
|
+
output: Optional[str] = typer.Option(
|
|
2561
|
+
None, "--output", help="text|json — формат вывода по умолчанию"
|
|
2562
|
+
),
|
|
2563
|
+
auto_update: Optional[bool] = typer.Option(
|
|
2564
|
+
None, "--auto-update/--no-auto-update", help="Тихо обновлять навыки"
|
|
2565
|
+
),
|
|
2566
|
+
auto_update_cooldown_min: Optional[int] = typer.Option(
|
|
2567
|
+
None, "--auto-update-cooldown-min", help="Минут между фон-проверками"
|
|
2568
|
+
),
|
|
2569
|
+
install_scope: Optional[str] = typer.Option(
|
|
2570
|
+
None, "--install-scope", help="global|project — куда install ставит skill"
|
|
2571
|
+
),
|
|
2572
|
+
project_dir: Optional[Path] = typer.Option(
|
|
2573
|
+
None, "--project-dir", help="Дефолтный project root для scope=project"
|
|
2574
|
+
),
|
|
2575
|
+
show: bool = typer.Option(False, "--show", help="Просто показать текущий config"),
|
|
2576
|
+
) -> None:
|
|
2577
|
+
"""Настройки CLI (output, auto-update, default install scope/project)."""
|
|
2578
|
+
cfg = ClientConfig.load()
|
|
2579
|
+
changes = False
|
|
2580
|
+
if output is not None:
|
|
2581
|
+
if output not in ("text", "json"):
|
|
2582
|
+
emit_error("VALIDATION", "output должен быть 'text' или 'json'")
|
|
2583
|
+
raise typer.Exit(1)
|
|
2584
|
+
cfg.output_format = output
|
|
2585
|
+
changes = True
|
|
2586
|
+
if auto_update is not None:
|
|
2587
|
+
cfg.auto_update = auto_update
|
|
2588
|
+
changes = True
|
|
2589
|
+
if auto_update_cooldown_min is not None:
|
|
2590
|
+
cfg.auto_update_cooldown_min = auto_update_cooldown_min
|
|
2591
|
+
changes = True
|
|
2592
|
+
if install_scope is not None:
|
|
2593
|
+
if install_scope not in ("global", "project"):
|
|
2594
|
+
emit_error("VALIDATION", "install-scope должен быть 'global' или 'project'")
|
|
2595
|
+
raise typer.Exit(1)
|
|
2596
|
+
cfg.default_install_scope = install_scope
|
|
2597
|
+
changes = True
|
|
2598
|
+
if project_dir is not None:
|
|
2599
|
+
cfg.default_project_dir = str(project_dir.resolve())
|
|
2600
|
+
changes = True
|
|
2601
|
+
cfg.save()
|
|
2602
|
+
payload = {
|
|
2603
|
+
"output_format": cfg.output_format,
|
|
2604
|
+
"auto_update": cfg.auto_update,
|
|
2605
|
+
"auto_update_cooldown_min": cfg.auto_update_cooldown_min,
|
|
2606
|
+
"default_install_scope": cfg.default_install_scope,
|
|
2607
|
+
"default_project_dir": cfg.default_project_dir,
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
def _render(p: dict) -> None:
|
|
2611
|
+
console.print(f" output_format: {p['output_format']}")
|
|
2612
|
+
console.print(f" auto_update: {p['auto_update']}")
|
|
2613
|
+
console.print(f" auto_update_cooldown_min: {p['auto_update_cooldown_min']}")
|
|
2614
|
+
console.print(f" default_install_scope: {p['default_install_scope']}")
|
|
2615
|
+
console.print(f" default_project_dir: {p['default_project_dir'] or '—'}")
|
|
2616
|
+
if not show and changes:
|
|
2617
|
+
console.print("[green]✓[/] Config обновлён")
|
|
2618
|
+
|
|
2619
|
+
emit_data(payload, text_renderer=_render)
|
|
2620
|
+
|
|
2621
|
+
|
|
2622
|
+
# ======================================================
|
|
2623
|
+
# BUILD APP DYNAMICALLY
|
|
2624
|
+
# ======================================================
|
|
2625
|
+
def _version_callback(value: bool) -> None:
|
|
2626
|
+
"""Eager-callback для глобального ``--version``: печать версии + выход.
|
|
2627
|
+
|
|
2628
|
+
``__version__`` живёт в ``skills_hub_cli/__init__.py`` — пробрасываем его
|
|
2629
|
+
в typer (раньше флаг отсутствовал). Печать уважает json-режим.
|
|
2630
|
+
"""
|
|
2631
|
+
if not value:
|
|
2632
|
+
return
|
|
2633
|
+
from skills_hub_cli import __version__
|
|
2634
|
+
|
|
2635
|
+
emit_data(
|
|
2636
|
+
{"version": __version__},
|
|
2637
|
+
text_renderer=lambda p: console.print(p["version"]),
|
|
2638
|
+
)
|
|
2639
|
+
raise typer.Exit()
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
def build_app() -> typer.Typer:
|
|
2643
|
+
cfg = ClientConfig.load()
|
|
2644
|
+
is_logged_in = cfg.is_logged_in()
|
|
2645
|
+
|
|
2646
|
+
description_lines = ["Skillery CLI"]
|
|
2647
|
+
if is_logged_in:
|
|
2648
|
+
is_hub_admin = cfg.is_hub_admin()
|
|
2649
|
+
is_skill_creator = cfg.is_skill_creator()
|
|
2650
|
+
roles = [
|
|
2651
|
+
r for r in (
|
|
2652
|
+
"hub-admin" if is_hub_admin else None,
|
|
2653
|
+
"skill-creator" if is_skill_creator else None,
|
|
2654
|
+
"member" if not (is_hub_admin or is_skill_creator) else None,
|
|
2655
|
+
) if r
|
|
2656
|
+
]
|
|
2657
|
+
description_lines.append(
|
|
2658
|
+
f"Вы вошли как [bold]{cfg.user_email}[/] ({', '.join(roles)})."
|
|
2659
|
+
)
|
|
2660
|
+
else:
|
|
2661
|
+
description_lines.append(
|
|
2662
|
+
"[dim]Не авторизован. Доступно без логина: login / register / join, "
|
|
2663
|
+
"автономная установка (install --path | --from-git), управление "
|
|
2664
|
+
"стором (enable/disable/remove/sync/migrate/store), локальные "
|
|
2665
|
+
"коллекции (collection *-local), onboard.[/]"
|
|
2666
|
+
)
|
|
2667
|
+
|
|
2668
|
+
# cli-kits W6: каркас root-приложения строится через clikit.command_kit
|
|
2669
|
+
# (build_root_app), а НЕ голым typer.Typer. Бренд — "skills-hub". Помощь и
|
|
2670
|
+
# no_args_is_help сохранены прежними.
|
|
2671
|
+
#
|
|
2672
|
+
# ВАЖНО — почему callback и `version`-команда ниже переопределяются/снимаются:
|
|
2673
|
+
# build_root_app задаёт СВОЙ глобальный callback (json-дефолт + --text/--plain
|
|
2674
|
+
# через clikit.output) и регистрирует подкоманду `version`. У этого CLI
|
|
2675
|
+
# ИСТОРИЧЕСКИЙ контракт вывода ДРУГОЙ: дефолт — text, режим инициализируется
|
|
2676
|
+
# пре-проходом по argv ДО построения app (см. init_output_mode выше через
|
|
2677
|
+
# skills_hub_cli.output), а callback — no-op. Чтобы не сломать ни поведение
|
|
2678
|
+
# вывода (is_json()), ни набор команд (`skillery --help`), мы:
|
|
2679
|
+
# 1) переопределяем callback историческим (--profile/--json/--version, без
|
|
2680
|
+
# --text и без clikit-реинициализации вывода);
|
|
2681
|
+
# 2) снимаем авто-зарегистрированную команду `version` (флага --version и
|
|
2682
|
+
# его callback'а достаточно — набор команд остаётся прежним).
|
|
2683
|
+
app = build_root_app(
|
|
2684
|
+
brand="skillery",
|
|
2685
|
+
help="\n".join(description_lines),
|
|
2686
|
+
no_args_is_help=True,
|
|
2687
|
+
)
|
|
2688
|
+
# Снять авто-`version`-подкоманду из build_root_app: исторически её нет,
|
|
2689
|
+
# версия печатается ТОЛЬКО глобальным флагом --version.
|
|
2690
|
+
app.registered_commands = [
|
|
2691
|
+
c for c in app.registered_commands if c.name != "version"
|
|
2692
|
+
]
|
|
2693
|
+
|
|
2694
|
+
@app.callback()
|
|
2695
|
+
def _root(
|
|
2696
|
+
profile: Optional[str] = typer.Option(
|
|
2697
|
+
None, "--profile", "-P", help="Использовать профиль (admin/test/...)"
|
|
2698
|
+
),
|
|
2699
|
+
json_output: bool = typer.Option(
|
|
2700
|
+
False, "--json", "-J",
|
|
2701
|
+
help="Вывод в JSON (для AI-агентов и скриптов). По умолчанию из config.output_format.",
|
|
2702
|
+
),
|
|
2703
|
+
version: bool = typer.Option(
|
|
2704
|
+
False, "--version", "-V",
|
|
2705
|
+
help="Показать версию skillery CLI и выйти.",
|
|
2706
|
+
callback=_version_callback,
|
|
2707
|
+
is_eager=True,
|
|
2708
|
+
),
|
|
2709
|
+
) -> None:
|
|
2710
|
+
"""Корневой callback (профиль + json считаны до построения app).
|
|
2711
|
+
|
|
2712
|
+
Переопределяет callback из build_root_app, чтобы сохранить исторический
|
|
2713
|
+
контракт вывода (дефолт text, режим уже инициализирован пре-проходом).
|
|
2714
|
+
"""
|
|
2715
|
+
_ = profile, json_output, version
|
|
2716
|
+
|
|
2717
|
+
# === Always-on ===
|
|
2718
|
+
app.command(name="login")(cmd_login)
|
|
2719
|
+
# --- P1 account ---
|
|
2720
|
+
# register/join — always-on онбординг: самостоятельная регистрация и
|
|
2721
|
+
# вступление в компанию по ссылке работают ДО login (join у залогиненного
|
|
2722
|
+
# сам переключается на /invite-links/accept).
|
|
2723
|
+
from skills_hub_cli.commands import account as _account_mod
|
|
2724
|
+
|
|
2725
|
+
_account_mod.register(app)
|
|
2726
|
+
app.command(name="set-tokens", hidden=True)(cmd_set_tokens)
|
|
2727
|
+
app.command(name="status")(cmd_status)
|
|
2728
|
+
# doctor — ALWAYS-ON (рядом со status): self-check окружения (Python/uv/
|
|
2729
|
+
# agent/login/PATH/clikit), pass/warn/fail, --strict для CI. Сети не нужно.
|
|
2730
|
+
from skills_hub_cli.commands import doctor as _doctor_mod
|
|
2731
|
+
|
|
2732
|
+
_doctor_mod.register(app)
|
|
2733
|
+
app.command(name="logout")(cmd_logout)
|
|
2734
|
+
app.command(name="whoami")(cmd_whoami)
|
|
2735
|
+
app.command(name="config")(cmd_config)
|
|
2736
|
+
app.command(name="web")(cmd_web)
|
|
2737
|
+
# install — ALWAYS-ON: автономные источники (--path/--from-git) работают без
|
|
2738
|
+
# login; hub-режим (без этих флагов) внутри cmd_install сам требует токен.
|
|
2739
|
+
app.command(name="install")(cmd_install)
|
|
2740
|
+
# lifecycle — ALWAYS-ON (фикс 3): enable/disable/remove/sync/migrate/store
|
|
2741
|
+
# работают с ЛОКАЛЬНЫМ стором без login. Сеть нужна только sync-докачке и
|
|
2742
|
+
# hub-enable — эти ветки сами отвечают NOT_LOGGED_IN / missing-подсказкой.
|
|
2743
|
+
app.command(name="enable")(cmd_enable)
|
|
2744
|
+
app.command(name="disable")(cmd_disable)
|
|
2745
|
+
app.command(name="remove")(cmd_remove)
|
|
2746
|
+
app.command(name="sync")(cmd_sync)
|
|
2747
|
+
# pull — докачка навыков, помеченных установленными в вебе (/me/installs).
|
|
2748
|
+
# Hub-режим: внутри сам требует login.
|
|
2749
|
+
app.command(name="pull")(cmd_pull)
|
|
2750
|
+
app.command(name="migrate")(cmd_migrate)
|
|
2751
|
+
store_app = typer.Typer(no_args_is_help=True, help="Центральный стор навыков")
|
|
2752
|
+
app.add_typer(store_app, name="store")
|
|
2753
|
+
store_app.command("list")(cmd_store_list)
|
|
2754
|
+
store_app.command("path")(cmd_store_path)
|
|
2755
|
+
store_app.command("gc")(cmd_store_gc)
|
|
2756
|
+
# --- P1 local-collections ---
|
|
2757
|
+
# Единый collection sub-app — ALWAYS-ON: все глаголы (list/show/install/
|
|
2758
|
+
# create/add/remove/delete/tags) регистрируются всегда; create/add/remove/
|
|
2759
|
+
# delete имеют локальный режим через флаг --local (оффлайн, без логина).
|
|
2760
|
+
# Серверный режим (без --local) гейтится ВНУТРИ модуля флагами
|
|
2761
|
+
# server_enabled (skill.read) / can_install (skill.install) / can_manage
|
|
2762
|
+
# (catalog.manage, M-2) — проверяются в рантайме команд.
|
|
2763
|
+
from skills_hub_cli.commands import collection as _coll_mod
|
|
2764
|
+
|
|
2765
|
+
_coll_mod.register(
|
|
2766
|
+
app,
|
|
2767
|
+
server_enabled=cfg.has_permission("skill.read"),
|
|
2768
|
+
can_install=cfg.has_permission("skill.install"),
|
|
2769
|
+
# серверный CRUD коллекций (create/add/remove/tags) —
|
|
2770
|
+
# право catalog.manage (hub.admin bypass внутри has_permission).
|
|
2771
|
+
can_manage=cfg.has_permission("catalog.manage"),
|
|
2772
|
+
)
|
|
2773
|
+
|
|
2774
|
+
# --- P1 onboard ---
|
|
2775
|
+
# Онбординг проекта — ALWAYS-ON: без логина работает по локальному
|
|
2776
|
+
# стору; hub-поиск и докачка отсутствующих включаются только при сессии.
|
|
2777
|
+
from skills_hub_cli.commands import onboard as _onboard_mod
|
|
2778
|
+
|
|
2779
|
+
_onboard_mod.register(app)
|
|
2780
|
+
|
|
2781
|
+
# --- discovery suggest ---
|
|
2782
|
+
# `skillery suggest "<query>"` — подбор навыков по свободному запросу.
|
|
2783
|
+
# ALWAYS-ON, read-only: локальный стор всегда, hub-поиск при сессии.
|
|
2784
|
+
from skills_hub_cli.commands import suggest as _suggest_mod
|
|
2785
|
+
|
|
2786
|
+
_suggest_mod.register(app)
|
|
2787
|
+
|
|
2788
|
+
# --- scaffold ---
|
|
2789
|
+
# `skillery new <slug> --kind ...` — генерация скелета навыка. ALWAYS-ON:
|
|
2790
|
+
# локальная генерация на диск, сети/логина не требует.
|
|
2791
|
+
from skills_hub_cli.commands import scaffold as _scaffold_mod
|
|
2792
|
+
|
|
2793
|
+
_scaffold_mod.register(app)
|
|
2794
|
+
|
|
2795
|
+
# --- analytics local ---
|
|
2796
|
+
# `skillery analytics local` — read-only локальная картина (стор + проект
|
|
2797
|
+
# + очередь событий). ALWAYS-ON: только локальные файлы, сети/логина не надо.
|
|
2798
|
+
from skills_hub_cli.commands import analytics as _analytics_mod
|
|
2799
|
+
|
|
2800
|
+
_analytics_mod.register(app)
|
|
2801
|
+
|
|
2802
|
+
if not is_logged_in:
|
|
2803
|
+
return app
|
|
2804
|
+
|
|
2805
|
+
# === Requires authenticated session ===
|
|
2806
|
+
app.command(name="passwd")(cmd_passwd)
|
|
2807
|
+
|
|
2808
|
+
# === Permission-gated user commands ===
|
|
2809
|
+
if cfg.has_permission("skill.read"):
|
|
2810
|
+
app.command(name="list")(cmd_list)
|
|
2811
|
+
app.command(name="show")(cmd_show)
|
|
2812
|
+
# --- P1 local-collections --- (перенесено): collection.register
|
|
2813
|
+
# теперь вызывается в always-on зоне выше — серверные команды
|
|
2814
|
+
# гейтятся внутри модуля (server_enabled=skill.read,
|
|
2815
|
+
# can_install=skill.install), локальные живут без логина.
|
|
2816
|
+
# contributors (public-аналог skill.read).
|
|
2817
|
+
from skills_hub_cli.commands import contrib as _contrib_mod
|
|
2818
|
+
|
|
2819
|
+
_contrib_mod.register(app)
|
|
2820
|
+
# comments list (public via skill.read).
|
|
2821
|
+
from skills_hub_cli.commands import comment as _comment_mod
|
|
2822
|
+
|
|
2823
|
+
# post-команду регистрируем отдельно ниже (нужен comment.post).
|
|
2824
|
+
app.command(name="comments")(_comment_mod.cmd_comments_list)
|
|
2825
|
+
# install зарегистрирован в always-on блоке (см. выше): автономные
|
|
2826
|
+
# источники --path/--from-git не требуют login.
|
|
2827
|
+
# enable/disable/remove/sync/migrate/store(list/path/gc) — тоже в
|
|
2828
|
+
# always-on блоке (фикс 3): lifecycle локального стора живёт без login.
|
|
2829
|
+
# cli-kits W6: одиночные permission-гейты → command_kit.gated.
|
|
2830
|
+
gated(app, permission="skill.install", has_permission=cfg.has_permission,
|
|
2831
|
+
name="update")(cmd_update)
|
|
2832
|
+
gated(app, permission="skill.report_issue", has_permission=cfg.has_permission,
|
|
2833
|
+
name="report")(cmd_report)
|
|
2834
|
+
|
|
2835
|
+
# === Skill review: ratings + comments post ===
|
|
2836
|
+
if cfg.has_permission("skill.rate"):
|
|
2837
|
+
from skills_hub_cli.commands import rate as _rate_mod
|
|
2838
|
+
|
|
2839
|
+
_rate_mod.register(app)
|
|
2840
|
+
# comment / comment-edit / comment-delete — независимые per-permission гейты
|
|
2841
|
+
# (PATCH/DELETE /comments/{id} адресуют comment по числовому id).
|
|
2842
|
+
# cli-kits W6: → command_kit.gated (1:1 одиночные команды).
|
|
2843
|
+
from skills_hub_cli.commands import comment as _comment_mod
|
|
2844
|
+
|
|
2845
|
+
gated(app, permission="comment.post", has_permission=cfg.has_permission,
|
|
2846
|
+
name="comment")(_comment_mod.cmd_comment_post)
|
|
2847
|
+
gated(app, permission="comment.edit_own", has_permission=cfg.has_permission,
|
|
2848
|
+
name="comment-edit")(_comment_mod.cmd_comment_edit)
|
|
2849
|
+
gated(app, permission="comment.delete_own", has_permission=cfg.has_permission,
|
|
2850
|
+
name="comment-delete")(_comment_mod.cmd_comment_delete)
|
|
2851
|
+
|
|
2852
|
+
# === Support tickets ===
|
|
2853
|
+
if cfg.has_permission("ticket.create") or cfg.has_permission("ticket.read"):
|
|
2854
|
+
from skills_hub_cli.commands import ticket as _ticket_mod
|
|
2855
|
+
|
|
2856
|
+
# `status` (PATCH) — под ticket.update_status; reply — под ticket.create.
|
|
2857
|
+
_ticket_mod.register_ticket(
|
|
2858
|
+
app, can_update=cfg.has_permission("ticket.update_status")
|
|
2859
|
+
)
|
|
2860
|
+
|
|
2861
|
+
# === Event tracking + daemon (always-on для залогиненного user'а) ===
|
|
2862
|
+
from skills_hub_cli.commands import daemon as _daemon_mod
|
|
2863
|
+
from skills_hub_cli.commands import event as _event_mod
|
|
2864
|
+
|
|
2865
|
+
_event_mod.register(app)
|
|
2866
|
+
_daemon_mod.register(app)
|
|
2867
|
+
|
|
2868
|
+
# === Creator ===
|
|
2869
|
+
# cli-kits W6: одиночный гейт publish → command_kit.gated.
|
|
2870
|
+
gated(app, permission="skill.publish", has_permission=cfg.has_permission,
|
|
2871
|
+
name="publish")(cmd_publish)
|
|
2872
|
+
|
|
2873
|
+
# --- P1 member ---
|
|
2874
|
+
# Участники + каталог ролей (C3): members/roles — любой залогиненный
|
|
2875
|
+
# (backend сам сужает выдачу: member без admin-прав видит только себя),
|
|
2876
|
+
# мутации — по правам (hub.admin bypass внутри has_permission).
|
|
2877
|
+
from skills_hub_cli.commands import member as _member_mod
|
|
2878
|
+
|
|
2879
|
+
_member_mod.register(
|
|
2880
|
+
app,
|
|
2881
|
+
can_invite=cfg.has_permission("user.invite"),
|
|
2882
|
+
can_remove=cfg.has_permission("user.remove"),
|
|
2883
|
+
can_change_role=cfg.has_permission("role.manage"),
|
|
2884
|
+
# S3 D2.2: lock/unlock гейтится тем же предикатом, что backend
|
|
2885
|
+
# `_can_admin_users` (а не узким user.lock) — иначе owner/manager не
|
|
2886
|
+
# видят команду, хотя бэк/UI их допускают. suspend/activate/
|
|
2887
|
+
# revoke-sessions — тот же предикат (зеркало bulk-эндпоинтов).
|
|
2888
|
+
can_lock=cfg.can_admin_users(),
|
|
2889
|
+
can_reset_password=cfg.has_permission("company.manage"),
|
|
2890
|
+
# CRUD пользователей. create/edit/delete — company-admin или
|
|
2891
|
+
# hub.admin (зеркало backend `_can_admin_users` per-action); transfer/
|
|
2892
|
+
# export — строго hub.admin (backend hub-admin-only).
|
|
2893
|
+
can_create=cfg.has_permission("user.create")
|
|
2894
|
+
or cfg.has_permission("company.manage"),
|
|
2895
|
+
can_update=cfg.has_permission("user.update")
|
|
2896
|
+
or cfg.has_permission("company.manage"),
|
|
2897
|
+
can_delete=cfg.has_permission("user.delete")
|
|
2898
|
+
or cfg.has_permission("company.manage"),
|
|
2899
|
+
can_transfer=cfg.is_hub_admin(),
|
|
2900
|
+
can_export=cfg.is_hub_admin(),
|
|
2901
|
+
)
|
|
2902
|
+
|
|
2903
|
+
# --- permissions + role set-permissions ---
|
|
2904
|
+
# Управление каталогом прав и набором прав роли — гейт hub.admin
|
|
2905
|
+
# (backend допускает set-permissions ещё и role.manage в своей компании,
|
|
2906
|
+
# но CLI-видимость держим на hub.admin; бэк финально режет 403).
|
|
2907
|
+
from skills_hub_cli.commands import permission as _permission_mod
|
|
2908
|
+
|
|
2909
|
+
_permission_mod.register(app, can_manage=cfg.is_hub_admin())
|
|
2910
|
+
|
|
2911
|
+
# === Admin sub-app (если есть хотя бы одно admin-право) ===
|
|
2912
|
+
can_sync = cfg.has_permission("hub.admin")
|
|
2913
|
+
can_company_create = cfg.has_permission("hub.company_create")
|
|
2914
|
+
can_invite = cfg.has_permission("invite.manage") or cfg.is_hub_admin()
|
|
2915
|
+
can_yank = cfg.has_permission("skill.manage") or cfg.is_hub_admin()
|
|
2916
|
+
if can_sync or can_company_create or can_invite or can_yank:
|
|
2917
|
+
admin_app = typer.Typer(
|
|
2918
|
+
no_args_is_help=True,
|
|
2919
|
+
help="Admin команды (зависят от ваших прав)",
|
|
2920
|
+
)
|
|
2921
|
+
app.add_typer(admin_app, name="admin")
|
|
2922
|
+
# cli-kits W6: подкоманды admin → command_kit.gated. Предикаты —
|
|
2923
|
+
# предвычисленные булевы (can_invite включает is_hub_admin-fallback),
|
|
2924
|
+
# поэтому has_permission отдаём как lambda над готовым bool (точное
|
|
2925
|
+
# зеркало прежних `if can_x:`).
|
|
2926
|
+
gated(admin_app, permission="sync-skill",
|
|
2927
|
+
has_permission=lambda _p: can_sync,
|
|
2928
|
+
name="sync-skill")(cmd_admin_sync)
|
|
2929
|
+
gated(admin_app, permission="yank",
|
|
2930
|
+
has_permission=lambda _p: can_yank,
|
|
2931
|
+
name="yank")(cmd_admin_yank)
|
|
2932
|
+
gated(admin_app, permission="company-create",
|
|
2933
|
+
has_permission=lambda _p: can_company_create,
|
|
2934
|
+
name="company-create")(cmd_admin_company_create)
|
|
2935
|
+
gated(admin_app, permission="invite",
|
|
2936
|
+
has_permission=lambda _p: can_invite,
|
|
2937
|
+
name="invite")(cmd_admin_invite)
|
|
2938
|
+
|
|
2939
|
+
# --- P1 company ---
|
|
2940
|
+
# sub-app `company` (C2): show/switch — always-on для залогиненного
|
|
2941
|
+
# (бэк сам режет tenant-изоляцией); остальные подкоманды гейтятся
|
|
2942
|
+
# permissions-зеркалом backend-роутов. has_permission() уже включает
|
|
2943
|
+
# hub.admin bypass.
|
|
2944
|
+
from skills_hub_cli.commands import company as _company_mod
|
|
2945
|
+
|
|
2946
|
+
_company_mod.register(
|
|
2947
|
+
app,
|
|
2948
|
+
can_list=cfg.is_hub_admin(),
|
|
2949
|
+
can_create=cfg.has_permission("hub.company_create"),
|
|
2950
|
+
can_edit=cfg.has_permission("company.manage"),
|
|
2951
|
+
can_invite_links=(
|
|
2952
|
+
cfg.has_permission("company.manage")
|
|
2953
|
+
or cfg.has_permission("role.manage")
|
|
2954
|
+
),
|
|
2955
|
+
can_catalog_view=(
|
|
2956
|
+
cfg.has_permission("catalog.manage")
|
|
2957
|
+
or cfg.has_permission("catalog.view_all")
|
|
2958
|
+
),
|
|
2959
|
+
can_catalog_manage=cfg.has_permission("catalog.manage"),
|
|
2960
|
+
)
|
|
2961
|
+
|
|
2962
|
+
return app
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
app = build_app()
|
|
2966
|
+
|
|
2967
|
+
|
|
2968
|
+
if __name__ == "__main__":
|
|
2969
|
+
app()
|