feat: catalog init и подсказка при нестянутых сабмодулях

- skills list / catalog list распознают неинициализированные сабмодули:
  жёлтое предупреждение, строка «не инициализирован»,
  в итоге — «Стянуть: harness catalog init»
- новый action catalog init: стягивает сабмодули на зафиксированных
  коммитах, идемпотентен
- catalog update теперь работает из свежего клона:
  сначала init, потом --remote
- skills install подсказывает про catalog init, если источники не стянуты
- русское склонение чисел (plural): 1 скилл / 2 скилла / 5 скиллов
- README: раздел «Свежий клон», правило 10 в «Правилах вывода списков»
This commit is contained in:
eka
2026-09-23 13:21:26 +05:00
parent 270e006a7a
commit c94fbce56f
2 changed files with 171 additions and 22 deletions
+139 -19
View File
@@ -17,6 +17,7 @@ Harness — менеджер скиллов, агентов, MCP-серверо
harness mcp add <name> <url|command> [--env KEY=VAL] [--arg ...]
harness mcp rm <name>
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 <url> {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 <url> {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()