From c94fbce56f71b4bf2617c01d9e557c377685140a Mon Sep 17 00:00:00 2001 From: eka Date: Wed, 23 Sep 2026 13:21:26 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20catalog=20init=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B5=D1=81=D1=82=D1=8F=D0=BD=D1=83=D1=82=D1=8B=D1=85?= =?UTF-8?q?=20=D1=81=D0=B0=D0=B1=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills list / catalog list распознают неинициализированные сабмодули: жёлтое предупреждение, строка «не инициализирован», в итоге — «Стянуть: harness catalog init» - новый action catalog init: стягивает сабмодули на зафиксированных коммитах, идемпотентен - catalog update теперь работает из свежего клона: сначала init, потом --remote - skills install подсказывает про catalog init, если источники не стянуты - русское склонение чисел (plural): 1 скилл / 2 скилла / 5 скиллов - README: раздел «Свежий клон», правило 10 в «Правилах вывода списков» --- README.md | 35 ++++++++++- scripts/harness | 158 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 171 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9e2abf9..0e85b0b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ dev/harness/ | `skills` | скиллы из каталога | `list`, `installed`, `install`, `remove` | | `agents` | агенты (только OpenCode) | `list`, `install`, `remove` | | `mcp` | MCP-серверы Hermes | `list`, `add`, `rm` | -| `catalog` | внешние репозитории каталога | `list`, `update` | +| `catalog` | внешние репозитории каталога | `list`, `init`, `update` | | `hermes` | сам Hermes | `config`, `restart`, `update` | | `opencode` | сам OpenCode | `install`, `restart` | @@ -80,6 +80,7 @@ dev/harness/ 7. **Итог** — `Итого: N <единица>, установлено M`, ниже dim-строками — куда ставится. 8. **Только русский.** Никаких `Available` / `Installed` вперемешку с русским. 9. **Порядок отметок** = порядок целей из `--target` / `--scope`. +10. **Числа — со склонением** через `plural()`: 1 скилл, 2 скилла, 5 скиллов. Правила продублированы комментарием в `scripts/harness` над `render_listing()`. @@ -196,10 +197,38 @@ dev/harness/ ## `catalog` — внешние репозитории ```bash -./scripts/harness catalog list # источники, содержимое, sha, признак «сдвинут» -./scripts/harness catalog update # git submodule update --remote / git pull для клонов +./scripts/harness catalog list # источники, sha, признак «сдвинут» +./scripts/harness catalog init # стянуть submodule'ы на зафиксированных коммитах +./scripts/harness catalog update # подтянуть свежие коммиты upstream (потом закоммитить) ``` +### Свежий клон + +Submodule'ы **не** тянутся при обычном `git clone` — каталоги `catalog/*/` окажутся +пустыми. `skills list` это распознаёт и подсказывает, что делать: + +``` +СКИЛЛЫ — доступно к установке + Каталог: /path/to/catalog + ✓ — установлено, · — нет + ⚠ submodule'ы не стянуты: jakubkrehel-skills + + ├─ jakubkrehel-skills не инициализирован + + Итого: 0 скиллов, установлено 0 + Стянуть: harness catalog init +``` + +Дальше — `harness catalog init`. Либо сразу клонировать с сабмодулями: + +```bash +git clone --recurse-submodules git@git.itphx.loc:eka/harness.git +``` + +`catalog init` идемпотентен: если всё на месте, просто сообщит об этом. +`catalog update`, в отличие от него, двигает коммиты на актуальные upstream — +сначала стягивает недостающие, потом обновляет. + ## `hermes` / `opencode` — сам агент ```bash diff --git a/scripts/harness b/scripts/harness index 6a582cc..32ba9ba 100755 --- a/scripts/harness +++ b/scripts/harness @@ -17,6 +17,7 @@ Harness — менеджер скиллов, агентов, MCP-серверо harness mcp add [--env KEY=VAL] [--arg ...] harness mcp rm harness catalog list + harness catalog init harness catalog update harness hermes config [--set key=value ...] harness hermes restart @@ -136,6 +137,19 @@ def short_path(path): return "~" + p[len(home):] if p.startswith(home) else p +def plural(n, one, few, many): + """Русская форма по числу: 1 скилл, 2 скилла, 5 скиллов.""" + n = abs(int(n)) % 100 + if 11 <= n <= 14: + return many + n %= 10 + if n == 1: + return one + if 2 <= n <= 4: + return few + return many + + # ─── Catalog ────────────────────────────────────────────────────── @@ -196,13 +210,22 @@ def source_git_state(entry): def list_catalog(): """Источники каталога. Состав каждого — в `skills list`.""" + subs = _submodule_state() rows = [] + uninit = [] total_skills = 0 for name, entry, skills in catalog_entries(): + rel = str(entry.relative_to(HARNESS_ROOT)) + if subs.get(rel) == "uninit": + uninit.append(name) + rows.append((None, name, "не инициализирован", None, None, None)) + continue + sha, drift = source_git_state(entry) if skills: - meta = f"{len(skills)} скиллов" - total_skills += len(skills) + n = len(skills) + meta = f"{n} {plural(n, 'скилл', 'скилла', 'скиллов')}" + total_skills += n elif (entry / "SKILL.md").exists(): meta = "SKILL.md в корне" total_skills += 1 @@ -212,16 +235,82 @@ def list_catalog(): meta += f", {sha}" rows.append((None, name, meta, None, None, "⚠ сдвинут" if drift else None)) + footer = (f"Итого: {len(rows)} {plural(len(rows), 'источник', 'источника', 'источников')}, " + f"{total_skills} {plural(total_skills, 'скилл', 'скилла', 'скиллов')}") + if uninit: + footer += "\nСтянуть: harness catalog init" + render_listing( "КАТАЛОГ — источники", f"Каталог: {CATALOG_DIR}", rows, - footer=f"Итого: {len(rows)} источников, {total_skills} скиллов", + footer=footer, + notice=f"submodule'ы не стянуты: {', '.join(uninit)}" if uninit else None, ) +def _submodule_state(): + """Состояние submodule'ов: {путь: 'ok' | 'uninit' | 'drift'}. + + Опирается на префикс в `git submodule status`: + '-' не инициализирован, '+' ушёл от зафиксированного коммита. + """ + if not (HARNESS_ROOT / ".gitmodules").exists(): + return {} + rc, out, err = run(["git", "submodule", "status"], cwd=HARNESS_ROOT) + if rc != 0: + return {} + state = {} + for line in out.splitlines(): + line = line.rstrip() + if not line: + continue + flag, rest = line[0], line[1:] + if flag not in "-+U": + flag, rest = " ", line + parts = rest.split() + if len(parts) >= 2: + state[parts[1]] = {"-": "uninit", "+": "drift"}.get(flag, "ok") + return state + + +def _print_submodule_status(): + rc, status, _ = run(["git", "submodule", "status"], cwd=HARNESS_ROOT) + if rc == 0: + for line in status.splitlines(): + print(f" {line}") + + +def init_catalog(): + """Стянуть submodule'ы на зафиксированных в репе коммитах.""" + if not CATALOG_DIR.exists(): + warn("catalog/ doesn't exist") + return + + subs = _submodule_paths() + if not subs: + warn("в .gitmodules нет источников — каталог пуст") + info(f"Добавь: git submodule add {CATALOG_DIR}/<имя>") + return + + state = _submodule_state() + if state and not any(s == "uninit" for s in state.values()): + info(f"Все источники на месте ({len(subs)})") + _print_submodule_status() + return + + info(f"Стягиваю источники ({len(subs)}) на зафиксированных коммитах...") + rc, out, err = run(["git", "submodule", "update", "--init", "--recursive"], cwd=HARNESS_ROOT) + if rc == 0: + _print_submodule_status() + ok("каталог готов") + else: + warn(f"submodule update --init: {err[:200]}") + info("Если клонировали без сабмодулей — проверь доступ к их URL") + + def update_catalog(): - """Обновить каталог: submodule'ы — через суперпроект, клоны — git pull.""" + """Обновить каталог: стянуть недостающее и подтянуть свежие коммиты upstream.""" if not CATALOG_DIR.exists(): warn("catalog/ doesn't exist") return @@ -229,16 +318,19 @@ def update_catalog(): subs = _submodule_paths() if subs: - info(f"Обновляю submodule'ы ({len(subs)})...") + state = _submodule_state() + if any(s == "uninit" for s in state.values()): + info("Часть источников не стянута — сначала инициализирую...") + run(["git", "submodule", "update", "--init", "--recursive"], cwd=HARNESS_ROOT) + + info(f"Обновляю источники ({len(subs)}) до актуальных коммитов upstream...") rc, out, err = run( - ["git", "submodule", "update", "--remote", "--init", "--recursive"], + ["git", "submodule", "update", "--remote", "--recursive"], cwd=HARNESS_ROOT, ) if rc == 0: - rc2, status, _ = run(["git", "submodule", "status"], cwd=HARNESS_ROOT) - for line in status.splitlines(): - print(f" {line}") - ok("submodule'ы обновлены (изменения — закоммить отдельно)") + _print_submodule_status() + ok("источники обновлены (изменения — закоммить отдельно)") else: warn(f"submodule update: {err[:200]}") @@ -279,8 +371,10 @@ def install_skill(skill_source_name, skill_name, target): skill_dir = candidate if not skill_dir: - die(f"Скилл '{skill_source_name}/{skill_name}' не найден в каталоге. " - f"Что есть — 'harness skills list'.") + hint = "Что есть — 'harness skills list'." + if any(s == "uninit" for s in _submodule_state().values()): + hint = "Источники каталога не стянуты — выполни: harness catalog init" + die(f"Скилл '{skill_source_name}/{skill_name}' не найден в каталоге. {hint}") targets = resolve_targets(target) @@ -407,6 +501,7 @@ def count_skills(target): # 7. Итог: "Итого: N <единица>, установлено M". # 8. Всё на русском, без вкраплений "Available"/"Installed". # 9. Порядок отметок = порядок целей из --target / --scope. +# 10. Числа — со склонением через plural(): 1 скилл, 2 скилла, 5 скиллов. MARK_ON = "✓" MARK_OFF = "·" @@ -421,7 +516,7 @@ def trim_line(s): return s.rstrip() -def render_listing(title, source, rows, targets=(), footer=None, dests=()): +def render_listing(title, source, rows, targets=(), footer=None, dests=(), notice=None): """Единый вид списков «доступно к установке». rows: список кортежей (group, name, meta, marks, desc, note) @@ -431,12 +526,15 @@ def render_listing(title, source, rows, targets=(), footer=None, dests=()): desc — описание строкой ниже (или None) note — пометка жёлтым в конце строки (или None) dests: [(цель, путь)] — куда ставится, dim-строками после итога + notice: предупреждение жёлтым сразу под легендой (или None) """ print(c(title, "bold")) if source: print(c(" " + source, "dim")) if targets: print(c(f" {MARK_ON} — установлено, {MARK_OFF} — нет", "dim")) + if notice: + print(c(" ⚠ " + notice, "yellow")) print() if not rows: @@ -484,15 +582,24 @@ def list_skills(target="all"): """Каталог: что доступно к установке, и где уже стоит.""" targets = resolve_targets(target) installed = {t: installed_skill_names(t) for t in targets} + subs = _submodule_state() rows = [] + uninit = [] total = 0 have = 0 for name, entry, skills in catalog_entries(): + rel = str(entry.relative_to(HARNESS_ROOT)) + if subs.get(rel) == "uninit": + uninit.append(name) + rows.append((None, name, "не инициализирован", None, None, None)) + continue + sha, drift = source_git_state(entry) bits = [] if skills: - bits.append(f"{len(skills)} скиллов") + n = len(skills) + bits.append(f"{n} {plural(n, 'скилл', 'скилла', 'скиллов')}") if sha: bits.append(sha) if drift: @@ -518,8 +625,16 @@ def list_skills(target="all"): rows.append((group, "(нет SKILL.md)", None, None, None, "нечего ставить")) total += 1 - footer = f"Итого: {total} скиллов, установлено {have}" - if have < total: + notice = None + if uninit: + notice = (f"submodule'ы не стянуты: {', '.join(uninit)}") + elif not rows: + notice = f"каталог пуст — добавь источник: git submodule add {CATALOG_DIR}" + + footer = f"Итого: {total} {plural(total, 'скилл', 'скилла', 'скиллов')}, установлено {have}" + if uninit: + footer += "\nСтянуть: harness catalog init" + elif have < total: footer += "\nПоставить: harness skills install <источник> <скилл>|--all" render_listing( @@ -528,6 +643,7 @@ def list_skills(target="all"): rows, targets, footer=footer, + notice=notice, dests=[ ("hermes", short_path(HERMES_SKILLS / "catalog")), ("opencode", " ".join(short_path(d) for d in OPENCODE_SKILLS_DIRS)), @@ -644,7 +760,7 @@ def list_agents(scope="all", project=None): f"Источник: {AGENTS_SRC}", rows, targets, - footer=f"Итого: {len(avail)} агентов, установлено {have}", + footer=f"Итого: {len(avail)} {plural(len(avail), 'агент', 'агента', 'агентов')}, установлено {have}", dests=[(label, short_path(d)) for label, d in dests], ) @@ -974,6 +1090,7 @@ def main(): harness mcp rm my-server harness catalog list + harness catalog init harness catalog update harness hermes config --set model.default=gpt-4o @@ -1051,8 +1168,9 @@ def main(): # ── catalog cat_p = sub.add_parser("catalog", help="Каталог внешних репозиториев") cat_sub = cat_p.add_subparsers(dest="action", required=True, metavar="<действие>") - cat_sub.add_parser("list", help="Источники каталога и их содержимое") - cat_sub.add_parser("update", help="Обновить submodule'ы / клоны") + cat_sub.add_parser("list", help="Источники каталога") + cat_sub.add_parser("init", help="Стянуть submodule'ы на зафиксированных коммитах") + cat_sub.add_parser("update", help="Подтянуть свежие коммиты upstream (потом закоммитить)") # ── hermes hermes_p = sub.add_parser("hermes", help="Hermes: конфиг и сервисы") @@ -1122,6 +1240,8 @@ def main(): elif obj == "catalog": if action == "list": list_catalog() + elif action == "init": + init_catalog() elif action == "update": update_catalog()