style: единый вид списков (skills / agents / catalog)

- один рендерер render_listing() для всех списочных команд
- заголовок ВЕРХНИМ РЕГИСТРОМ, строка источника, легенда отметок, группы, итог, «куда ставится»
- skills list и agents list теперь одного вида: имя + мета + отметки + описание
- catalog list — только источники (состав смотреть в skills list), тот же стиль
- skills installed переведён на те же заголовки, убраны англ. вкрапления
- фикс: заголовок группы повторялся перед каждым элементом
- фикс: хвостовые пробелы — rstrip не работал из-за ANSI-reset, добавлен trim_line()
- README: раздел «Правила вывода списков»
This commit is contained in:
eka
2026-09-23 12:46:07 +05:00
parent da70365701
commit 270e006a7a
2 changed files with 236 additions and 91 deletions
+45 -4
View File
@@ -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
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` — что реально стоит
+189 -85
View File
@@ -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):