From 270e006a7adfcaa6dcb3684c9d6d9b5c5e3db53b Mon Sep 17 00:00:00 2001 From: eka Date: Wed, 23 Sep 2026 12:46:07 +0500 Subject: [PATCH] =?UTF-8?q?style:=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B2=D0=B8=D0=B4=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20(skills=20/=20agents=20/=20catalog)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - один рендерер render_listing() для всех списочных команд - заголовок ВЕРХНИМ РЕГИСТРОМ, строка источника, легенда отметок, группы, итог, «куда ставится» - skills list и agents list теперь одного вида: имя + мета + отметки + описание - catalog list — только источники (состав смотреть в skills list), тот же стиль - skills installed переведён на те же заголовки, убраны англ. вкрапления - фикс: заголовок группы повторялся перед каждым элементом - фикс: хвостовые пробелы — rstrip не работал из-за ANSI-reset, добавлен trim_line() - README: раздел «Правила вывода списков» --- README.md | 53 ++++++++-- scripts/harness | 274 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 236 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 3e1c5fd..9e2abf9 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,39 @@ dev/harness/ ./scripts/harness catalog list # Источники каталога ``` +## Правила вывода списков + +Все списочные команды идут через один рендерер — `render_listing()`. Новую +списочную команду нужно добавлять так же: собрать `rows` и вызвать рендерер, +а не печатать таблицу самой. + +``` +СКИЛЛЫ — доступно к установке ← 1. заголовок ВЕРХНИМ РЕГИСТРОМ + Каталог: /mnt/data/Agent/dev/harness/catalog ← 2. источник, dim + ✓ — установлено, · — нет ← 3. легенда, если есть отметки + + jakubkrehel-skills (11 скиллов, 267330e) ← 4. группа, мета в скобках + ├─ better-ui hermes · opencode ✓ ← 5. имя, мета, отметки + │ Polishes and improves the UI in your… ← 6. описание, обрезка до 72 + + Итого: 12 скиллов, установлено 0 ← 7. итог + hermes ~/.hermes/skills/catalog ← куда ставится, dim +``` + +1. **Заголовок** — ВЕРХНИМ РЕГИСТРОМ, формат `<ОБЪЕКТ> — что именно`. +2. **Источник** — второй строкой, dim: `Каталог: <путь>` / `Источник: <путь>`. +3. **Легенда отметок** — `✓ — установлено, · — нет`, только если отметки есть. +4. **Группы** — жёлтым, метаданные в скобках (кол-во, sha, `⚠ сдвинут`), между + группами пустая строка. Нет групп — строки идут сразу после легенды. +5. **Элемент** — `├─ <имя>` (cyan, выравнивание по самому длинному имени), затем + мета (dim, выровнена), затем отметки `<цель> ✓` / `<цель> ·`, затем пометка (yellow). +6. **Описание** — строкой ниже, dim, обрезано до 72 символов. +7. **Итог** — `Итого: N <единица>, установлено M`, ниже dim-строками — куда ставится. +8. **Только русский.** Никаких `Available` / `Installed` вперемешку с русским. +9. **Порядок отметок** = порядок целей из `--target` / `--scope`. + +Правила продублированы комментарием в `scripts/harness` над `render_listing()`. + ## `skills` — скиллы из каталога ### `skills list` — что доступно к установке @@ -62,17 +95,25 @@ dev/harness/ ``` ``` -Доступно из каталога (/mnt/data/Agent/dev/harness/catalog) +СКИЛЛЫ — доступно к установке + Каталог: /mnt/data/Agent/dev/harness/catalog ✓ — установлено, · — нет - jakubkrehel-skills 11 скиллов 267330e - ├─ better-ui hermes · opencode ✓ - ├─ better-colors hermes · opencode · + jakubkrehel-skills (11 скиллов, 267330e) + ├─ better-ui hermes · opencode ✓ + │ Polishes and improves the UI in your project… + ├─ better-colors hermes · opencode · + │ Helps you build a color system… ... + + Итого: 12 скиллов, установлено 1 + Поставить: harness skills install <источник> <скилл>|--all + hermes ~/.hermes/skills/catalog + opencode ~/.claude/skills ~/.config/opencode/skills ``` -Для git-источников показывается короткий sha; `⚠ сдвинут` — submodule ушёл вперёд -и это ещё не закоммичено. +Для git-источников в скобках группы виден короткий sha; `⚠ сдвинут` — submodule +ушёл вперёд и это ещё не закоммичено. ### `skills installed` — что реально стоит diff --git a/scripts/harness b/scripts/harness index b967eb7..6a582cc 100755 --- a/scripts/harness +++ b/scripts/harness @@ -129,6 +129,13 @@ def read_frontmatter_field(path, field, width=None): return val[:width] if width else val +def short_path(path): + """~ вместо домашнего каталога.""" + p = str(path) + home = str(Path.home()) + return "~" + p[len(home):] if p.startswith(home) else p + + # ─── Catalog ────────────────────────────────────────────────────── @@ -188,34 +195,29 @@ def source_git_state(entry): def list_catalog(): - """Источники каталога и их содержимое.""" - entries = catalog_entries() - print(c("Каталог источников", "bold"), c(f"({CATALOG_DIR})", "dim")) - if not entries: - print(" (пусто)") - return - total = 0 - for name, entry, skills in entries: + """Источники каталога. Состав каждого — в `skills list`.""" + rows = [] + total_skills = 0 + for name, entry, skills in catalog_entries(): sha, drift = source_git_state(entry) - tail = [] - if sha: - tail.append(c(sha, "dim")) - if drift: - tail.append(c("⚠ сдвинут, не закоммичен", "yellow")) - suffix = (" " + " ".join(tail)) if tail else "" if skills: - print(f" {c(name, 'bold')} {c(str(len(skills)) + ' скиллов', 'dim')}{suffix}") - for s in skills: - print(f" ├─ {s}") - total += len(skills) + meta = f"{len(skills)} скиллов" + total_skills += len(skills) elif (entry / "SKILL.md").exists(): - nm = read_frontmatter_field(entry / "SKILL.md", "name") or name - print(f" {c(name, 'bold')} {c('SKILL.md в корне репы', 'yellow')}{suffix}") - print(f" ├─ {nm} {c('(ставится вручную)', 'dim')}") - total += 1 + meta = "SKILL.md в корне" + total_skills += 1 else: - print(f" {c(name, 'bold')} {c('нет skills/', 'dim')}{suffix}") - print(f"\n Всего скиллов: {c(str(total), 'cyan')}") + meta = "пусто" + if sha: + meta += f", {sha}" + rows.append((None, name, meta, None, None, "⚠ сдвинут" if drift else None)) + + render_listing( + "КАТАЛОГ — источники", + f"Каталог: {CATALOG_DIR}", + rows, + footer=f"Итого: {len(rows)} источников, {total_skills} скиллов", + ) def update_catalog(): @@ -392,85 +394,178 @@ def count_skills(target): return len(installed_skill_names(target)) +# ─── Единый вид списков ─────────────────────────────────────────── +# +# Правила вывода (обязательны для всех списочных команд): +# 1. Заголовок ВЕРХНИМ РЕГИСТРОМ: "<ОБЪЕКТ> — что именно". +# 2. Вторая строка (dim) — источник: "Каталог: <путь>" / "Источник: <путь>". +# 3. Есть отметки установки → строка-легенда "✓ — установлено, · — нет". +# 4. Группы — жёлтым, метаданные группы в скобках; между группами пустая строка. +# 5. Элемент: "├─ <имя>" (cyan, выровнено), затем мета (dim, выровнена), +# затем отметки "<цель> ✓" / "<цель> ·", затем пометка (yellow). +# 6. Описание — строкой ниже, dim, обрезано до 72 символов. +# 7. Итог: "Итого: N <единица>, установлено M". +# 8. Всё на русском, без вкраплений "Available"/"Installed". +# 9. Порядок отметок = порядок целей из --target / --scope. + +MARK_ON = "✓" +MARK_OFF = "·" +DESC_WIDTH = 72 + + +def trim_line(s): + """rstrip с учётом завершающего ANSI-reset (иначе хвостовые пробелы остаются).""" + reset = COLOR["reset"] + if s.endswith(reset): + return s[: -len(reset)].rstrip() + reset + return s.rstrip() + + +def render_listing(title, source, rows, targets=(), footer=None, dests=()): + """Единый вид списков «доступно к установке». + + rows: список кортежей (group, name, meta, marks, desc, note) + group — заголовок группы или None + meta — короткая мета справа от имени (или None) + marks — {цель: bool} или None + desc — описание строкой ниже (или None) + note — пометка жёлтым в конце строки (или None) + dests: [(цель, путь)] — куда ставится, dim-строками после итога + """ + print(c(title, "bold")) + if source: + print(c(" " + source, "dim")) + if targets: + print(c(f" {MARK_ON} — установлено, {MARK_OFF} — нет", "dim")) + print() + + if not rows: + print(" (пусто)") + else: + width_name = max(len(r[1]) for r in rows) + metas = [r[2] or "" for r in rows] + width_meta = max(len(m) for m in metas) if any(metas) else 0 + + prev_group = None + printed = False + for group, name, meta, marks, desc, note in rows: + if group != prev_group: + if printed: + print() + if group: + print(f" {c(group, 'yellow')}") + prev_group = group + indent = " " if group else " " + + line = f"{indent}├─ {c(name.ljust(width_name), 'cyan')}" + if width_meta: + line += " " + c((meta or "").ljust(width_meta), "dim") + if marks: + line += " " + " ".join( + c(f"{t} {MARK_ON}", "green") if marks.get(t) else c(f"{t} {MARK_OFF}", "dim") + for t in targets + ) + if note: + line += " " + c(note, "yellow") + print(trim_line(line)) + if desc: + print(f"{indent}│ {c(desc[:DESC_WIDTH], 'dim')}") + printed = True + + if footer: + print() + for line in footer.splitlines(): + print(" " + line) + for label, path in dests: + print(c(f" {label:<18s} {path}", "dim")) + + def list_skills(target="all"): - """Каталог: что доступно к установке/обновлению, и где уже стоит.""" + """Каталог: что доступно к установке, и где уже стоит.""" targets = resolve_targets(target) installed = {t: installed_skill_names(t) for t in targets} - print(c("Доступно из каталога", "bold"), c(f"({CATALOG_DIR})", "dim")) - print(c(" ✓ — установлено, · — нет", "dim")) - - entries = catalog_entries() - if not entries: - print(" (каталог пуст)") - return - + rows = [] total = 0 have = 0 - for name, entry, skills in entries: + for name, entry, skills in catalog_entries(): sha, drift = source_git_state(entry) - tail = [] + bits = [] + if skills: + bits.append(f"{len(skills)} скиллов") if sha: - tail.append(c(sha, "dim")) + bits.append(sha) if drift: - tail.append(c("⚠ сдвинут", "yellow")) - suffix = (" " + " ".join(tail)) if tail else "" + bits.append("⚠ сдвинут") + group = name + (f" ({', '.join(bits)})" if bits else "") if skills: - print(f"\n {c(name, 'bold')} {c(str(len(skills)) + ' скиллов', 'dim')}{suffix}") for s in skills: - marks = " ".join( - c(f"{t} ✓", "green") if s in installed[t] else c(f"{t} ·", "dim") - for t in targets - ) - print(f" ├─ {c(s.ljust(28), 'cyan')} {marks}") + marks = {t: s in installed[t] for t in targets} + desc = read_frontmatter_field(entry / "skills" / s / "SKILL.md", "description") + rows.append((group, s, None, marks, desc, None)) total += 1 - if any(s in installed[t] for t in targets): + if any(marks.values()): have += 1 elif (entry / "SKILL.md").exists(): nm = read_frontmatter_field(entry / "SKILL.md", "name") or name - print(f"\n {c(name, 'bold')} {c('SKILL.md в корне репы', 'yellow')}{suffix}") - print(f" ├─ {c(nm.ljust(28), 'cyan')} " - f"{c('ставится вручную', 'dim')}") + desc = read_frontmatter_field(entry / "SKILL.md", "description") + rows.append((group, nm, None, None, desc, "ставится вручную")) total += 1 if nm in installed.get("opencode", set()): have += 1 else: - print(f"\n {c(name, 'bold')} {c('нет skills/', 'dim')}{suffix}") + rows.append((group, "(нет SKILL.md)", None, None, None, "нечего ставить")) + total += 1 - print(f"\n В каталоге: {c(str(total), 'cyan')} установлено: {c(str(have), 'cyan')}") + footer = f"Итого: {total} скиллов, установлено {have}" if have < total: - print(c(f" Поставить: harness skills install <источник> <скилл>|--all", "dim")) + footer += "\nПоставить: harness skills install <источник> <скилл>|--all" + + render_listing( + "СКИЛЛЫ — доступно к установке", + f"Каталог: {CATALOG_DIR}", + rows, + targets, + footer=footer, + dests=[ + ("hermes", short_path(HERMES_SKILLS / "catalog")), + ("opencode", " ".join(short_path(d) for d in OPENCODE_SKILLS_DIRS)), + ], + ) def list_skills_installed(target="all"): """Что реально установлено у агентов (включая не из каталога).""" targets = resolve_targets(target) + roots = {"hermes": [HERMES_SKILLS], "opencode": OPENCODE_SKILLS_DIRS} for t in targets: - if t == "hermes": - print(f"\n{c('Hermes skills', 'bold')} ({HERMES_SKILLS}):") - _print_skill_tree(HERMES_SKILLS) - else: - for skill_root in OPENCODE_SKILLS_DIRS: - if skill_root.exists(): - print(f"\n{c('OpenCode skills', 'bold')} ({skill_root}):") - _print_skill_tree(skill_root) + print(c(f"УСТАНОВЛЕНО — СКИЛЛЫ ({t})", "bold")) + existing = [r for r in roots[t] if r.exists()] + if not existing: + print(c(" (каталоги не найдены)", "dim")) + print() + continue + for root in existing: + print(c(f" {root}", "dim")) + _print_skill_tree(root) + print() def _print_skill_tree(root, indent=""): if not root.exists(): - print(f"{indent} (not found)") + print(f"{indent} (не найдено)") return count = 0 for entry in sorted(root.iterdir()): sk = entry / "SKILL.md" if sk.exists(): name = entry.name - desc = read_frontmatter_field(sk, "description", 80) + desc = read_frontmatter_field(sk, "description") sym = " → " + os.readlink(str(entry)) if entry.is_symlink() else "" print(f"{indent} ├─ {c(name, 'cyan')}{c(sym, 'dim')}") if desc: - print(f"{indent} │ {c(desc[:80], 'dim')}") + print(f"{indent} │ {c(desc[:DESC_WIDTH], 'dim')}") count += 1 elif entry.is_dir() and not entry.name.startswith("."): sub = sum(1 for _ in entry.rglob("SKILL.md")) @@ -478,7 +573,7 @@ def _print_skill_tree(root, indent=""): print(f"{indent} {c(entry.name, 'yellow')}/") _print_skill_tree(entry, indent + " ") if count == 0 and indent == "": - print(f" (no skills installed)") + print(f" (ничего не установлено)") def resolve_targets(target): @@ -520,29 +615,38 @@ def agent_dest_dirs(scope="global", project=None): def list_agents(scope="all", project=None): - """Показать доступных в репо и установленных агентов.""" - avail = available_agents() - print(f"{c('Available agents', 'bold')} ({AGENTS_SRC}):") - if not avail: - print(" (none)") - for p in avail: - mode = read_frontmatter_field(p, "mode") or "?" - desc = read_frontmatter_field(p, "description", 72) - print(f" ├─ {c(p.stem, 'cyan')} {c('[' + mode + ']', 'dim')}") - if desc: - print(f" │ {c(desc, 'dim')}") + """Доступные в репо и установленные агенты — единый вид со списком скиллов.""" + dests = agent_dest_dirs(scope, project) + targets = [label for label, _ in dests] + installed = { + label: ({p.stem for p in d.glob("*.md")} if d.exists() else set()) + for label, d in dests + } - for label, d in agent_dest_dirs(scope, project): - print(f"\n{c('Installed — ' + label, 'bold')} ({d}):") - if not d.exists(): - print(" (dir not found)") - continue - items = sorted(d.glob("*.md")) - if not items: - print(" (none)") - for p in items: - sym = " → " + os.readlink(str(p)) if p.is_symlink() else "" - print(f" ├─ {c(p.stem, 'cyan')}{c(sym, 'dim')}") + avail = available_agents() + rows = [] + have = 0 + for p in avail: + marks = {t: p.stem in installed[t] for t in targets} + rows.append(( + None, + p.stem, + read_frontmatter_field(p, "mode") or "?", + marks, + read_frontmatter_field(p, "description"), + None, + )) + if any(marks.values()): + have += 1 + + render_listing( + "АГЕНТЫ — доступно к установке", + f"Источник: {AGENTS_SRC}", + rows, + targets, + footer=f"Итого: {len(avail)} агентов, установлено {have}", + dests=[(label, short_path(d)) for label, d in dests], + ) def install_agent(name=None, install_all=False, scope="global", project=None):