fix: polish command surfaces — scoping, validation, perf, consistency

This commit is contained in:
ajspig 2026-04-13 16:50:47 -04:00
parent 2599784a6a
commit 58004dd7f0
8 changed files with 138 additions and 62 deletions

View File

@ -181,13 +181,14 @@ def delete(
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a conclusion."""
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
handle_cmd_flags(json_output=json_output, quiet=quiet, workspace=workspace, peer=peer)
validate_resource_id(conclusion_id, "conclusion")
client, config = get_client()
@ -197,11 +198,27 @@ def delete(
print_error("NO_PEER", "Observer peer ID required. Use --observer or set default peer.")
raise typer.Exit(1)
if not yes:
typer.confirm(f"Delete conclusion '{conclusion_id}'?", abort=True)
p = client.peer(observer)
if not yes:
# Show a short preview so the user knows which conclusion is targeted.
preview_content: str | None = None
try:
scope_preview = p.conclusions_of(observed) if observed else p.conclusions
for c in scope_preview.list(size=100).items:
if c.id == conclusion_id:
preview_content = c.content
break
except Exception:
pass
typer.echo(
f" id: {conclusion_id}\n"
f" observer: {observer}\n"
f" observed: {observed or '(self)'}\n"
f" content: {(preview_content[:200] + '...') if preview_content and len(preview_content) > 200 else (preview_content or '(not found in first 100)')}"
)
typer.confirm(f"Delete conclusion '{conclusion_id}'?", abort=True)
try:
if observed:
scope = p.conclusions_of(observed)

View File

@ -25,6 +25,7 @@ def list_messages(
brief: bool = typer.Option(False, "--brief", help="Show only IDs, peer, token count, and created_at (no content)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List messages in a session."""
@ -32,7 +33,7 @@ def list_messages(
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
handle_cmd_flags(json_output=json_output, quiet=quiet, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
@ -102,16 +103,14 @@ def get_message(
client, config = get_client()
try:
# Use raw HTTP to get a single message
# Hit the direct message endpoint instead of paging the session.
from honcho.http import routes
from honcho.api_types import MessageResponse
from honcho.message import Message
sess = client.session(sid)
msgs = list(sess.messages())
msg = next((m for m in msgs if m.id == message_id), None)
if msg is None:
from honcho_cli.output import print_error
print_error("MESSAGE_NOT_FOUND", f"Message '{message_id}' not found in session '{sid}'", {"message_id": message_id, "session_id": sid})
raise typer.Exit(1)
data = client._http.get(routes.message(sess.workspace_id, sess.id, message_id))
msg = Message.from_api_response(MessageResponse.model_validate(data))
print_result({
"id": msg.id,

View File

@ -77,19 +77,24 @@ def inspect(
try:
card = p.get_card()
sessions = list(p.sessions())
conclusions = list(p.conclusions.list())
# First page only; SyncPage.total (when the server supplies it) is
# authoritative for counts without walking every page.
session_page = p.sessions()
conclusion_page = p.conclusions.list(size=10)
session_items = session_page.items
conclusion_items = conclusion_page.items
result = {
"id": pid,
"card": card,
"session_count": len(sessions),
"conclusion_count": len(conclusions),
"session_count": session_page.total if session_page.total is not None else len(session_items),
"conclusion_count": conclusion_page.total if conclusion_page.total is not None else len(conclusion_items),
"recent_conclusions": [
{"id": c.id, "content": c.content[:200], "created_at": str(c.created_at)}
for c in conclusions[:10]
for c in conclusion_items
],
"sessions": [{"id": s.id} for s in sessions[:10]],
"sessions": [{"id": s.id} for s in session_items[:10]],
}
print_result(result)
except Exception as e:
@ -124,7 +129,6 @@ def card(
def chat(
query: str = typer.Argument(help="Question to ask about the peer"),
target: Optional[str] = typer.Option(None, help="Target peer for perspective"),
session: Optional[str] = typer.Option(None, help="Session context"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Peer ID (uses default if omitted)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
@ -139,7 +143,9 @@ def chat(
p = client.peer(pid)
try:
response = p.chat(query, target=target, session=session)
# Session scope comes from the global -s pipeline (config.session_id),
# keeping chat consistent with every other peer/session command.
response = p.chat(query, target=target, session=config.session_id or None)
print_result({"peer_id": pid, "query": query, "response": response})
except Exception as e:
_handle_error(e, "peer", pid)
@ -208,10 +214,15 @@ def create_peer(
try:
p = client.peer(pid, configuration=peer_config, metadata=parsed_metadata)
# get-or-create semantics: the server may have an existing config that
# differs from what we passed. Read back what's actually stored so the
# output reflects server state, not the (possibly-None) input.
server_config = _config_to_dict(p.get_configuration())
server_metadata = p.get_metadata()
result = {
"peer_id": p.id,
"metadata": parsed_metadata,
"configuration": {"observe_me": observe_me} if observe_me is not None else None,
"metadata": server_metadata,
"configuration": server_config,
}
print_result(result)
except Exception as e:
@ -276,7 +287,6 @@ def set_metadata(
def representation(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer to get representation about"),
session: Optional[str] = typer.Option(None, help="Scope representation to a session"),
search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"),
max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
@ -295,7 +305,7 @@ def representation(
try:
result = p.representation(
target=target,
session=session,
session=config.session_id or None,
search_query=search_query,
max_conclusions=max_conclusions,
)

View File

@ -34,7 +34,6 @@ def _get_session_id(session_id: str | None) -> str:
def list_sessions(
peer_id: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List sessions in the workspace."""
@ -42,7 +41,7 @@ def list_sessions(
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
handle_cmd_flags(json_output=json_output, workspace=workspace)
client, config = get_client()
try:
@ -84,17 +83,22 @@ def inspect(
try:
peers = sess.peers()
messages = list(sess.messages())
msg_page = sess.messages()
summaries = sess.summaries()
sess_config = sess.get_configuration()
from honcho_cli.commands.workspace import _compact_config
# Use SyncPage.total when the server provides it; fall back to a
# first-page count to avoid paginating the full session just for a
# count.
message_count = msg_page.total if msg_page.total is not None else len(msg_page.items)
raw_config = _config_to_dict(sess_config) if sess_config else None
result = {
"id": sid,
"peers": [{"id": p.id} for p in peers],
"message_count": len(messages),
"message_count": message_count,
"summaries": {
"short": summaries.short_summary if hasattr(summaries, "short_summary") else None,
"long": summaries.long_summary if hasattr(summaries, "long_summary") else None,
@ -208,21 +212,33 @@ def delete(
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a session and all its data. Destructive — requires --yes or interactive confirm."""
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
handle_cmd_flags(json_output=json_output, quiet=quiet, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
if not yes:
# Show a short preview so the user knows what's about to disappear.
try:
peers = sess.peers()
message_count = len(sess.messages().items)
peer_ids = [p.id for p in peers]
typer.echo(
f" session: {sid}\n"
f" peers: {', '.join(peer_ids) if peer_ids else '(none)'}\n"
f" messages: {message_count}+ (first page)"
)
except Exception:
pass
typer.confirm(f"Delete session '{sid}' and all its messages, conclusions, and queue items?", abort=True)
sess = client.session(sid)
try:
sess.delete()
status(f"Session '{sid}' deleted")
@ -257,8 +273,8 @@ def session_peers(
@app.command("add-peers")
def add_peers(
session_id: str = typer.Argument(help="Session ID"),
peer_ids: List[str] = typer.Argument(help="Peer IDs to add to the session"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
@ -266,7 +282,7 @@ def add_peers(
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session_id)
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
@ -280,8 +296,8 @@ def add_peers(
@app.command("remove-peers")
def remove_peers(
session_id: str = typer.Argument(help="Session ID"),
peer_ids: List[str] = typer.Argument(help="Peer IDs to remove from the session"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
@ -289,7 +305,7 @@ def remove_peers(
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session_id)
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
@ -304,17 +320,17 @@ def remove_peers(
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
limit: int = typer.Option(10, help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID (uses default if omitted)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Search messages in a session."""
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(None)
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
@ -337,19 +353,19 @@ def search(
@app.command()
def representation(
peer_id: str = typer.Argument(help="Peer ID to get representation for"),
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer (what peer_id knows about target)"),
search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"),
max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID (uses default if omitted)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get the representation of a peer within a session."""
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(None)
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
@ -391,16 +407,16 @@ def get_metadata(
@app.command("set-metadata")
def set_metadata(
metadata: str = typer.Argument(help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"),
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID (uses default if omitted)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Set metadata for a session."""
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(None)
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
try:

View File

@ -76,7 +76,7 @@ def inspect(
handle_cmd_flags(json_output=json_output, workspace=workspace)
wid = _get_workspace_id(workspace_id)
client, config = get_client()
client, config = get_client(require_workspace=False)
# Override workspace if positional arg given
if workspace_id:
@ -116,6 +116,7 @@ def delete(
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt (for scripted/agent use)"),
cascade: bool = typer.Option(False, "--cascade", help="Delete all sessions before deleting the workspace"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a workspace. Use --dry-run first to see what will be deleted.
@ -126,10 +127,12 @@ def delete(
from honcho_cli.common import handle_cmd_flags
from honcho_cli.main import get_client
handle_cmd_flags(json_output=json_output)
handle_cmd_flags(json_output=json_output, quiet=quiet)
validate_resource_id(workspace_id, "workspace")
client, config = get_client()
# workspace_id is a required positional and we rebuild the client with it
# immediately, so the default-workspace guard isn't needed here.
client, config = get_client(require_workspace=False)
ws_client = _with_workspace(client, workspace_id)
# Always fetch sessions for dry-run or cascade
@ -175,7 +178,6 @@ def delete(
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
workspace_id: Optional[str] = typer.Option(None, help="Workspace ID"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
limit: int = typer.Option(10, help="Max results"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
@ -186,7 +188,7 @@ def search(
handle_cmd_flags(json_output=json_output, workspace=workspace)
wid = _get_workspace_id(workspace_id)
wid = _get_workspace_id(None)
client, config = get_client()
try:
@ -208,11 +210,9 @@ def search(
@app.command("queue-status")
def queue_status(
workspace_id: Optional[str] = typer.Option(None, help="Workspace ID"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
observer: Optional[str] = typer.Option(None, help="Filter by observer peer"),
sender: Optional[str] = typer.Option(None, help="Filter by sender peer"),
session: Optional[str] = typer.Option(None, help="Filter by session"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get queue processing status."""
@ -221,11 +221,11 @@ def queue_status(
handle_cmd_flags(json_output=json_output, workspace=workspace)
_get_workspace_id(workspace_id)
_get_workspace_id(None)
client, config = get_client()
try:
result = client.queue_status(observer=observer, sender=sender, session=session)
result = client.queue_status(observer=observer, sender=sender, session=config.session_id or None)
print_result(result.__dict__ if hasattr(result, "__dict__") else result)
except Exception as e:
_handle_error(e, "queue", "status")

View File

@ -18,6 +18,7 @@ from honcho_cli.output import set_json_mode, set_quiet_mode
def handle_cmd_flags(
json_output: bool = False,
quiet: bool = False,
workspace: str | None = None,
peer: str | None = None,
session: str | None = None,
@ -25,6 +26,8 @@ def handle_cmd_flags(
"""Apply command-level flags. Idempotent if already set by group callback."""
if json_output:
set_json_mode(True)
if quiet:
set_quiet_mode(True)
from honcho_cli.main import _global_overrides

View File

@ -82,6 +82,11 @@ class CLIConfig:
val = os.environ.get(env_var)
if val:
setattr(config, fld_name, val)
elif val == "":
# SDK reads these env vars directly and crashes on empty
# strings with a Pydantic ValidationError. Drop them so the
# SDK falls back to kwargs / defaults.
os.environ.pop(env_var, None)
return config

View File

@ -29,9 +29,26 @@ app = typer.Typer(
)
def _json_requested_early() -> bool:
"""Best-effort JSON detection before Typer parses flags.
version_callback is eager and fires before set_json_mode() runs, so we
can't call use_json() here. Mirror its logic against argv/env/TTY.
"""
import os
import sys
return (
"--json" in sys.argv
or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true")
or not sys.stdout.isatty()
)
def version_callback(value: bool) -> None:
if value:
print(BANNER)
if not _json_requested_early():
print(BANNER)
print(f" honcho-cli {__version__}")
raise typer.Exit()
@ -58,9 +75,12 @@ def main(
if ctx.invoked_subcommand is None:
from rich.console import Console
from honcho_cli.output import use_json
console = Console()
console.print(f"[bold #B6DAFD]{BANNER}[/bold #B6DAFD]")
console.print(f" [dim]v{__version__}[/dim]\n")
if not use_json():
console.print(f"[bold #B6DAFD]{BANNER}[/bold #B6DAFD]")
console.print(f" [dim]v{__version__}[/dim]\n")
console.print(ctx.get_help())
raise typer.Exit()
@ -74,17 +94,23 @@ _global_overrides: dict[str, str | None] = {
def get_resolved_config():
"""Get config with global flag overrides applied."""
"""Get config with global flag overrides applied.
Overrides flow through ``validate_resource_id`` so that a malformed
``-w``/``-p``/``-s`` value fails fast with a structured error rather than
reaching the API and surfacing as an opaque ``UNKNOWN_ERROR``.
"""
from honcho_cli.config import CLIConfig
from honcho_cli.validation import validate_resource_id
config = CLIConfig.load()
if _global_overrides["workspace"]:
config.workspace_id = _global_overrides["workspace"]
config.workspace_id = validate_resource_id(_global_overrides["workspace"], "workspace")
if _global_overrides["peer"]:
config.peer_id = _global_overrides["peer"]
config.peer_id = validate_resource_id(_global_overrides["peer"], "peer")
if _global_overrides["session"]:
config.session_id = _global_overrides["session"]
config.session_id = validate_resource_id(_global_overrides["session"], "session")
return config