Skip to content

fix(skills): require --force to replace an untracked skill on install - #80903

Open
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/skills-install-unlocked-local-overwrite-80848
Open

fix(skills): require --force to replace an untracked skill on install#80903
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/skills-install-unlocked-local-overwrite-80848

Conversation

@briandevans

@briandevans briandevans commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This is a sibling follow-up to #80848

  • What fix(skills): skip locally-edited hub skills on update unless --force (Paperclip port) #80848 covers: do_update()do_install(force=True) replacing a hub skill the user has edited. It compares content_hash(skill_dir) against the lockfile's content_hash and skips unless --force.
  • What fix(skills): skip locally-edited hub skills on update unless --force (Paperclip port) #80848 does not touch: a skill with no lock entry at all. A lockfile-hash comparison structurally cannot cover a skill that has no lockfile row to compare against, and its hunks are in do_check / do_update / skills_command / handle_skills_slash (@@1052, @@1063, @@1082, @@1745, @@1929) — the do_install collision block is untouched.
  • What this adds: the same consent gate on the install caller, keyed on the filesystem instead of the lockfile, so a locally authored or user-edited skill with no lock entry also requires --force. Disjoint hunks (@@179, @@636); the two changes compose.

What does this PR do?

install_from_quarantine() calls shutil.rmtree(install_dir) on whatever sits at a skill's install path. Its own comment justifies that by deferring the consent question upstream — tools/skills_hub.py, above the rmtree:

A directory that directly contains SKILL.md is an existing skill installation and stays overwritable (hub-installed skills are additionally guarded by the lock-file check in do_install()).

That parenthetical does not hold for a skill with no lock entry. do_install()'s only pre-install collision check is:

# Check if already installed
lock = HubLockFile()
existing = lock.get_installed(bundle.name)
if existing:
    c.print(f"[yellow]Warning:[/] '{bundle.name}' is already installed at {existing['install_path']}")
    if not force:
        c.print("Use --force to reinstall.\n")
        return

HubLockFile only tracks hub installs, so existing is None for two first-class populations that live under skills/ without a lock row:

  1. Locally authored skillssource_type = "local" / trust = "local".
  2. Bundled skills the user has edited — the repo ships skills list --modified, skills diff and skills reset --restore specifically for this population.

For either, installing a same-named skill takes no --force, prints no warning, and deletes the directory. This PR makes that comment true by consulting the filesystem alongside the lock file.

This is the sibling of the data-loss report in #75983 ("silently deletes an entire existing category directory … unconditional rmtree, data loss"). The fix for that one — 75e85ef (refuse to overwrite category bucket during skill install), widened a day later by 881ac52 (hybrid skill-dir nesting and file collisions) — guards the category-bucket branch and explicitly leaves the SKILL.md-bearing branch overwritable, on the strength of the lock-file claim quoted above. This closes that remaining branch.

Coverage: one guard covers every install surface

do_install() is the single production caller of install_from_quarantine():

