hermes-agent/hermes_cli/subcommands/sync.py
Ben Barclay eaa539552c feat(sync): HSP/1 personal skill sync client (Milestone 1, client strand)
Implements the hermes-agent HSP/1 sync CLIENT against the frozen wire
contract (~/src/specs/collective-wisdom/hsp-1-contract.md §8), tested
against an in-process mock HSP server.

tools/skills_sync_client.py (new, low-level; does NOT import the CLI):
  * Full 64-hex sha256 content addressing + canonical JSON (§2.1/§2.5,
    OI-5) — kept distinct from the truncated local content_hash namespace.
  * HSPClient: capabilities/refs/objects GET, batch object upload
    (multipart, raw bytes per §1/§4.2), CAS ref (§4.4) with 409->HSPConflict.
  * Object building: skill dir -> blob/tree/commit; exec-bit preserved,
    symlinks skipped, oversize (413) surfaced; profile-root category trees.
  * push/pull + three-way merge (M1-C): reuses the origin/user/incoming
    decision semantics of skills_sync.py; non-overlap -> merge commit +
    retry CAS; true overlap -> refs/user/<owner>/conflict/<n> + surface.
  * DEV-PHASE gate: sync is INERT unless the resolved Nous token carries
    tool_gateway_admin===true (decoded from the bearer; server re-verifies).
  * Auth reuses resolve_nous_runtime_credentials() (no refresh reimpl).
  * maybe_push_skills / maybe_pull_skills gate-and-swallow entrypoints.

Opt-in (M1-D): tools/skill_usage.set_sync / is_sync_enabled — a `sync`
flag on the .usage.json sidecar; nothing syncs unless opted in. Only
agent-created/user-authored skills are eligible (bundled/hub excluded).

Hooks:
  * Debounced push in skill_manage success block (after the write gate).
  * Periodic pull at the two curator tick sites (gateway housekeeping loop
    + CLI startup).

CLI: hermes sync status|pull|push|now|enable|disable
  (hermes_cli/subcommands/sync.py + cmd_sync in main.py).

Tests: tests/tools/test_skills_sync_client.py — 29 tests (addressing,
canonicalization, dev gate, opt-in, object building, merge decisions, and
e2e push/pull/idempotency/conflict against a stdlib mock HSP server).
2026-07-18 13:55:09 +10:00

44 lines
1.9 KiB
Python

"""``hermes sync`` subcommand parser (HSP/1 personal skill sync).
Cloned from ``hermes_cli/subcommands/cron.py`` — same injected-handler shape
(``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance).
Commands:
hermes sync status -- show gate/opt-in/head state
hermes sync pull -- pull the owner's HEAD, materialize opted-in skills
hermes sync push -- push opted-in skills to the owner's HEAD
hermes sync now -- pull then push (full reconcile)
hermes sync enable <skill> -- opt a skill into sync (M1-D)
hermes sync disable <skill> -- opt a skill out of sync
Sync is INERT unless the resolved Nous token carries the DEV-PHASE gate claim
(tool_gateway_admin) AND a sync base URL is configured. The commands report
that state rather than failing opaquely.
"""
from __future__ import annotations
from typing import Callable
def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None:
"""Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``."""
sync_parser = subparsers.add_parser(
"sync",
help="Personal skill sync (HSP/1)",
description="Sync agent-created and user-authored skills across devices.",
)
sync_sub = sync_parser.add_subparsers(dest="sync_command")
sync_sub.add_parser("status", help="Show sync gate, opt-in, and head state")
sync_sub.add_parser("pull", help="Pull the owner's HEAD and materialize opted-in skills")
sync_sub.add_parser("push", help="Push opted-in skills to the owner's HEAD")
sync_sub.add_parser("now", help="Reconcile now: pull then push")
enable = sync_sub.add_parser("enable", help="Opt a skill into sync")
enable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
disable = sync_sub.add_parser("disable", help="Opt a skill out of sync")
disable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
sync_parser.set_defaults(func=cmd_sync)