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:
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user