Skip to content
2 changes: 2 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7769,6 +7769,8 @@ def process_command(self, command: str) -> bool:
self.show_help()
elif canonical == "profile":
self._handle_profile_command()
elif canonical == "whoami":
self._handle_whoami_command()
elif canonical == "tools":
self._handle_tools_command(cmd_original)
elif canonical == "toolsets":
Expand Down
22 changes: 22 additions & 0 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,28 @@ def _handle_profile_command(self):
print(f" Home: {display}")
print()

def _handle_whoami_command(self):
"""Show the user's access level in the CLI context.

In the CLI the operator is always the owner with full access —
there is no platform-mediated slash-access policy. The handler
mirrors the gateway's ``/whoami`` output shape so users see a
familiar format regardless of surface.
"""
from hermes_cli.profiles import get_active_profile_name

profile_name = get_active_profile_name()

from hermes_cli.commands import COMMAND_REGISTRY
all_commands = sorted(cmd.name for cmd in COMMAND_REGISTRY if not cmd.gateway_only)

print()
print(f" **You** — CLI (local)")
print(f" Profile: {profile_name}")
print(f" Tier: owner (full access)")
print(f" Slash commands available: {len(all_commands)}")
print()

def _handle_handoff_command(self, cmd_original: str) -> bool:
"""Handle ``/handoff <platform>`` — transfer this CLI session to a gateway platform.

Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,11 @@ def create_task(
"goal_mode": bool(goal_mode) or None,
},
)
# Emit a "blocked" event so tasks created with
# initial_status="blocked" are treated as sticky-blocked
# by recompute_ready (#47777).
if task_status == "blocked":
_append_event(conn, task_id, "blocked", {"reason": "created with initial_status=blocked"})
return task_id
except sqlite3.IntegrityError:
if attempt == 1:
Expand Down
106 changes: 106 additions & 0 deletions tests/cli/test_whoami_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Tests for the /whoami CLI command dispatch and handler.

The /whoami command is registered in COMMAND_REGISTRY (and advertised by
/help, tab-completion, and the tips system) but had no dispatch branch in
HermesCLI.process_command — so typing it in the CLI/TUI/Desktop printed
"Unknown command: /whoami". These tests lock in the dispatch wiring and
the handler behavior. See issue #51009.
"""

import unittest
from unittest.mock import MagicMock, patch

from cli import HermesCLI


def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = None
cli_obj._pending_input = MagicMock()
return cli_obj


class TestWhoamiDispatch(unittest.TestCase):
"""/whoami must route to its handler — not fall through to "Unknown"."""

def test_whoami_dispatches_to_handler(self):
cli_obj = _make_cli()
with patch.object(cli_obj, "_handle_whoami_command") as mock_handler:
result = cli_obj.process_command("/whoami")

mock_handler.assert_called_once()
self.assertTrue(result)

def test_whoami_is_not_unknown_command(self):
cli_obj = _make_cli()
with patch("cli._cprint") as mock_cprint:
result = cli_obj.process_command("/whoami")

printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertNotIn("Unknown command", printed)
self.assertTrue(result)


class TestHandleWhoamiCommand(unittest.TestCase):
"""Handler should report owner-tier access for the CLI context."""

def test_output_contains_owner_tier(self):
cli_obj = _make_cli()
with (
patch("hermes_cli.profiles.get_active_profile_name", return_value="default"),
patch("builtins.print") as mock_print,
):
HermesCLI._handle_whoami_command(cli_obj)

printed = " ".join(str(c) for c in mock_print.call_args_list)
self.assertIn("owner", printed)

def test_output_contains_cli_surface(self):
cli_obj = _make_cli()
with (
patch("hermes_cli.profiles.get_active_profile_name", return_value="default"),
patch("builtins.print") as mock_print,
):
HermesCLI._handle_whoami_command(cli_obj)

printed = " ".join(str(c) for c in mock_print.call_args_list)
self.assertIn("CLI", printed)

def test_output_shows_profile_name(self):
cli_obj = _make_cli()
with (
patch("hermes_cli.profiles.get_active_profile_name", return_value="test-profile"),
patch("builtins.print") as mock_print,
):
HermesCLI._handle_whoami_command(cli_obj)

printed = " ".join(str(c) for c in mock_print.call_args_list)
self.assertIn("test-profile", printed)


class TestWhoamiRegistry(unittest.TestCase):
def test_whoami_in_registry(self):
from hermes_cli.commands import COMMAND_REGISTRY

names = [c.name for c in COMMAND_REGISTRY]
self.assertIn("whoami", names)

def test_whoami_not_gateway_only(self):
from hermes_cli.commands import COMMAND_REGISTRY

whoami = next(c for c in COMMAND_REGISTRY if c.name == "whoami")
self.assertFalse(whoami.gateway_only, "/whoami should be available on all surfaces")

def test_whoami_category_is_info(self):
from hermes_cli.commands import COMMAND_REGISTRY

whoami = next(c for c in COMMAND_REGISTRY if c.name == "whoami")
self.assertEqual(whoami.category, "Info")


if __name__ == "__main__":
unittest.main()
56 changes: 56 additions & 0 deletions tests/hermes_cli/test_kanban_blocked_sticky.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,59 @@ def test_protocol_violation_loop_is_broken(kanban_home: Path) -> None:
# (landed via #28754 / #28781). The original PR shipped a duplicate test
# here; dropped during salvage to avoid two assertions of the same contract.
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# Created-blocked tasks must stay blocked (issue #47777)
# ---------------------------------------------------------------------------


def test_created_blocked_task_stays_blocked(kanban_home: Path) -> None:
"""A task created with initial_status='blocked' must stay blocked
across dispatcher ticks, not be auto-promoted by recompute_ready."""
with kb.connect() as conn:
tid = kb.create_task(conn, title="parked task", initial_status="blocked")
assert kb.get_task(conn, tid).status == "blocked"

for _ in range(5):
promoted = kb.recompute_ready(conn)
assert promoted == 0, "created-blocked task must not auto-promote"
assert kb.get_task(conn, tid).status == "blocked"


def test_created_blocked_with_done_parents_stays_blocked(kanban_home: Path) -> None:
"""Created-blocked must stay blocked even when all parents are done."""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
child = kb.create_task(conn, title="child", parents=[parent], initial_status="blocked")
kb.complete_task(conn, parent, result="parent ok")

assert kb.get_task(conn, child).status == "blocked"
promoted = kb.recompute_ready(conn)
assert promoted == 0
assert kb.get_task(conn, child).status == "blocked"


def test_created_blocked_can_be_unblocked(kanban_home: Path) -> None:
"""A created-blocked task can be explicitly unblocked and then
promoted normally (#47777)."""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
child = kb.create_task(
conn, title="child", parents=[parent], initial_status="blocked"
)
kb.complete_task(conn, parent, result="parent ok")

# Must stay blocked until explicit unblock.
assert kb.recompute_ready(conn) == 0
assert kb.get_task(conn, child).status == "blocked"

# Explicit unblock frees it (status flips to ready because
# all parents are done).
kb.unblock_task(conn, child)
assert kb.get_task(conn, child).status == "ready"

# recompute_ready sees the task is already ready.
promoted = kb.recompute_ready(conn)
assert promoted == 0
assert kb.get_task(conn, child).status == "ready"