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:
@@ -25,7 +25,7 @@ dev/harness/
|
|||||||
| `skills` | скиллы из каталога | `list`, `installed`, `install`, `remove` |
|
| `skills` | скиллы из каталога | `list`, `installed`, `install`, `remove` |
|
||||||
| `agents` | агенты (только OpenCode) | `list`, `install`, `remove` |
|
| `agents` | агенты (только OpenCode) | `list`, `install`, `remove` |
|
||||||
| `mcp` | MCP-серверы Hermes | `list`, `add`, `rm` |
|
| `mcp` | MCP-серверы Hermes | `list`, `add`, `rm` |
|
||||||
| `catalog` | внешние репозитории каталога | `list`, `update` |
|
| `catalog` | внешние репозитории каталога | `list`, `init`, `update` |
|
||||||
| `hermes` | сам Hermes | `config`, `restart`, `update` |
|
| `hermes` | сам Hermes | `config`, `restart`, `update` |
|
||||||
| `opencode` | сам OpenCode | `install`, `restart` |
|
| `opencode` | сам OpenCode | `install`, `restart` |
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ dev/harness/
|
|||||||
7. **Итог** — `Итого: N <единица>, установлено M`, ниже dim-строками — куда ставится.
|
7. **Итог** — `Итого: N <единица>, установлено M`, ниже dim-строками — куда ставится.
|
||||||
8. **Только русский.** Никаких `Available` / `Installed` вперемешку с русским.
|
8. **Только русский.** Никаких `Available` / `Installed` вперемешку с русским.
|
||||||
9. **Порядок отметок** = порядок целей из `--target` / `--scope`.
|
9. **Порядок отметок** = порядок целей из `--target` / `--scope`.
|
||||||
|
10. **Числа — со склонением** через `plural()`: 1 скилл, 2 скилла, 5 скиллов.
|
||||||
|
|
||||||
Правила продублированы комментарием в `scripts/harness` над `render_listing()`.
|
Правила продублированы комментарием в `scripts/harness` над `render_listing()`.
|
||||||
|
|
||||||
@@ -196,10 +197,38 @@ dev/harness/
|
|||||||
## `catalog` — внешние репозитории
|
## `catalog` — внешние репозитории
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/harness catalog list # источники, содержимое, sha, признак «сдвинут»
|
./scripts/harness catalog list # источники, sha, признак «сдвинут»
|
||||||
./scripts/harness catalog update # git submodule update --remote / git pull для клонов
|
./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` — сам агент
|
## `hermes` / `opencode` — сам агент
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+139
-19
@@ -17,6 +17,7 @@ Harness — менеджер скиллов, агентов, MCP-серверо
|
|||||||
harness mcp add <name> <url|command> [--env KEY=VAL] [--arg ...]
|
harness mcp add <name> <url|command> [--env KEY=VAL] [--arg ...]
|
||||||
harness mcp rm <name>
|
harness mcp rm <name>
|
||||||
harness catalog list
|
harness catalog list
|
||||||
|
harness catalog init
|
||||||
harness catalog update
|
harness catalog update
|
||||||
harness hermes config [--set key=value ...]
|
harness hermes config [--set key=value ...]
|
||||||
harness hermes restart
|
harness hermes restart
|
||||||
@@ -136,6 +137,19 @@ def short_path(path):
|
|||||||
return "~" + p[len(home):] if p.startswith(home) else p
|
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 ──────────────────────────────────────────────────────
|
# ─── Catalog ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -196,13 +210,22 @@ def source_git_state(entry):
|
|||||||
|
|
||||||
def list_catalog():
|
def list_catalog():
|
||||||
"""Источники каталога. Состав каждого — в `skills list`."""
|
"""Источники каталога. Состав каждого — в `skills list`."""
|
||||||
|
subs = _submodule_state()
|
||||||
rows = []
|
rows = []
|
||||||
|
uninit = []
|
||||||
total_skills = 0
|
total_skills = 0
|
||||||
for name, entry, skills in catalog_entries():
|
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)
|
sha, drift = source_git_state(entry)
|
||||||
if skills:
|
if skills:
|
||||||
meta = f"{len(skills)} скиллов"
|
n = len(skills)
|
||||||
total_skills += len(skills)
|
meta = f"{n} {plural(n, 'скилл', 'скилла', 'скиллов')}"
|
||||||
|
total_skills += n
|
||||||
elif (entry / "SKILL.md").exists():
|
elif (entry / "SKILL.md").exists():
|
||||||
meta = "SKILL.md в корне"
|
meta = "SKILL.md в корне"
|
||||||
total_skills += 1
|
total_skills += 1
|
||||||
@@ -212,16 +235,82 @@ def list_catalog():
|
|||||||
meta += f", {sha}"
|
meta += f", {sha}"
|
||||||
rows.append((None, name, meta, None, None, "⚠ сдвинут" if drift else None))
|
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(
|
render_listing(
|
||||||
"КАТАЛОГ — источники",
|
"КАТАЛОГ — источники",
|
||||||
f"Каталог: {CATALOG_DIR}",
|
f"Каталог: {CATALOG_DIR}",
|
||||||
rows,
|
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():
|
def update_catalog():
|
||||||
"""Обновить каталог: submodule'ы — через суперпроект, клоны — git pull."""
|
"""Обновить каталог: стянуть недостающее и подтянуть свежие коммиты upstream."""
|
||||||
if not CATALOG_DIR.exists():
|
if not CATALOG_DIR.exists():
|
||||||
warn("catalog/ doesn't exist")
|
warn("catalog/ doesn't exist")
|
||||||
return
|
return
|
||||||
@@ -229,16 +318,19 @@ def update_catalog():
|
|||||||
subs = _submodule_paths()
|
subs = _submodule_paths()
|
||||||
|
|
||||||
if subs:
|
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(
|
rc, out, err = run(
|
||||||
["git", "submodule", "update", "--remote", "--init", "--recursive"],
|
["git", "submodule", "update", "--remote", "--recursive"],
|
||||||
cwd=HARNESS_ROOT,
|
cwd=HARNESS_ROOT,
|
||||||
)
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
rc2, status, _ = run(["git", "submodule", "status"], cwd=HARNESS_ROOT)
|
_print_submodule_status()
|
||||||
for line in status.splitlines():
|
ok("источники обновлены (изменения — закоммить отдельно)")
|
||||||
print(f" {line}")
|
|
||||||
ok("submodule'ы обновлены (изменения — закоммить отдельно)")
|
|
||||||
else:
|
else:
|
||||||
warn(f"submodule update: {err[:200]}")
|
warn(f"submodule update: {err[:200]}")
|
||||||
|
|
||||||
@@ -279,8 +371,10 @@ def install_skill(skill_source_name, skill_name, target):
|
|||||||
skill_dir = candidate
|
skill_dir = candidate
|
||||||
|
|
||||||
if not skill_dir:
|
if not skill_dir:
|
||||||
die(f"Скилл '{skill_source_name}/{skill_name}' не найден в каталоге. "
|
hint = "Что есть — 'harness skills list'."
|
||||||
f"Что есть — '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)
|
targets = resolve_targets(target)
|
||||||
|
|
||||||
@@ -407,6 +501,7 @@ def count_skills(target):
|
|||||||
# 7. Итог: "Итого: N <единица>, установлено M".
|
# 7. Итог: "Итого: N <единица>, установлено M".
|
||||||
# 8. Всё на русском, без вкраплений "Available"/"Installed".
|
# 8. Всё на русском, без вкраплений "Available"/"Installed".
|
||||||
# 9. Порядок отметок = порядок целей из --target / --scope.
|
# 9. Порядок отметок = порядок целей из --target / --scope.
|
||||||
|
# 10. Числа — со склонением через plural(): 1 скилл, 2 скилла, 5 скиллов.
|
||||||
|
|
||||||
MARK_ON = "✓"
|
MARK_ON = "✓"
|
||||||
MARK_OFF = "·"
|
MARK_OFF = "·"
|
||||||
@@ -421,7 +516,7 @@ def trim_line(s):
|
|||||||
return s.rstrip()
|
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)
|
rows: список кортежей (group, name, meta, marks, desc, note)
|
||||||
@@ -431,12 +526,15 @@ def render_listing(title, source, rows, targets=(), footer=None, dests=()):
|
|||||||
desc — описание строкой ниже (или None)
|
desc — описание строкой ниже (или None)
|
||||||
note — пометка жёлтым в конце строки (или None)
|
note — пометка жёлтым в конце строки (или None)
|
||||||
dests: [(цель, путь)] — куда ставится, dim-строками после итога
|
dests: [(цель, путь)] — куда ставится, dim-строками после итога
|
||||||
|
notice: предупреждение жёлтым сразу под легендой (или None)
|
||||||
"""
|
"""
|
||||||
print(c(title, "bold"))
|
print(c(title, "bold"))
|
||||||
if source:
|
if source:
|
||||||
print(c(" " + source, "dim"))
|
print(c(" " + source, "dim"))
|
||||||
if targets:
|
if targets:
|
||||||
print(c(f" {MARK_ON} — установлено, {MARK_OFF} — нет", "dim"))
|
print(c(f" {MARK_ON} — установлено, {MARK_OFF} — нет", "dim"))
|
||||||
|
if notice:
|
||||||
|
print(c(" ⚠ " + notice, "yellow"))
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -484,15 +582,24 @@ def list_skills(target="all"):
|
|||||||
"""Каталог: что доступно к установке, и где уже стоит."""
|
"""Каталог: что доступно к установке, и где уже стоит."""
|
||||||
targets = resolve_targets(target)
|
targets = resolve_targets(target)
|
||||||
installed = {t: installed_skill_names(t) for t in targets}
|
installed = {t: installed_skill_names(t) for t in targets}
|
||||||
|
subs = _submodule_state()
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
|
uninit = []
|
||||||
total = 0
|
total = 0
|
||||||
have = 0
|
have = 0
|
||||||
for name, entry, skills in catalog_entries():
|
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)
|
sha, drift = source_git_state(entry)
|
||||||
bits = []
|
bits = []
|
||||||
if skills:
|
if skills:
|
||||||
bits.append(f"{len(skills)} скиллов")
|
n = len(skills)
|
||||||
|
bits.append(f"{n} {plural(n, 'скилл', 'скилла', 'скиллов')}")
|
||||||
if sha:
|
if sha:
|
||||||
bits.append(sha)
|
bits.append(sha)
|
||||||
if drift:
|
if drift:
|
||||||
@@ -518,8 +625,16 @@ def list_skills(target="all"):
|
|||||||
rows.append((group, "(нет SKILL.md)", None, None, None, "нечего ставить"))
|
rows.append((group, "(нет SKILL.md)", None, None, None, "нечего ставить"))
|
||||||
total += 1
|
total += 1
|
||||||
|
|
||||||
footer = f"Итого: {total} скиллов, установлено {have}"
|
notice = None
|
||||||
if have < total:
|
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"
|
footer += "\nПоставить: harness skills install <источник> <скилл>|--all"
|
||||||
|
|
||||||
render_listing(
|
render_listing(
|
||||||
@@ -528,6 +643,7 @@ def list_skills(target="all"):
|
|||||||
rows,
|
rows,
|
||||||
targets,
|
targets,
|
||||||
footer=footer,
|
footer=footer,
|
||||||
|
notice=notice,
|
||||||
dests=[
|
dests=[
|
||||||
("hermes", short_path(HERMES_SKILLS / "catalog")),
|
("hermes", short_path(HERMES_SKILLS / "catalog")),
|
||||||
("opencode", " ".join(short_path(d) for d in OPENCODE_SKILLS_DIRS)),
|
("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}",
|
f"Источник: {AGENTS_SRC}",
|
||||||
rows,
|
rows,
|
||||||
targets,
|
targets,
|
||||||
footer=f"Итого: {len(avail)} агентов, установлено {have}",
|
footer=f"Итого: {len(avail)} {plural(len(avail), 'агент', 'агента', 'агентов')}, установлено {have}",
|
||||||
dests=[(label, short_path(d)) for label, d in dests],
|
dests=[(label, short_path(d)) for label, d in dests],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -974,6 +1090,7 @@ def main():
|
|||||||
harness mcp rm my-server
|
harness mcp rm my-server
|
||||||
|
|
||||||
harness catalog list
|
harness catalog list
|
||||||
|
harness catalog init
|
||||||
harness catalog update
|
harness catalog update
|
||||||
|
|
||||||
harness hermes config --set model.default=gpt-4o
|
harness hermes config --set model.default=gpt-4o
|
||||||
@@ -1051,8 +1168,9 @@ def main():
|
|||||||
# ── catalog
|
# ── catalog
|
||||||
cat_p = sub.add_parser("catalog", help="Каталог внешних репозиториев")
|
cat_p = sub.add_parser("catalog", help="Каталог внешних репозиториев")
|
||||||
cat_sub = cat_p.add_subparsers(dest="action", required=True, metavar="<действие>")
|
cat_sub = cat_p.add_subparsers(dest="action", required=True, metavar="<действие>")
|
||||||
cat_sub.add_parser("list", help="Источники каталога и их содержимое")
|
cat_sub.add_parser("list", help="Источники каталога")
|
||||||
cat_sub.add_parser("update", help="Обновить submodule'ы / клоны")
|
cat_sub.add_parser("init", help="Стянуть submodule'ы на зафиксированных коммитах")
|
||||||
|
cat_sub.add_parser("update", help="Подтянуть свежие коммиты upstream (потом закоммитить)")
|
||||||
|
|
||||||
# ── hermes
|
# ── hermes
|
||||||
hermes_p = sub.add_parser("hermes", help="Hermes: конфиг и сервисы")
|
hermes_p = sub.add_parser("hermes", help="Hermes: конфиг и сервисы")
|
||||||
@@ -1122,6 +1240,8 @@ def main():
|
|||||||
elif obj == "catalog":
|
elif obj == "catalog":
|
||||||
if action == "list":
|
if action == "list":
|
||||||
list_catalog()
|
list_catalog()
|
||||||
|
elif action == "init":
|
||||||
|
init_catalog()
|
||||||
elif action == "update":
|
elif action == "update":
|
||||||
update_catalog()
|
update_catalog()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user