$ git grep -n "install_from_quarantine" -- '*.py' | grep -v tests/
hermes_cli/skills_hub.py:513:        quarantine_bundle, install_from_quarantine, HubLockFile,
hermes_cli/skills_hub.py:731:        install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
tools/skills_hub.py:3711:def install_from_quarantine(

So the one guard covers all of these, and no other site shares the root cause:

Surface Call site Consent available today
hermes skills install skills_command, hermes_cli/skills_hub.py confirm panel, but it only says "Files will be at: …" — it never says an existing directory is deleted
/skills install handle_skills_slash noneskip_confirm = True is hardcoded
tui_gateway skills tool skills.manage, tui_gateway/methods_tools.py none as originally filedskip_confirm=True and a console whose print discards output. Fixed by the follow-up commit below
hermes skills snapshot import do_snapshot_import passes force through; defaults to False
hermes skills update do_update passes force=Trueunaffected by this change; that path is #80848's

Follow-up commit 0398a15 — make the refusal observable on skills.manage

Thanks @spfcraze — this was a real hole and it was one this PR opened. The table row above understated it, so it is corrected there too.

The problem. The refusal behaviour reached the tui_gateway surface (no rmtree), but the report did not. As filed, the handler was:

class _Q:
    def print(self, *a, **k):
        pass

do_install(query, skip_confirm=True, console=_Q())
return _ok(rid, {"installed": True, "name": query})

do_install() is -> None and refuses with a bare return, and _Q.print is a literal pass. So on the one surface this PR newly refuses on, a refusal was reported as installed: True, the Use --force to overwrite. hint went nowhere, and there was no force argument to override it with. That is strictly worse than the pre-PR state, where the same unconditional installed: True at least happened to be true.

The fix. Consult the guard's own helper before calling, and forward force:

  • installed: false with reason: "untracked_skill_exists", path (the directory that would have been replaced), and a message carrying the actionable hint. The success shape is byte-identical to today's {"installed": True, "name": query}, so no consumer breaks.
  • The directory is resolved by _untracked_skill_dir() — the same helper the CLI guard uses — rather than rebuilt here, so the two surfaces cannot name different deletion targets. This is the same reasoning as the Copilot thread on the CLI message, applied to the second surface.
  • force is forwarded to do_install(), giving this surface the CLI's escape hatch. It is coerced through the project's shared is_truthy_value() (as methods_session.py / methods_prompt.py do) rather than bool(), because bool("false") is True and this gate decides whether a user's own skill directory is rmtree'd.

No client change is needed for /skills install. ui-tui/src/app/slash/commands/ops.ts:612 already branches on the flag:

sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')

so an honest installed: false renders as install failed today, with no ui-tui/ edit. Separately, ui-tui/src/components/skillsHub.tsx:83 discards the payload (.then(() => onClose())) — a pre-existing gap in the overlay that predates this PR and is not touched here; #38688 already proposes changes to that file (see Positioning).

Deliberately out of scope: do_install()'s -> None contract is unchanged. It has 11 bare returns; the other ten (unknown source adapter, fetch failure / rate limit, missing-name on a URL install, blocked scan verdict, cancelled confirm, invalid install path) also misreport as installed: True on this surface. But those predate this PR, sit on different code paths, and fixing them means changing the return type of a function with five call sites — a general outcome-reporting refactor bundled into a consent-gate fix. Happy to file it as a follow-up, or to fold it in here if you would rather have it in one go.

Related Issue

No auto-close keyword — this PR does not fully resolve an open issue on its own.

Related: #75983 (CLOSED, the category-bucket case whose fix introduced the comment quoted above) and #80848 (the update-caller half of the same hazard).

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_cli/skills_hub.py — new _untracked_skill_dir(name, category) helper. It resolves the install directory using install_from_quarantine()'s own validators (_validate_skill_name, _validate_install_parent_path, _resolve_lock_install_path) so both functions agree on which directory is at stake, and returns it only when that directory directly contains SKILL.md.
  • hermes_cli/skills_hub.py — in do_install(), after the existing lock-file block, refuse when not existing and not force and the helper reports an untracked skill. Message follows the surrounding console idiom and the path is rendered via display_hermes_home(), matching the confirm panel.
  • tests/hermes_cli/test_skills_hub.py — three regression tests.
  • tui_gateway/methods_tools.py (follow-up 0398a15) — the skills.manage install action pre-checks with _untracked_skill_dir(), reports installed: false + reason + path + message on refusal, and forwards force (coerced with is_truthy_value) to do_install(). +29/-2.
  • tests/tui_gateway/test_protocol.py (follow-up 0398a15) — three regression tests on that handler. +91.

Deliberately unchanged, to keep the diff to one idea:

  • The existing lock-file branch is byte-identical — the new check is a separate statement after it, so the already installed path keeps its exact message and behavior.
  • --force remains a full escape hatch.
  • The category-bucket guard and the malformed-name/symlink ValueErrors in install_from_quarantine() are untouched; _untracked_skill_dir returns None on those so the existing, more actionable downstream error is still what the user sees.
  • tools/skills_hub.py is not edited. The point is to make its existing comment true, not to move the guard.

How to Test

# Seed a locally authored skill with no hub lock entry
mkdir -p "$HERMES_HOME/skills/note-taker"
printf -- '---\nname: note-taker\ndescription: hand written\n---\n# my own notes\n' \
  > "$HERMES_HOME/skills/note-taker/SKILL.md"

# Install a same-named skill from the hub
hermes skills install <some-source>/note-taker
  • Before: installs successfully; SKILL.md now contains the upstream body. The hand-written file is gone.
  • After: Warning: 'note-taker' already exists at <hermes-home>/skills/note-taker and is not tracked by the skills hub (a local or user-edited skill). / Installing would replace it. Use --force to overwrite. — and the file is untouched.
  • --force still overwrites.

Regression test, verified in both directions

The tests drive the real quarantine_bundle / install_from_quarantine / HubLockFile inside tmp_path (only the remote source and the scanner are faked), so the destructive rmtree genuinely runs and the red is real data loss rather than a mock assertion.

RED — production hunk reverted (git stash push -- hermes_cli/skills_hub.py), tests untouched:

FAILED tests/hermes_cli/test_skills_hub.py::test_install_refuses_to_replace_untracked_local_skill
>       assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == _LOCAL_SKILL_BODY
E       AssertionError: assert '---\nname: n...stream body\n' == '---\nname: n...y own notes\n'
E           ---
E           name: note-taker
E         - description: hand written
E         + description: upstream
E           ---
E         - # my own notes
E         + # upstream body
========================= 1 failed, 5 passed in 0.26s ==========================

It fails on the data-survival assertion, not on a message string.

GREEN — fix restored:

tests/hermes_cli/test_skills_hub.py::test_install_refuses_to_replace_untracked_local_skill PASSED
tests/hermes_cli/test_skills_hub.py::test_install_force_still_replaces_untracked_local_skill PASSED
tests/hermes_cli/test_skills_hub.py::test_install_untracked_guard_leaves_lockfile_branch_alone PASSED
============================== 6 passed in 0.21s ===============================

The other two tests hold in both directions by design — they are non-regression assertions, pinning that --force still overwrites and that the lock-file branch keeps its existing message.

Follow-up commit 0398a15, also verified in both directions

Three tests in tests/tui_gateway/test_protocol.py, driven through server.handle_request() so they exercise the real registered handler. They seed a real untracked skill directory under tmp_path and stub only do_install, so "was the refusal reported?" and "was do_install reached at all?" are both real assertions.

RED — follow-up production hunk reverted (git checkout HEAD~1 -- tui_gateway/methods_tools.py), tests untouched:

FAILED test_skills_manage_install_reports_untracked_refusal
>       assert result["installed"] is False
E       assert True is False

FAILED test_skills_manage_install_forwards_force_override
>       assert kwargs["force"] is True
E       KeyError: 'force'

FAILED test_skills_manage_install_force_is_not_bare_truthiness
>       assert resp["result"]["installed"] is False
E       assert True is False

The first failure is the reported bug exactly: the surface says installed: True for an install it refused.

GREEN — fix restored: 43 passed (tests/tui_gateway/test_protocol.py).

Suites run for the follow-up commit (green on 0398a15):

tests/hermes_cli/test_skills_hub.py + tests/tools/test_skills_hub.py         67 passed
tests/tui_gateway/test_protocol.py                                           43 passed
ruff check tui_gateway/methods_tools.py tests/tui_gateway/test_protocol.py   clean

Suites run for the first two commits (green on faec6d6):

tests/hermes_cli/test_skills_hub.py
tests/tools/test_skills_hub.py + tests/tools/test_skills_guard.py   91 passed
tests/hermes_cli/test_skills_skip_confirm.py
tests/hermes_cli/test_skills_install_flags.py
tests/hermes_cli/test_managed_installs.py
tests/hermes_cli/test_web_server_skills_profiles.py
tests/tools/test_blueprints.py
tests/tools/test_skill_bundle_provenance.py

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — ran the targeted suites listed under "How to Test" instead of the full tree; leaving this unchecked rather than claiming a run I didn't do
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.13

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A — N/A: no user-facing flag or config changed; --force already documented. Behavior is covered by the new docstring.

  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A — N/A

  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A — N/A

  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A — pathlib only, no separators or platform branches; path resolution is delegated to the existing _resolve_lock_install_path, so it inherits whatever junction/symlink handling that helper has (including any hardening from fix(skills): resolve install path against resolved skills dir (Windows junction) #76885 / fix(skills): resolve the skills root before computing the lock install path #64954).

  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A — The skills.manage install action changes in 0398a15: it accepts a new optional force param and, on refusal, returns installed: false with reason / path / message instead of installed: true. The success shape is byte-identical. skills.manage has no declared JSON schema — its params are read from the params dict — so there is no schema file to update; the two typed TS consumers are SkillsInstallResponse (ui-tui/src/app/slash/commands/ops.ts:41, whose fields are all optional and which already branches on r.installed) and the inline type at ui-tui/src/components/skillsHub.tsx:83. Neither needs a change to keep compiling or behaving.

    (This line previously read "N/A: no tool schema change; the tui_gateway skills tool gains the same refusal as the CLI." That overstated: the refusal behaviour reached that surface, but the report and the override did not — which is exactly what the table above records as consent "none", so the body contradicted itself. Corrected here and in that table.)

Related / Positioning

Dedup performed before opening, recorded so it can be checked rather than taken on trust:

Re-run for the follow-up commit's file, tui_gateway/methods_tools.py (snapshot spanning PRs #80579#81135):

Happy to rebase behind #80848, fold this into it, or adjust the wording of the warning if you'd prefer it phrased differently.

`install_from_quarantine()` calls `shutil.rmtree(install_dir)` on whatever
sits at a skill's install path. Its own comment justifies that by deferring
the consent question upstream:

    A directory that directly contains SKILL.md is an existing skill
    installation and stays overwritable (hub-installed skills are
    additionally guarded by the lock-file check in do_install()).

That parenthetical does not hold for a skill with no lock entry.
`do_install()`'s only pre-install collision check reads the hub lock file, so
`existing is None` for two first-class populations that live under `skills/`
without one: locally authored skills (`source_type="local"`) and bundled
skills the user has edited — the repo ships `skills list --modified`,
`skills diff` and `skills reset --restore` specifically for the latter. For
those, installing a same-named skill needs no `--force`, prints no warning,
and destroys the directory.

Consult the filesystem alongside the lock file, resolving the target with
`install_from_quarantine`'s own validators so the two agree on which
directory is at stake. When it already holds a skill (directly contains
`SKILL.md`) and the lock file has no entry for it, warn and require
`--force`, matching the consent gate a hub-installed skill already gets.

`do_install()` is the single production caller of
`install_from_quarantine()`, so this one guard covers every install surface:
`hermes skills install`, the `/skills install` slash command (which hardcodes
`skip_confirm=True`), the tui_gateway skills tool (`skip_confirm=True` with a
console that discards output), and `skills snapshot import`. `do_update()`
passes `force=True` and is unaffected.

The existing lock-file branch, the `--force` escape hatch, the category-bucket
guard, and the malformed-name errors are all left unchanged.
Copilot AI lite review requested due to automatic review settings August 7, 2026 08:00
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard tool/skills Skills system (list, view, manage) labels Aug 7, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an additional overwrite-consent gate for hermes skills install when the target directory already contains a skill on disk but has no Skills Hub lock entry, preventing silent deletion of locally-authored or user-edited skills.

Changes:

  • Add _untracked_skill_dir() to detect an existing on-disk skill directory (by presence of SKILL.md) using the same path validators as install_from_quarantine().
  • Extend do_install() to refuse installs (unless --force) when the install target is an existing untracked skill directory, even if the hub lock file has no entry.
  • Add regression tests that exercise the real quarantine/install path and assert the on-disk skill content is preserved without --force.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
hermes_cli/skills_hub.py Adds filesystem-based collision detection for untracked skills and gates overwrite behind --force.
tests/hermes_cli/test_skills_hub.py Adds end-to-end regression coverage ensuring untracked skills aren’t silently replaced, and --force remains an escape hatch.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hermes_cli/skills_hub.py Outdated
Comment on lines +680 to +683
shown = (
f"{display_hermes_home()}/skills/"
f"{category + '/' if category else ''}{bundle.name}"
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in faec6d69a93 (current head).

You're right that the two can diverge, and it's worth spelling out how far: _untracked_skill_dir() routes the category through _validate_install_parent_path()_normalize_bundle_path(), which drops empty and . segments and rewrites \/, then _resolve_lock_install_path() .resolve()s the result. So --category "personal//./notes" produced a prompt naming skills/personal//./notes/note-taker while the directory install_from_quarantine() would have rmtree'd was skills/personal/notes/note-taker. Naming the deletion target is the entire purpose of this prompt, so a mismatch defeats it.

The message is now derived from the resolved untracked path (hermes_cli/skills_hub.py:685-692), keeping the profile-safe display_hermes_home() shorthand and falling back to the absolute path when the target lies outside HERMES_HOME (i.e. a SKILLS_DIR override, where there is no ~/ shorthand to apply).

Pinned by test_untracked_warning_names_the_resolved_deletion_target in tests/hermes_cli/test_skills_hub.py:369. It asserts the emitted path equals _untracked_skill_dir()'s return value — the actual rmtree target — and that the raw category string is absent from the output. Verified red before the change (the assertion diff showed the two paths side by side) and green after; the file's 7 tests pass.

One deliberate exclusion: the same raw-category construct appears in the two "Files will be at:" panels further down (the Official Skill and Disclaimer panels). Those describe an install destination rather than a deletion target, and they are not part of this PR's diff, so I've left them alone rather than widen an in-review change. Happy to send a follow-up for them if you'd like the normalization applied there too.

… warning

The refusal message built its path by interpolating the raw `category`
string, while the directory actually at risk is the one returned by
`_untracked_skill_dir()`, which resolves through
`_validate_install_parent_path()` / `_resolve_lock_install_path()`. Those
normalize the category -- empty and "." segments are dropped and "\" is
rewritten to "/" -- so the two could disagree: with `--category
"personal//./notes"` the prompt named `skills/personal//./notes/note-taker`
while `install_from_quarantine()` would have rmtree'd
`skills/personal/notes/note-taker`.

Naming the deletion target is the entire purpose of this consent prompt, so
derive the message from the resolved path instead, keeping the
`display_hermes_home()` shorthand and falling back to the absolute path when
the target lies outside HERMES_HOME (a `SKILLS_DIR` override).
@spfcraze

spfcraze commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary:
The tui_gateway skills tool reports installed: True even when this guard refuses — its call is do_install(query, skip_confirm=True, console=_Q()), its console discards output, and the wrapper returns success unconditionally.

Problems:

  • tui_gateway/methods_tools.py:1743-1744 — the tool calls do_install(query, skip_confirm=True, console=_Q()) and then returns _ok(rid, {"installed": True, "name": query}); _Q().print (lines 1740-1741) is a no-op, so the guard's "Use --force to overwrite." warning goes nowhere.
  • The guard's refusal path is a bare return before the install call at hermes_cli/skills_hub.py:731, so do_install returns normally and the tool reports success for an install that was refused.
  • The PR's checklist says the tool "gains the same refusal as the CLI", but the CLI refusal is visible and overridable with --force; this surface is neither — the call at line 1743 has no force argument and the wrapper reads none.

Solution:
Thread a --force flag through the tui_gateway skills install action, or return an error from the tool when do_install refuses, so the refusal is observable and overridable on the surface whose console discards output.


Checked against faec6d6 — the tip of fix/skills-install-unlocked-local-overwrite-80848 when this was written — and 72b7305, main at the same moment.

…rface

The guard added earlier in this PR refuses to replace an untracked skill by
returning early from do_install(). On the tui_gateway skills tool that refusal
was invisible: the handler hands do_install() a console whose print() is a
literal `pass`, and then returns {"installed": True, "name": query}
unconditionally. So the one surface this PR newly refuses on was the one
surface that reported the refusal as a success, with no way to override it.

Consult the guard's own helper (_untracked_skill_dir) before calling, and
report {"installed": False, "reason": "untracked_skill_exists", "path": ...}
with the directory that would have been replaced and an actionable hint.
Resolving that directory is delegated to the helper rather than rebuilt here,
so this surface and the CLI cannot name different deletion targets. `force` is
now forwarded to do_install(), giving this surface the same escape hatch the
CLI has; it is coerced with the project's shared is_truthy_value() so the JSON
string "false" cannot consent to an rmtree.

The success shape is unchanged, and do_install()'s `-> None` contract is not
touched: its ten other early returns (blocked scan, rate limit, unknown source
adapter, malformed name) misreport on this surface too, but they predate this
PR and belong to different code paths.
@briandevans

Copy link
Copy Markdown
Contributor Author

@spfcraze Confirmed on all three points, and fixed in 0398a1598e0 (current head). You were right that this is worse than a missed surface — it's a hole this PR opened: before it, the unconditional {"installed": True} happened to be true, because the install always happened.

1. No-op console + unconditional success. Fixed at tui_gateway/methods_tools.py, skills.manageaction == "install". The handler now consults the guard's own helper before calling and returns an honest result:

force = is_truthy_value(params.get("force", False))
if not force:
    untracked = _untracked_skill_dir(query, "")
    if untracked is not None:
        return _ok(rid, {
            "installed": False,
            "name": query,
            "reason": "untracked_skill_exists",
            "path": str(untracked),
            "message": (...  "Retry with force: true to overwrite."),
        })
do_install(query, force=force, skip_confirm=True, console=_Q())

The success shape is byte-identical to before, so no consumer breaks. The directory is resolved by _untracked_skill_dir() rather than rebuilt here, so this surface and the CLI cannot name different deletion targets — same reasoning as the Copilot thread on the CLI message.

2. The bare return is still a bare return. I did not change do_install()'s -> None contract, so the refusal is detected by pre-checking instead of by reading a return value. Worth being explicit about the boundary: do_install() has 11 bare returns, and the other ten (unknown source adapter, fetch failure / rate limit, missing name on a URL install, blocked scan verdict, cancelled confirm, invalid install path) still misreport as installed: True on this surface. Those predate this PR and sit on different code paths, so changing the return type of a five-call-site function to fix them is a separate change rather than something to bundle into a consent-gate fix. Happy to file it, or to fold it in here if @teknium1 would rather have it in one go.

3. --force had no equivalent here. It does now — force is forwarded to do_install(). It goes through the project's shared is_truthy_value() (as methods_session.py and methods_prompt.py do) rather than bool(), because bool("false") is True and this particular flag decides whether a user's own skill directory gets rmtree'd.

And the checklist line you quoted was wrong, so I've corrected it rather than leaving it. It claimed the tool "gains the same refusal as the CLI"; the refusal behaviour did apply (no rmtree), but the report and the override did not — which is exactly what the body's own surface table records as consent "none". Body and table now agree, and the correction is noted inline.

Regression tests, in tests/tui_gateway/test_protocol.py (naming these as well as the SHA, since the SHA won't survive a rebase):

  • test_skills_manage_install_reports_untracked_refusal (:585) — asserts installed is False, the reason/path, and that do_install is never reached.
  • test_skills_manage_install_forwards_force_override (:614) — asserts force=True arrives at do_install.
  • test_skills_manage_install_force_is_not_bare_truthiness (:636) — the string "false" must not consent.

All three fail against the previous head faec6d69a93, and the first fails with assert True is False on result["installed"] — the exact symptom you described. Green after: 43 passed in that file, 67 passed across tests/hermes_cli/test_skills_hub.py + tests/tools/test_skills_hub.py, ruff clean.

One thing worth surfacing while this block is in view: #38688 (@coygeek) is a competing design on these same ~14 lines — it removes do_install from this surface entirely in favour of {"installed": False, "status": "review_required"}. It targets tui_gateway/server.py@15761, so it predates the methods_* split and doesn't apply to the current tree, but it is a genuine alternative rather than a duplicate. Flagged in the PR body too; if that direction is preferred, I'll close this half.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants