{"repo":"NousResearch/hermes-agent","public_id":"33674","display_label":"Skills search identifier column truncation fix","summary":"A bug where the skills search table truncates identifier values, causing install failures when copying them. Multiple related fixes address preventing truncation and adding a --json output option.","subsystem":"hermes_cli","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33810},"resolution":{"state":"resolved","by":"pr-33810"},"freshness":{"state":"verified","last_verified_at":"2026-05-28T11:53:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":33674,"pr_number":33810,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":33674,"title":"Bug: hermes skills search table truncates Identifier column — install fails with copied value","url":null,"state":"closed"},{"kind":"pr","number":33680,"title":"fix(cli): print copyable skills search identifiers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e0572a6def17fa359e00691be76421eccb82b5ee","number":0},{"kind":"pr","value":"#33810","number":33810},{"kind":"pr","value":"#33674","number":33674}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33682,"title":"fix(cli): prevent Identifier column truncation in skills search table","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e0572a6def17fa359e00691be76421eccb82b5ee","number":0},{"kind":"pr","value":"#33810","number":33810},{"kind":"pr","value":"#33674","number":33674}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33703,"title":"fix(cli): skills search identifier truncation + add --json output","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e0572a6def17fa359e00691be76421eccb82b5ee","number":0},{"kind":"pr","value":"#33810","number":33810}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33730,"title":"fix(cli): prevent Identifier column truncation in skills search; add --json flag (#33674)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33674","number":33674}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":33810,"title":"fix(skills-hub): stop ellipsis-truncating the Identifier column (#33674)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_cli/skills_hub.py","tests/hermes_cli/test_skills_hub.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -12554,6 +12554,11 @@ def cmd_pairing(args):\n         ],\n     )\n     skills_search.add_argument(\"--limit\", type=int, default=10, help=\"Max results\")\n+    skills_search.add_argument(\n+        \"--json\",\n+        action=\"store_true\",\n+        help=\"Output JSON instead of a table (full identifiers, scripting-friendly)\",\n+    )\n \n     skills_install = skills_subparsers.add_parser(\"install\", help=\"Install a skill\")\n     skills_install.add_argument("]},{"file":"hermes_cli/skills_hub.py","hunks":["@@ -58,7 +58,9 @@ def _resolve_short_name(name: str, sources, console: Console) -> str:\n         table = Table()\n         table.add_column(\"Source\", style=\"dim\")\n         table.add_column(\"Trust\", style=\"dim\")\n-        table.add_column(\"Identifier\", style=\"bold cyan\")\n+        # overflow=\"fold\" keeps the full slug visible (wraps instead of ellipsis-truncating)\n+        # so users can copy it for `hermes skills install`.\n+        table.add_column(\"Identifier\", style=\"bold cyan\", overflow=\"fold\", no_wrap=False)\n         for r in exact:\n             trust_style = {\"builtin\": \"bright_cyan\", \"trusted\": \"green\", \"community\": \"yellow\"}.get(r.trust_level, \"dim\")\n             trust_label = \"official\" if r.source == \"official\" else r.trust_level","@@ -244,15 +246,39 @@ def _prompt_for_category(c: Console, existing: List[str]) -> str:\n \n \n def do_search(query: str, source: str = \"all\", limit: int = 10,\n-              console: Optional[Console] = None) -> None:\n-    \"\"\"Search registries and display results as a Rich table.\"\"\"\n+              console: Optional[Console] = None, as_json: bool = False) -> None:\n+    \"\"\"Search registries and display results as a Rich table.\n+\n+    When ``as_json=True`` writes a JSON array of result records to stdout\n+    (one object per skill: ``name``, ``identifier``, ``source``,\n+    ``trust_level``, ``description``) and skips the table render. This is\n+    the scripting / copy-paste handle: the full identifier is always\n+    intact, even for browse-sh slugs that the table would otherwise wrap.\n+    \"\"\"\n     from tools.skills_hub import GitHubAuth, create_source_router, unified_search\n \n     c = console or _console\n-    c.print(f\"\\n[bold]Searching for:[/] {query}\")\n \n     auth = GitHubAuth()\n     sources = create_source_router(auth)\n+    if as_json:\n+        # Avoid Rich status spinner contaminating stdout — JSON consumers\n+        # expect a clean parseable stream.\n+        results = unified_search(query, sources, source_filter=source, limit=limit)\n+        payload = [\n+            {\n+                \"name\": r.name,\n+                \"identifier\": r.identifier,\n+                \"source\": r.source,\n+                \"trust_level\": r.trust_level,\n+                \"description\": r.description,\n+            }\n+            for r in results\n+        ]\n+        print(json.dumps(payload, indent=2))\n+        return\n+\n+    c.print(f\"\\n[bold]Searching for:[/] {query}\")\n     with c.status(\"[bold]Searching registries...\"):\n         results = unified_search(query, sources, source_filter=source, limit=limit)\n ","@@ -265,7 +291,11 @@ def do_search(query: str, source: str = \"all\", limit: int = 10,\n     table.add_column(\"Description\", max_width=60)\n     table.add_column(\"Source\", style=\"dim\")\n     table.add_column(\"Trust\", style=\"dim\")\n-    table.add_column(\"Identifier\", style=\"dim\")\n+    # overflow=\"fold\" keeps the full slug visible (wraps instead of\n+    # ellipsis-truncating). Browse.sh slugs end in a `-XXXXXX` hash that\n+    # is part of the actual identifier — truncating it makes copy-paste\n+    # into `hermes skills install` fail.\n+    table.add_column(\"Identifier\", style=\"dim\", overflow=\"fold\", no_wrap=False)\n \n     for r in results:\n         trust_style = {\"builtin\": \"bright_cyan\", \"trusted\": \"green\", \"community\": \"yellow\"}.get(r.trust_level, \"dim\")","@@ -280,7 +310,8 @@ def do_search(query: str, source: str = \"all\", limit: int = 10,\n \n     c.print(table)\n     c.print(\"[dim]Use: hermes skills inspect <identifier> to preview, \"\n-            \"hermes skills install <identifier> to install[/]\\n\")\n+            \"hermes skills install <identifier> to install \"\n+            \"(--json for scripting)[/]\\n\")\n \n \n def do_browse(page: int = 1, page_size: int = 20, source: str = \"all\",","@@ -1390,7 +1421,8 @@ def skills_command(args) -> None:\n     if action == \"browse\":\n         do_browse(page=args.page, page_size=args.size, source=args.source)\n     elif action == \"search\":\n-        do_search(args.query, source=args.source, limit=args.limit)\n+        do_search(args.query, source=args.source, limit=args.limit,\n+                  as_json=getattr(args, \"json\", False))\n     elif action == \"install\":\n         do_install(args.identifier, category=args.category, force=args.force,\n                    skip_confirm=getattr(args, \"yes\", False),","@@ -1511,10 +1543,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:\n \n     elif action == \"search\":\n         if not args:\n-            c.print(\"[bold red]Usage:[/] /skills search <query> [--source skills-sh|well-known|github|official] [--limit N]\\n\")\n+            c.print(\"[bold red]Usage:[/] /skills search <query> [--source skills-sh|well-known|github|official] [--limit N] [--json]\\n\")\n             return\n         source = \"all\"\n         limit = 10\n+        as_json = False\n         query_parts = []\n         i = 0\n         while i < len(args):","@@ -1527,10 +1560,14 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:\n                 except ValueError:\n                     pass\n                 i += 2\n+            elif args[i] == \"--json\":\n+                as_json = True\n+                i += 1\n             else:\n                 query_parts.append(args[i])\n                 i += 1\n-        do_search(\" \".join(query_parts), source=source, limit=limit, console=c)\n+        do_search(\" \".join(query_parts), source=source, limit=limit,\n+                  console=c, as_json=as_json)\n \n     elif action == \"install\":\n         if not args:"]},{"file":"tests/hermes_cli/test_skills_hub.py","hunks":["@@ -651,3 +651,95 @@ def test_browse_skills_dedup_uses_identifier_not_name(monkeypatch):\n         \"browse_skills() must not deduplicate browse-sh skills with the same name \"\n         \"but different identifiers\"\n     )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Regression: full identifier must be recoverable from `hermes skills search`\n+# even when the slug is too long to fit the terminal width (issue #33674).\n+# ---------------------------------------------------------------------------\n+\n+# A real browse-sh-style slug whose trailing -XXXXXX hash matters for install\n+_LONG_SLUG = \"browse-sh/weather.gov/get-forecast-1uezib\"\n+\n+_LONG_RESULT = type(\"R\", (), {\n+    \"name\": \"get-forecast\",\n+    \"description\": \"Fetch the forecast\",\n+    \"source\": \"browse-sh\",\n+    \"trust_level\": \"community\",\n+    \"identifier\": _LONG_SLUG,\n+})()\n+\n+\n+def test_do_search_identifier_column_does_not_truncate_long_slug():\n+    \"\"\"The Identifier column must use overflow='fold', not the default ellipsis.\n+\n+    Renders into a deliberately narrow Console; the full slug (including the\n+    trailing -1uezib hash) must still appear in the output. Before the fix,\n+    Rich would render `browse-sh/weather…` and lose the hash.\n+    \"\"\"\n+    from hermes_cli.skills_hub import do_search\n+\n+    sink = StringIO()\n+    # Narrow width forces Rich to apply overflow rules — exactly the scenario\n+    # the issue reports. width=40 is too small for the slug; we want the slug\n+    # wrapped (not ellipsis-truncated).\n+    console = Console(file=sink, force_terminal=False, color_system=None, width=40)\n+\n+    with patch(\"tools.skills_hub.unified_search\", return_value=[_LONG_RESULT]), \\\n+         patch(\"tools.skills_hub.create_source_router\", return_value={}), \\\n+         patch(\"tools.skills_hub.GitHubAuth\"):\n+        do_search(\"weather\", console=console)\n+\n+    output = sink.getvalue()\n+\n+    # The fix is working when the Identifier column wraps the slug across\n+    # multiple lines (folded chunks) rather than emitting ONE line with an\n+    # ellipsis. Extract every chunk that appears in the rightmost cell of\n+    # the table by walking lines that look like table rows (\"│ ... │\") and\n+    # taking the last `│...│` cell. Concatenating those chunks must yield\n+    # the full slug.\n+    chunks = []\n+    for line in output.splitlines():\n+        # Table data rows start and end with the box-drawing vertical bar.\n+        if not line.startswith(\"│\") or not line.rstrip().endswith(\"│\"):\n+            continue\n+        # Last `│ ... │` cell on the row is the Identifier column.\n+        last_cell = line.rstrip().rsplit(\"│\", 2)[-2].strip()\n+        if last_cell:\n+            chunks.append(last_cell)\n+    reconstructed = \"\".join(chunks)\n+    assert _LONG_SLUG in reconstructed, (\n+        f\"Expected full slug {_LONG_SLUG!r} to be recoverable from the \"\n+        f\"folded Identifier column; got chunks {chunks!r}\\n\"\n+        f\"Full output:\\n{output}\"\n+    )\n+    # And the truncating ellipsis must NOT appear in the Identifier column.\n+    # Rich uses U+2026 HORIZONTAL ELLIPSIS for the default overflow=\"ellipsis\".\n+    assert \"\\u2026\" not in reconstructed, (\n+        f\"Identifier column still ellipsis-truncated: {reconstructed!r}\"\n+    )\n+\n+\n+def test_do_search_json_flag_emits_full_identifiers(capsys):\n+    \"\"\"`--json` must print a parseable array with full identifiers and skip the table.\"\"\"\n+    from hermes_cli.skills_hub import do_search\n+\n+    sink = StringIO()\n+    console = Console(file=sink, force_terminal=False, color_system=None, width=40)\n+\n+    with patch(\"tools.skills_hub.unified_search\", return_value=[_LONG_RESULT]), \\\n+         patch(\"tools.skills_hub.create_source_router\", return_value={}), \\\n+         patch(\"tools.skills_hub.GitHubAuth\"):\n+        do_search(\"weather\", console=console, as_json=True)\n+\n+    # JSON goes to stdout via print(), not the Rich console sink.\n+    captured = capsys.readouterr().out\n+    import json as _json\n+    payload = _json.loads(captured)\n+    assert isinstance(payload, list) and len(payload) == 1\n+    assert payload[0][\"identifier\"] == _LONG_SLUG\n+    assert payload[0][\"name\"] == \"get-forecast\"\n+    assert payload[0][\"source\"] == \"browse-sh\"\n+    # Table render must be suppressed — sink should be empty (no \"Searching for:\" header).\n+    assert \"Searching for:\" not in sink.getvalue()\n+\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}