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.
Files changed (59) hide show
  1. skillery_cli-0.4.0.dist-info/METADATA +185 -0
  2. skillery_cli-0.4.0.dist-info/RECORD +59 -0
  3. skillery_cli-0.4.0.dist-info/WHEEL +4 -0
  4. skillery_cli-0.4.0.dist-info/entry_points.txt +3 -0
  5. skillery_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. skills_hub_cli/__init__.py +2 -0
  7. skills_hub_cli/__main__.py +2969 -0
  8. skills_hub_cli/_deprecated_alias.py +22 -0
  9. skills_hub_cli/commands/__init__.py +6 -0
  10. skills_hub_cli/commands/_common.py +135 -0
  11. skills_hub_cli/commands/account.py +294 -0
  12. skills_hub_cli/commands/analytics.py +198 -0
  13. skills_hub_cli/commands/collection.py +917 -0
  14. skills_hub_cli/commands/comment.py +213 -0
  15. skills_hub_cli/commands/company.py +635 -0
  16. skills_hub_cli/commands/contrib.py +66 -0
  17. skills_hub_cli/commands/daemon.py +427 -0
  18. skills_hub_cli/commands/doctor.py +285 -0
  19. skills_hub_cli/commands/event.py +190 -0
  20. skills_hub_cli/commands/member.py +872 -0
  21. skills_hub_cli/commands/onboard.py +239 -0
  22. skills_hub_cli/commands/permission.py +180 -0
  23. skills_hub_cli/commands/rate.py +97 -0
  24. skills_hub_cli/commands/scaffold.py +326 -0
  25. skills_hub_cli/commands/suggest.py +393 -0
  26. skills_hub_cli/commands/ticket.py +332 -0
  27. skills_hub_cli/config.py +558 -0
  28. skills_hub_cli/core/__init__.py +0 -0
  29. skills_hub_cli/core/_kit_config.py +73 -0
  30. skills_hub_cli/core/agents/__init__.py +25 -0
  31. skills_hub_cli/core/agents/antigravity.py +4 -0
  32. skills_hub_cli/core/agents/base.py +4 -0
  33. skills_hub_cli/core/agents/claude_code.py +4 -0
  34. skills_hub_cli/core/agents/codex.py +4 -0
  35. skills_hub_cli/core/agents/detect.py +13 -0
  36. skills_hub_cli/core/deps_installer.py +16 -0
  37. skills_hub_cli/core/installer.py +77 -0
  38. skills_hub_cli/core/linker.py +16 -0
  39. skills_hub_cli/core/local_collections.py +26 -0
  40. skills_hub_cli/core/manifest_builder.py +18 -0
  41. skills_hub_cli/core/mcp_register.py +16 -0
  42. skills_hub_cli/core/onboarding.py +155 -0
  43. skills_hub_cli/core/path_store.py +29 -0
  44. skills_hub_cli/core/project_manifest.py +30 -0
  45. skills_hub_cli/core/secret_scan.py +345 -0
  46. skills_hub_cli/core/skill_filter.py +13 -0
  47. skills_hub_cli/core/suggest.py +131 -0
  48. skills_hub_cli/core/tooling_install.py +22 -0
  49. skills_hub_cli/core/transport.py +1448 -0
  50. skills_hub_cli/daemon/__init__.py +30 -0
  51. skills_hub_cli/daemon/autostart.py +357 -0
  52. skills_hub_cli/daemon/backoff.py +48 -0
  53. skills_hub_cli/daemon/daemon_runner.py +293 -0
  54. skills_hub_cli/daemon/event_collector.py +196 -0
  55. skills_hub_cli/daemon/event_guard.py +167 -0
  56. skills_hub_cli/daemon/event_sender.py +174 -0
  57. skills_hub_cli/daemon/instrumentation.py +104 -0
  58. skills_hub_cli/output.py +102 -0
  59. 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()