honcho-cli 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/CHANGELOG.md +29 -1
  2. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/PKG-INFO +9 -2
  3. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/README.md +7 -0
  4. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/pyproject.toml +2 -2
  5. honcho_cli-0.5.0/src/honcho_cli/__init__.py +11 -0
  6. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/_help.py +2 -0
  7. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/conclusion.py +18 -5
  8. honcho_cli-0.5.0/src/honcho_cli/commands/dream.py +318 -0
  9. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/setup.py +2 -0
  10. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/common.py +13 -6
  11. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/config.py +22 -10
  12. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/main.py +2 -0
  13. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_common.py +81 -1
  14. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_conclusion_attribution.py +39 -1
  15. honcho_cli-0.5.0/tests/test_dream.py +234 -0
  16. honcho_cli-0.5.0/tests/test_version.py +13 -0
  17. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/uv.lock +5 -5
  18. honcho_cli-0.3.0/src/honcho_cli/__init__.py +0 -3
  19. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/.gitignore +0 -0
  20. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/scripts/generate_cli_docs.py +0 -0
  21. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/agents.py +0 -0
  22. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/branding.py +0 -0
  23. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/__init__.py +0 -0
  24. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/config_cmd.py +0 -0
  25. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/install.py +0 -0
  26. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/message.py +0 -0
  27. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/peer.py +0 -0
  28. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/scope.py +0 -0
  29. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/session.py +0 -0
  30. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/stack.py +0 -0
  31. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/workspace.py +0 -0
  32. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/__init__.py +0 -0
  33. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/docker.py +0 -0
  34. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/env.py +0 -0
  35. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/health.py +0 -0
  36. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/profile.py +0 -0
  37. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/setup.py +0 -0
  38. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/templates/__init__.py +0 -0
  39. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/templates/docker-compose.yml +0 -0
  40. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/templates/init.sql +0 -0
  41. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/oauth.py +0 -0
  42. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/output.py +0 -0
  43. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/recall.py +0 -0
  44. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/update_check.py +0 -0
  45. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/validation.py +0 -0
  46. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/__init__.py +0 -0
  47. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/conftest.py +0 -0
  48. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_commands.py +0 -0
  49. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_config.py +0 -0
  50. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_install.py +0 -0
  51. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_local.py +0 -0
  52. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_oauth.py +0 -0
  53. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_output.py +0 -0
  54. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_recall.py +0 -0
  55. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_setup.py +0 -0
  56. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_start.py +0 -0
  57. {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_validation.py +0 -0
@@ -5,7 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.5.0] - 2026-11-05
9
+
10
+ ### Added
11
+
12
+ - `honcho dream list` and `honcho dream inspect <id>` (Representation History): list the dreams in a workspace, newest first, or the dreams about one peer with `-p`, and see what a dream changed. `inspect` shows the conclusions it created, each with the conclusions it was derived from and where those stand now (current, removed and by which dream, or unavailable once deleted or removed longer ago than history is kept); the conclusions it removed, as they read when removed; and the peer card before and after. A dream past the retention window shows when it was pruned and keeps only the conclusions it created that still exist. Needs a workspace or admin key, and Honcho v3.6.0+ (#1542)
13
+
14
+ ### Changed
15
+
16
+ - Requires `honcho-ai` 2.9.0 or newer, the first Python SDK with `dreams()` and `get_dream()`
17
+
18
+ ### Fixed
19
+
20
+ - `HONCHO_API_KEY` wins over a saved OAuth grant, as the documented flag > env > file order says. Before, `HONCHO_API_KEY=<key> honcho workspace list` quietly ran as the saved OAuth identity. A run with an env key no longer refreshes or writes `~/.honcho/config.json`, and an OAuth refresh under `HONCHO_API_KEY` or `HONCHO_BASE_URL` no longer writes those values into it, which had switched every other tool sharing the file to that key's org. A refreshed grant records the host it was refreshed against, and `honcho doctor` names the env key as the credential in use (#1518)
21
+
22
+ ## [0.4.0] - 2026-10-29
23
+
24
+ ### Added
25
+
26
+ - `source_message_ids` on conclusion output: the messages an explicit conclusion cites as evidence (Honcho v3.5.0+; a count in table mode, the full list in JSON). `conclusion list --cites <message_id>` narrows to the conclusions that cite a message. Null in JSON on servers or SDKs that predate the field (#935)
27
+
28
+ ### Changed
29
+
30
+ - Requires `honcho-ai` 2.8.0 or newer, the first Python SDK that carries `source_message_ids`
9
31
 
10
32
  ## [0.3.0] - 2026-10-23
11
33
 
@@ -24,6 +46,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
24
46
 
25
47
  - `honcho session summaries`, `honcho session inspect`, `honcho session context` and `honcho workspace queue-status` emit nested SDK objects (summaries, context messages, per-session queue status) as JSON objects instead of Python repr strings like `"content='...' message_id='...'"` (#1324)
26
48
 
49
+ ## [0.2.1] - 2026-10-20
50
+
51
+ ### Fixed
52
+
53
+ - `honcho session summaries`, `honcho session inspect`, `honcho session context` and `honcho workspace queue-status` emit nested SDK objects (summaries, context messages, per-session queue status) as JSON objects instead of Python repr strings like `"content='...' message_id='...'"` (#1324)
54
+
27
55
  ## [0.2.0] - 2026-09-21
28
56
 
29
57
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: honcho-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: A terminal for Honcho — memory that reasons.
5
5
  Project-URL: Homepage, https://github.com/plastic-labs/honcho
6
6
  Project-URL: Repository, https://github.com/plastic-labs/honcho
@@ -15,7 +15,7 @@ Classifier: Topic :: Software Development :: Libraries
15
15
  Requires-Python: >=3.11
16
16
  Requires-Dist: certifi>=2026.10.19
17
17
  Requires-Dist: click>=8.0.0
18
- Requires-Dist: honcho-ai>=2.5.0
18
+ Requires-Dist: honcho-ai>=2.9.0
19
19
  Requires-Dist: httpx>=0.27.0
20
20
  Requires-Dist: questionary>=2.1.0
21
21
  Requires-Dist: rich>=13.0.0
@@ -166,6 +166,13 @@ honcho stop --wipe # also delete volumes
166
166
  | `honcho conclusion create` | Create a conclusion |
167
167
  | `honcho conclusion delete <id>` | Delete a conclusion |
168
168
 
169
+ ### Dreams (Representation History)
170
+
171
+ | Command | Description |
172
+ |---------|-------------|
173
+ | `honcho dream list` | Dreams in the workspace, newest first (`-p` for dreams about one peer, `--observer` for one representation) |
174
+ | `honcho dream inspect <id>` | What one dream changed: conclusions created (with what each was derived from), conclusions removed, and the peer card before and after |
175
+
169
176
  ### Config
170
177
 
171
178
  | Command | Description |
@@ -139,6 +139,13 @@ honcho stop --wipe # also delete volumes
139
139
  | `honcho conclusion create` | Create a conclusion |
140
140
  | `honcho conclusion delete <id>` | Delete a conclusion |
141
141
 
142
+ ### Dreams (Representation History)
143
+
144
+ | Command | Description |
145
+ |---------|-------------|
146
+ | `honcho dream list` | Dreams in the workspace, newest first (`-p` for dreams about one peer, `--observer` for one representation) |
147
+ | `honcho dream inspect <id>` | What one dream changed: conclusions created (with what each was derived from), conclusions removed, and the peer card before and after |
148
+
142
149
  ### Config
143
150
 
144
151
  | Command | Description |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "honcho-cli"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "A terminal for Honcho — memory that reasons."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -19,7 +19,7 @@ classifiers = [
19
19
  dependencies = [
20
20
  "click>=8.0.0",
21
21
  "typer>=0.15.0",
22
- "honcho-ai>=2.5.0",
22
+ "honcho-ai>=2.9.0",
23
23
  "rich>=13.0.0",
24
24
  "httpx>=0.27.0",
25
25
  "questionary>=2.1.0",
@@ -0,0 +1,11 @@
1
+ """Honcho CLI — a terminal for Honcho."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ # pyproject.toml is the single source of the version; reading it from the
7
+ # installed metadata keeps --version, the update check and the
8
+ # X-Honcho-Host header in step with each release bump.
9
+ __version__ = version("honcho-cli")
10
+ except PackageNotFoundError: # running from a source tree that isn't installed
11
+ __version__ = "0+unknown"
@@ -91,6 +91,7 @@ def print_welcome(console: Console) -> None:
91
91
  ("", "remove-session · status"),
92
92
  ("message", "list · create · get"),
93
93
  ("conclusion", "list · create · search · delete"),
94
+ ("dream", "list · inspect"),
94
95
  ("config", "inspect current configuration"),
95
96
  ]
96
97
  memory_rows = [
@@ -103,6 +104,7 @@ def print_welcome(console: Console) -> None:
103
104
  ("honcho peer chat \"...\" -p <peer> --scope <scope>", "answer from one scope's sessions only"),
104
105
  ("honcho peer card -p <peer> -w <workspace>", "synthesized identity: traits, preferences, instructions"),
105
106
  ("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
107
+ ("honcho dream list -p <peer> -w <workspace>", "dreams about a peer; inspect one to see what it changed"),
106
108
  ("honcho session view / context -s <session>", "transcript, or what an agent would see"),
107
109
  ("honcho workspace queue-status", "is the deriver processing?"),
108
110
  ]
@@ -21,23 +21,29 @@ add_common_options(app)
21
21
  _LEVELS = ("explicit", "deductive", "inductive", "contradiction")
22
22
 
23
23
  #: Columns carrying a conclusion's attribution, shown alongside the content.
24
- _ATTRIBUTION_COLUMNS = ["id", "level", "source_ids", "times_derived", "content"]
24
+ _ATTRIBUTION_COLUMNS = ["id", "level", "source_ids", "source_message_ids", "times_derived", "content"]
25
25
 
26
26
 
27
27
  def _format_conclusion(c, workspace_id: str | None) -> dict:
28
28
  """Shape a Conclusion for output.
29
29
 
30
30
  ``level``, ``source_ids`` and ``times_derived`` are the attribution the
31
- server started returning in Honcho v3.2.0. In table mode ``source_ids``
32
- collapses to a count, since the ids are nanoids and a list of them makes
33
- the row unreadable; JSON mode keeps the full list so it can be piped back
34
- into ``honcho conclusion get``.
31
+ server started returning in Honcho v3.2.0; ``source_message_ids`` (the
32
+ messages an explicit conclusion cites) arrived in v3.5.0 and is read
33
+ leniently so older SDKs still work. In table mode the id lists collapse
34
+ to counts, since nanoids make the row unreadable; JSON mode keeps them so
35
+ they can be piped back into ``honcho conclusion get`` or ``message get``.
35
36
  """
36
37
  source_ids = c.source_ids or []
38
+ # JSON keeps None ("no citation record") apart from [] ("cites nothing").
39
+ source_message_ids = getattr(c, "source_message_ids", None)
37
40
  return {
38
41
  "id": c.id,
39
42
  "level": c.level,
40
43
  "source_ids": source_ids if use_json() else len(source_ids),
44
+ "source_message_ids": source_message_ids
45
+ if use_json()
46
+ else len(source_message_ids or []),
41
47
  "times_derived": c.times_derived,
42
48
  "content": c.content if use_json() else c.content[:160],
43
49
  "workspace_id": workspace_id,
@@ -86,6 +92,11 @@ def list_conclusions(
86
92
  "--derived-from",
87
93
  help="Only conclusions derived from this conclusion ID",
88
94
  ),
95
+ cites: Optional[str] = typer.Option(
96
+ None,
97
+ "--cites",
98
+ help="Only explicit conclusions that cite this message ID as evidence",
99
+ ),
89
100
  workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
90
101
  peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
91
102
  json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
@@ -104,6 +115,8 @@ def list_conclusions(
104
115
  filters["level"] = level
105
116
  if derived_from:
106
117
  filters["source_ids"] = {"contains": derived_from}
118
+ if cites:
119
+ filters["source_message_ids"] = {"contains": cites}
107
120
 
108
121
  try:
109
122
  if observed:
@@ -0,0 +1,318 @@
1
+ """Dream commands: list, inspect.
2
+
3
+ Representation History: every dream records what it changed in a peer's
4
+ representation — the conclusions it created (with the conclusions each was
5
+ derived from), the conclusions it removed (with their text as it read when
6
+ removed), and the peer card before and after. Runs are kept for the server's
7
+ retention window (30 days by default). Needs a workspace or admin key.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+ from typing import Any, Optional
14
+
15
+ import typer
16
+ from rich.console import Group
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ from honcho_cli._help import HonchoTyperGroup
22
+ from honcho_cli.commands.workspace import _handle_error
23
+ from honcho_cli.common import add_common_options, get_client, handle_cmd_flags
24
+ from honcho_cli.output import (
25
+ _format_timestamp,
26
+ print_error,
27
+ print_json,
28
+ status,
29
+ stdout_console,
30
+ use_json,
31
+ )
32
+ from honcho_cli.validation import validate_resource_id
33
+
34
+ app = typer.Typer(
35
+ cls=HonchoTyperGroup,
36
+ help="List dreams and inspect what each one changed in a peer's representation.",
37
+ )
38
+ add_common_options(app)
39
+
40
+
41
+ _STATUS_STYLE = {
42
+ "completed": "green",
43
+ "running": "cyan",
44
+ "failed": "red",
45
+ "interrupted": "yellow",
46
+ }
47
+
48
+ def _short_time(value: datetime | None) -> str:
49
+ """``YYYY-MM-DD HH:MM`` UTC — a dream takes minutes, not milliseconds."""
50
+ return _format_timestamp(value)[:16].replace("T", " ") if value else ""
51
+
52
+
53
+ def _changes(run: Any) -> Text:
54
+ text = Text()
55
+ text.append(f"+{run.created_count}", style="green" if run.created_count else "dim")
56
+ text.append(" ")
57
+ text.append(f"-{run.removed_count}", style="red" if run.removed_count else "dim")
58
+ if run.peer_card_updated:
59
+ text.append(" card", style="cyan")
60
+ return text
61
+
62
+
63
+ def _print_runs(runs: list, title: str) -> None:
64
+ """Dream runs as a table. Run IDs never truncate: they feed `dream inspect`."""
65
+ table = Table(title=title, show_header=True, header_style="bold")
66
+ # min_width holds the id whole in a narrow terminal; the rest wrap instead.
67
+ table.add_column("id", no_wrap=True, min_width=max(len(run.id) for run in runs))
68
+ table.add_column("started (UTC)", style="dim")
69
+ table.add_column("status")
70
+ table.add_column("observer → observed", overflow="fold")
71
+ table.add_column("type", style="dim", overflow="fold")
72
+ table.add_column("changes")
73
+ for run in runs:
74
+ table.add_row(
75
+ run.id,
76
+ _short_time(run.started_at),
77
+ Text(run.status, style=_STATUS_STYLE.get(run.status, "")),
78
+ f"{run.observer_id} → {run.observed_id}",
79
+ " · ".join(part for part in (run.dream_type, run.trigger_reason) if part),
80
+ _changes(run),
81
+ )
82
+ stdout_console.print(table)
83
+
84
+
85
+ @app.command("list")
86
+ def list_dreams(
87
+ observer: Optional[str] = typer.Option(
88
+ None,
89
+ "--observer",
90
+ help="Only dreams from this observer's perspective (with -p: one representation)",
91
+ ),
92
+ limit: int = typer.Option(20, "--limit", help="Dreams per page"),
93
+ page: int = typer.Option(1, "--page", help="Page number, newest dreams first"),
94
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
95
+ peer: Optional[str] = typer.Option(
96
+ None, "--peer", "-p", help="Only dreams about this peer (the observed peer)"
97
+ ),
98
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
99
+ ) -> None:
100
+ """List dreams in the workspace, newest first.
101
+
102
+ Pass -p to see the dreams about one peer, and --observer to narrow to one
103
+ observer's model of that peer. `honcho dream inspect <id>` shows what a
104
+ dream changed.
105
+ """
106
+ handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
107
+ if limit < 1 or page < 1:
108
+ print_error("INVALID_ARGUMENT", "--limit and --page must be positive")
109
+ raise typer.Exit(1)
110
+ if observer:
111
+ validate_resource_id(observer, "peer")
112
+ client, config = get_client()
113
+
114
+ try:
115
+ # An unset peer is "" in config; "" would filter to no peer at all.
116
+ result = client.dreams(
117
+ observed=config.peer_id or None, observer=observer or None, page=page, size=limit
118
+ )
119
+ if use_json():
120
+ print_json([run.model_dump(mode="json") for run in result.items])
121
+ return
122
+ title = "Dreams"
123
+ if config.peer_id:
124
+ title += f" about {config.peer_id}"
125
+ if result.pages:
126
+ title += f" · page {result.page}/{result.pages}"
127
+ if not result.items:
128
+ status("No dreams recorded in the retention window.")
129
+ return
130
+ _print_runs(result.items, title)
131
+ if result.pages and page < result.pages:
132
+ status(f"more: --page {page + 1}")
133
+ except Exception as e:
134
+ _handle_error(e, "dream", "list")
135
+
136
+
137
+ def _duration(started: datetime, finished: datetime | None) -> str:
138
+ if finished is None:
139
+ return ""
140
+ seconds = int((finished - started).total_seconds())
141
+ minutes, seconds = divmod(seconds, 60)
142
+ return f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s"
143
+
144
+
145
+ def _removed_note(removed_in_run: str | None, run_id: str) -> str:
146
+ if removed_in_run == run_id:
147
+ return "removed in this dream"
148
+ return f"removed by dream {removed_in_run}" if removed_in_run else "removed"
149
+
150
+
151
+ def _source_line(source: Any, run_id: str) -> Text:
152
+ """One source conclusion, marked by what has happened to it since."""
153
+ line = Text(" ")
154
+ if source.status == "current":
155
+ line.append("● ", style="green")
156
+ line.append(source.content or "")
157
+ elif source.status == "removed":
158
+ line.append("✕ ", style="red")
159
+ line.append(source.content or "", style="strike dim")
160
+ line.append(f" {_removed_note(source.removed_in_run, run_id)}", style="dim red")
161
+ else:
162
+ line.append("? ", style="dim")
163
+ line.append(
164
+ f"{source.id} (unavailable: deleted, or removed longer ago than history is kept)",
165
+ style="dim",
166
+ )
167
+ return line
168
+
169
+
170
+ def _header(detail: Any) -> Panel:
171
+ status_text = Text(detail.status, style=f"bold {_STATUS_STYLE.get(detail.status, '')}")
172
+ grid = Table.grid(padding=(0, 2))
173
+ grid.add_column(style="bold")
174
+ grid.add_column()
175
+ grid.add_row("representation", f"{detail.observer_id} → {detail.observed_id}")
176
+ grid.add_row(
177
+ "type",
178
+ " · ".join(part for part in (detail.dream_type, detail.trigger_reason) if part),
179
+ )
180
+ grid.add_row("status", status_text)
181
+ grid.add_row("started", _format_timestamp(detail.started_at))
182
+ if detail.finished_at:
183
+ grid.add_row(
184
+ "finished",
185
+ f"{_format_timestamp(detail.finished_at)} "
186
+ f"({_duration(detail.started_at, detail.finished_at)})",
187
+ )
188
+ if detail.pruned_at:
189
+ grid.add_row(
190
+ "pruned",
191
+ Text(
192
+ f"{_format_timestamp(detail.pruned_at)} past retention: only "
193
+ "created conclusions that still exist are shown",
194
+ style="dim",
195
+ ),
196
+ )
197
+ grid.add_row(
198
+ "changes",
199
+ Text.assemble(
200
+ (f"+{detail.created_count} created", "green"),
201
+ " ",
202
+ (f"-{detail.removed_count} removed", "red"),
203
+ " ",
204
+ ("peer card updated" if detail.peer_card_updated else "peer card unchanged", "dim"),
205
+ ),
206
+ )
207
+ return Panel(grid, title=f"dream {detail.id}", title_align="left")
208
+
209
+
210
+ def _created_table(detail: Any) -> Table:
211
+ table = Table(
212
+ title=f"Created ({len(detail.created)})",
213
+ title_justify="left",
214
+ show_header=True,
215
+ header_style="bold",
216
+ show_lines=True,
217
+ expand=True,
218
+ )
219
+ table.add_column("specialist", style="dim", no_wrap=True)
220
+ table.add_column("level", no_wrap=True)
221
+ table.add_column("conclusion, and what it was derived from", ratio=1)
222
+ for created in detail.created:
223
+ conclusion = created.conclusion
224
+ body = Text()
225
+ body.append("+ ", style="bold green")
226
+ if conclusion.status == "current":
227
+ body.append(conclusion.content or "")
228
+ elif conclusion.status == "removed":
229
+ body.append(conclusion.content or "", style="strike")
230
+ body.append(
231
+ f" ({_removed_note(conclusion.removed_in_run, detail.id)})", style="dim red"
232
+ )
233
+ else:
234
+ body.append(f"{conclusion.id} (unavailable)", style="dim")
235
+ lines = [body] + [_source_line(source, detail.id) for source in created.sources]
236
+ table.add_row(created.specialist or "", conclusion.level or "", Group(*lines))
237
+ return table
238
+
239
+
240
+ def _removed_table(detail: Any) -> Table:
241
+ replacements = {c.conclusion.id: c.conclusion.content for c in detail.created}
242
+ table = Table(
243
+ title=f"Removed ({len(detail.removed)})",
244
+ title_justify="left",
245
+ show_header=True,
246
+ header_style="bold",
247
+ show_lines=True,
248
+ expand=True,
249
+ )
250
+ table.add_column("specialist", style="dim", no_wrap=True)
251
+ table.add_column("level", no_wrap=True)
252
+ table.add_column("conclusion, as it read when removed", ratio=1)
253
+ for removed in detail.removed:
254
+ body = Text()
255
+ body.append("- ", style="bold red")
256
+ body.append(removed.content)
257
+ lines = [body]
258
+ if removed.replaced_by:
259
+ replacement = replacements.get(removed.replaced_by) or removed.replaced_by
260
+ lines.append(Text(f" replaced by: {replacement}", style="dim"))
261
+ table.add_row(removed.specialist or "", removed.level or "", Group(*lines))
262
+ return table
263
+
264
+
265
+ def _peer_card_panel(detail: Any) -> Panel:
266
+ before = list(detail.peer_card.before or [])
267
+ after = list(detail.peer_card.after)
268
+ lines: list[Text] = []
269
+ for entry in before:
270
+ if entry not in after:
271
+ lines.append(Text(f"- {entry}", style="red"))
272
+ for entry in after:
273
+ if entry in before:
274
+ lines.append(Text(f" {entry}", style="dim"))
275
+ else:
276
+ lines.append(Text(f"+ {entry}", style="green"))
277
+ if not lines:
278
+ lines.append(Text("(empty)", style="dim"))
279
+ return Panel(Group(*lines), title="Peer card", title_align="left")
280
+
281
+
282
+ @app.command()
283
+ def inspect(
284
+ run_id: str = typer.Argument(help="Dream run ID, from `honcho dream list`"),
285
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
286
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
287
+ ) -> None:
288
+ """Show what one dream changed.
289
+
290
+ Lists the conclusions it created, each with the conclusions it was derived
291
+ from; the conclusions it removed, as they read when removed; and the peer
292
+ card before and after. Each source shows where it stands now: current,
293
+ removed (and by which dream), or unavailable once deleted or removed longer
294
+ ago than history is kept.
295
+ """
296
+ handle_cmd_flags(json_output=json_output, workspace=workspace)
297
+ validate_resource_id(run_id, "dream")
298
+ client, _config = get_client()
299
+
300
+ try:
301
+ detail = client.get_dream(run_id)
302
+ except Exception as e:
303
+ _handle_error(e, "dream", run_id)
304
+ return
305
+
306
+ if use_json():
307
+ print_json(detail.model_dump(mode="json"))
308
+ return
309
+
310
+ stdout_console.print(_header(detail))
311
+ if detail.created:
312
+ stdout_console.print(_created_table(detail))
313
+ if detail.removed:
314
+ stdout_console.print(_removed_table(detail))
315
+ if detail.peer_card is not None:
316
+ stdout_console.print(_peer_card_panel(detail))
317
+ if not (detail.created or detail.removed or detail.peer_card):
318
+ status("This dream committed no changes.")
@@ -334,6 +334,8 @@ def _check_connection(base_url: str, api_key: str) -> None:
334
334
 
335
335
  def _auth_mode_detail(config: CLIConfig) -> str:
336
336
  """Human summary of which credential the CLI will use."""
337
+ if config.api_key_from_env:
338
+ return "API key (HONCHO_API_KEY)"
337
339
  tokens = config.usable_oauth()
338
340
  if tokens is not None:
339
341
  if tokens.access_valid():
@@ -64,10 +64,13 @@ def get_flag_overrides() -> dict[str, str | None]:
64
64
  def maybe_refresh_token(config: CLIConfig) -> None:
65
65
  """Refresh an expired OAuth access token in place and persist it.
66
66
 
67
- No-op when there is no grant for the current host or the token is still
68
- valid. A dead grant degrades to the saved apiKey with a warning; exits
69
- only when nothing is left to authenticate with.
67
+ No-op when HONCHO_API_KEY is set (the env key wins, so the grant goes
68
+ unused), when there is no grant for the current host, or when the token is
69
+ still valid. A dead grant degrades to the saved apiKey with a warning;
70
+ exits only when nothing is left to authenticate with.
70
71
  """
72
+ if config.api_key_from_env:
73
+ return
71
74
  tokens = config.usable_oauth()
72
75
  if tokens is None or tokens.access_valid():
73
76
  return
@@ -83,15 +86,19 @@ def maybe_refresh_token(config: CLIConfig) -> None:
83
86
  if refreshed is not None:
84
87
  # rotation-safe: persist the (possibly new) refresh token before
85
88
  # it's reused; keep the old one if the server didn't rotate
86
- # (refresh_token is optional)
89
+ # (refresh_token is optional). Only the grant is written: config
90
+ # carries env overrides that must not land in the shared file.
91
+ # Bind it to the host it was refreshed against, so a legacy
92
+ # hostless grant refreshed under HONCHO_BASE_URL isn't later sent
93
+ # to the saved environmentUrl.
87
94
  config.oauth = OAuthTokens.from_response(
88
95
  refreshed,
89
96
  client_id=tokens.client_id,
90
97
  scope_fallback=tokens.scope,
91
98
  refresh_fallback=tokens.refresh_token,
92
- host=tokens.host,
99
+ host=config.base_url,
93
100
  )
94
- config.save()
101
+ config.save(oauth_only=True)
95
102
  return
96
103
 
97
104
  if config.api_key:
@@ -12,7 +12,8 @@ The CLI owns these top-level keys in that file:
12
12
 
13
13
  ``apiKey`` (manual admin JWT) is shared with sibling tools: the CLI writes it
14
14
  on paste-key login and reads it as a fallback, but never deletes it. A live
15
- OAuth token takes precedence over ``apiKey`` for the CLI's own calls.
15
+ OAuth token takes precedence over ``apiKey`` for the CLI's own calls, and
16
+ ``HONCHO_API_KEY`` takes precedence over both.
16
17
 
17
18
  All other top-level keys (``hosts``, ``sessions``, ``saveMessages``,
18
19
  ``sessionStrategy``, …) are written by sibling Honcho tools and are
@@ -148,6 +149,7 @@ class CLIConfig:
148
149
  peer_id: str = ""
149
150
  session_id: str = ""
150
151
  oauth: OAuthTokens | None = None
152
+ api_key_from_env: bool = False # api_key came from HONCHO_API_KEY, not the file
151
153
 
152
154
  def usable_oauth(self) -> OAuthTokens | None:
153
155
  """The OAuth grant, if present and bound to the current host."""
@@ -160,12 +162,15 @@ class CLIConfig:
160
162
  return None
161
163
 
162
164
  def resolved_api_key(self) -> str:
163
- """The key handed to the SDK: a live OAuth token wins, else apiKey.
165
+ """The key handed to the SDK: HONCHO_API_KEY, else a live OAuth token, else apiKey.
164
166
 
165
- An expired grant loses to a saved apiKey (a dead grant degrades to the
166
- shared key) but still wins over nothing, since the server is the final
167
- judge.
167
+ An env key is an explicit per-run choice, so it beats the stored grant
168
+ (flag > env > file). An expired grant loses to a saved apiKey (a dead
169
+ grant degrades to the shared key) but still wins over nothing, since
170
+ the server is the final judge.
168
171
  """
172
+ if self.api_key_from_env:
173
+ return self.api_key
169
174
  tokens = self.usable_oauth()
170
175
  if tokens and tokens.access_valid():
171
176
  return tokens.access_token
@@ -214,16 +219,22 @@ class CLIConfig:
214
219
  # strings with a Pydantic ValidationError. Drop them so the
215
220
  # SDK falls back to kwargs / defaults.
216
221
  os.environ.pop(env_var, None)
222
+ config.api_key_from_env = bool(os.environ.get(ENV_MAP["api_key"]))
217
223
 
218
224
  return config
219
225
 
220
- def save(self) -> None:
226
+ def save(self, *, oauth_only: bool = False) -> None:
221
227
  """Write ``environmentUrl`` + credentials to config.json.
222
228
 
223
229
  Preserves unrelated top-level keys (``hosts``, ``sessions``,
224
230
  ``saveMessages``, ``sessionStrategy``, …) that other tools write.
225
231
  ``apiKey`` is written when set but never removed — sibling tools read
226
232
  it. The ``oauth`` block is CLI-owned and dropped when empty.
233
+
234
+ ``oauth_only`` writes just the ``oauth`` block. A config from
235
+ ``load()`` carries env overrides (``HONCHO_API_KEY``,
236
+ ``HONCHO_BASE_URL``), and a token refresh must not copy them into the
237
+ shared file, where sibling tools would pick them up.
227
238
  """
228
239
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
229
240
 
@@ -237,9 +248,10 @@ class CLIConfig:
237
248
  except (json.JSONDecodeError, OSError):
238
249
  data = {}
239
250
 
240
- data["environmentUrl"] = self.base_url
241
- if self.api_key:
242
- data["apiKey"] = self.api_key
251
+ if not oauth_only:
252
+ data["environmentUrl"] = self.base_url
253
+ if self.api_key:
254
+ data["apiKey"] = self.api_key
243
255
 
244
256
  if self.oauth and self.oauth.access_token:
245
257
  data["oauth"] = {
@@ -269,7 +281,7 @@ class CLIConfig:
269
281
  result: dict[str, str] = {}
270
282
  for fld in fields(self):
271
283
  val = getattr(self, fld.name)
272
- if not val:
284
+ if not val or fld.name == "api_key_from_env":
273
285
  continue
274
286
  if fld.name == "api_key":
275
287
  # Show ``***<last4>`` only — enough to compare keys without
@@ -87,6 +87,7 @@ def help_cmd(ctx: typer.Context) -> None:
87
87
  # Register command groups
88
88
  from honcho_cli.commands.config_cmd import app as config_app
89
89
  from honcho_cli.commands.conclusion import app as conclusion_app
90
+ from honcho_cli.commands.dream import app as dream_app
90
91
  from honcho_cli.commands.message import app as message_app
91
92
  from honcho_cli.commands.peer import app as peer_app
92
93
  from honcho_cli.commands.scope import app as scope_app
@@ -98,6 +99,7 @@ app.add_typer(session_app, name="session")
98
99
  app.add_typer(scope_app, name="scope")
99
100
  app.add_typer(message_app, name="message")
100
101
  app.add_typer(conclusion_app, name="conclusion")
102
+ app.add_typer(dream_app, name="dream")
101
103
  app.add_typer(workspace_app, name="workspace")
102
104
  app.add_typer(config_app, name="config")
103
105
 
@@ -5,13 +5,15 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import time
8
- from unittest.mock import patch
8
+ from unittest.mock import MagicMock, patch
9
9
 
10
10
  import pytest
11
11
  import typer
12
12
  from honcho_cli import common
13
13
  from honcho_cli.config import CLIConfig, OAuthTokens
14
+ from honcho_cli.main import app
14
15
  from honcho_cli.oauth import OAuthFlowError, TokenResponse
16
+ from typer.testing import CliRunner
15
17
 
16
18
 
17
19
  @pytest.fixture
@@ -95,6 +97,84 @@ def test_expired_token_refreshes_and_persists(cfg_path):
95
97
  assert on_disk["refreshToken"] == "new-rt"
96
98
 
97
99
 
100
+ def test_refresh_does_not_persist_env_overrides(cfg_path, monkeypatch):
101
+ """A refresh writes the new grant only, never the HONCHO_BASE_URL overlay.
102
+
103
+ Sibling tools read ``environmentUrl`` and ``apiKey`` from the shared file,
104
+ so a leaked override silently moves them to another server or org. (With
105
+ HONCHO_API_KEY set the refresh is skipped altogether.) A legacy grant with
106
+ no recorded host is trusted on any URL, so it refreshes under the override.
107
+ """
108
+ original = {
109
+ "apiKey": "file-key",
110
+ "environmentUrl": "http://localhost:8000",
111
+ "oauth": {
112
+ "accessToken": "old-at",
113
+ "refreshToken": "old-rt",
114
+ "accessExpiresAt": time.time() - 100,
115
+ "clientId": "honcho-cli",
116
+ "scope": "write",
117
+ },
118
+ "hosts": {"harness": {"workspace": "harness-honcho"}},
119
+ }
120
+ cfg_path.write_text(json.dumps(original))
121
+ monkeypatch.setenv("HONCHO_BASE_URL", "http://127.0.0.1:9000")
122
+ rotated = TokenResponse(
123
+ access_token="new-at", refresh_token="new-rt", expires_in=3600, scope="write"
124
+ )
125
+ client = MagicMock()
126
+ client.workspaces.return_value = ["ws-a"]
127
+ with (
128
+ patch("honcho_cli.oauth.refresh_access_token", return_value=rotated),
129
+ patch("honcho_cli.common.Honcho", return_value=client),
130
+ ):
131
+ result = CliRunner().invoke(app, ["workspace", "list"])
132
+ assert result.exit_code == 0, result.output
133
+
134
+ on_disk = json.loads(cfg_path.read_text())
135
+ assert on_disk["apiKey"] == "file-key"
136
+ assert on_disk["environmentUrl"] == "http://localhost:8000"
137
+ assert on_disk["hosts"] == original["hosts"]
138
+ assert on_disk["oauth"]["accessToken"] == "new-at"
139
+ assert on_disk["oauth"]["refreshToken"] == "new-rt"
140
+ # bound to the override it was minted against, not left hostless for the saved URL
141
+ assert on_disk["oauth"]["host"] == "http://127.0.0.1:9000"
142
+
143
+
144
+ def test_env_api_key_beats_live_oauth_grant(cfg_path, monkeypatch):
145
+ """HONCHO_API_KEY is an explicit per-run choice, so it outranks the saved grant."""
146
+ cfg_path.write_text(json.dumps({
147
+ "environmentUrl": "http://localhost:8000",
148
+ "oauth": {
149
+ "accessToken": "oauth-at",
150
+ "refreshToken": "oauth-rt",
151
+ "accessExpiresAt": time.time() + 3600,
152
+ "host": "http://localhost:8000",
153
+ },
154
+ }))
155
+ monkeypatch.setenv("HONCHO_API_KEY", "other-org-key")
156
+ client = MagicMock()
157
+ client.workspaces.return_value = ["ws-a"]
158
+ with patch("honcho_cli.common.Honcho", return_value=client) as honcho:
159
+ result = CliRunner().invoke(app, ["workspace", "list"])
160
+ assert result.exit_code == 0, result.output
161
+ assert honcho.call_args.kwargs["api_key"] == "other-org-key"
162
+
163
+
164
+ def test_env_api_key_skips_refresh(cfg_path, monkeypatch):
165
+ """The env key wins, so an expired grant it won't use is neither refreshed nor saved."""
166
+ cfg_path.write_text(json.dumps(
167
+ {"oauth": {"accessToken": "old-at", "refreshToken": "old-rt", "accessExpiresAt": 0}}
168
+ ))
169
+ before = cfg_path.read_text()
170
+ monkeypatch.setenv("HONCHO_API_KEY", "other-org-key")
171
+ config = CLIConfig.load()
172
+ with patch("honcho_cli.oauth.refresh_access_token") as refresh:
173
+ common.maybe_refresh_token(config)
174
+ refresh.assert_not_called()
175
+ assert cfg_path.read_text() == before
176
+
177
+
98
178
  def test_refresh_failure_exits(cfg_path):
99
179
  config = _cfg(time.time() - 100)
100
180
  with patch("honcho_cli.oauth.refresh_access_token", side_effect=OAuthFlowError("invalid_grant")):
@@ -32,12 +32,18 @@ def runner():
32
32
  return CliRunner()
33
33
 
34
34
 
35
- def _conclusion(cid: str, level: str = "inductive", source_ids: list[str] | None = None) -> MagicMock:
35
+ def _conclusion(
36
+ cid: str,
37
+ level: str = "inductive",
38
+ source_ids: list[str] | None = None,
39
+ source_message_ids: list[str] | None = None,
40
+ ) -> MagicMock:
36
41
  return MagicMock(
37
42
  id=cid,
38
43
  content=f"content for {cid}",
39
44
  level=level,
40
45
  source_ids=source_ids if source_ids is not None else ["p1", "p2"],
46
+ source_message_ids=source_message_ids,
41
47
  times_derived=3,
42
48
  observer_id="alice",
43
49
  observed_id="alice",
@@ -59,10 +65,42 @@ class TestConclusionAttribution:
59
65
  row = json.loads(result.stdout)[0]
60
66
  assert row["level"] == "inductive"
61
67
  assert row["source_ids"] == ["p1", "p2"]
68
+ assert row["source_message_ids"] is None
62
69
  assert row["times_derived"] == 3
63
70
  # --level reaches the server as a filter rather than being applied locally
64
71
  assert client.peer.return_value.conclusions.list.call_args.kwargs["filters"] == {"level": "inductive"}
65
72
 
73
+ def test_list_reports_cited_messages_and_filters_by_them(self, cfg, runner):
74
+ client = MagicMock()
75
+ config = MagicMock(workspace_id="ws1", peer_id="alice")
76
+ client.peer.return_value.conclusions.list.return_value = MagicMock(
77
+ items=[_conclusion("c1", level="explicit", source_ids=[], source_message_ids=["m1", "m2"])]
78
+ )
79
+
80
+ with patch("honcho_cli.commands.conclusion.get_client", return_value=(client, config)):
81
+ result = runner.invoke(app, ["conclusion", "list", "--cites", "m1", "-p", "alice"])
82
+
83
+ assert result.exit_code == 0
84
+ row = json.loads(result.stdout)[0]
85
+ assert row["source_message_ids"] == ["m1", "m2"]
86
+ assert client.peer.return_value.conclusions.list.call_args.kwargs["filters"] == {
87
+ "source_message_ids": {"contains": "m1"}
88
+ }
89
+
90
+ def test_list_tolerates_sdks_without_source_message_ids(self, cfg, runner):
91
+ """An SDK predating the field must not break the listing."""
92
+ client = MagicMock()
93
+ config = MagicMock(workspace_id="ws1", peer_id="alice")
94
+ legacy = _conclusion("c1", level="explicit", source_ids=[])
95
+ del legacy.source_message_ids
96
+ client.peer.return_value.conclusions.list.return_value = MagicMock(items=[legacy])
97
+
98
+ with patch("honcho_cli.commands.conclusion.get_client", return_value=(client, config)):
99
+ result = runner.invoke(app, ["conclusion", "list", "-p", "alice"])
100
+
101
+ assert result.exit_code == 0
102
+ assert json.loads(result.stdout)[0]["source_message_ids"] is None
103
+
66
104
  def test_list_rejects_an_unknown_level(self, cfg, runner):
67
105
  result = runner.invoke(app, ["conclusion", "list", "--level", "nonsense", "-p", "alice"])
68
106
  assert result.exit_code == 1
@@ -0,0 +1,234 @@
1
+ """`honcho dream list` / `honcho dream inspect` (Representation History)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import pytest
11
+ from honcho import NotFoundError
12
+ from honcho.api_types import DreamRunDetailResponse, DreamRunResponse
13
+ from typer.testing import CliRunner
14
+
15
+ from honcho_cli.main import app
16
+
17
+ STARTED = datetime(2026, 11, 3, 12, 0, tzinfo=timezone.utc)
18
+ FINISHED = datetime(2026, 11, 3, 12, 1, 30, tzinfo=timezone.utc)
19
+
20
+
21
+ @pytest.fixture
22
+ def cfg(tmp_path, monkeypatch):
23
+ f = tmp_path / "config.json"
24
+ monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
25
+ monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
26
+ for k in [k for k in os.environ if k.startswith("HONCHO_")]:
27
+ monkeypatch.delenv(k)
28
+ f.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
29
+ return f
30
+
31
+
32
+ @pytest.fixture
33
+ def runner():
34
+ return CliRunner()
35
+
36
+
37
+ def _run(**overrides) -> DreamRunResponse:
38
+ fields = {
39
+ "id": "run1",
40
+ "observer_id": "maya",
41
+ "observed_id": "maya",
42
+ "dream_type": "omni",
43
+ "trigger_reason": "threshold",
44
+ "status": "completed",
45
+ "created_count": 1,
46
+ "removed_count": 1,
47
+ "peer_card_updated": True,
48
+ "started_at": STARTED,
49
+ "finished_at": FINISHED,
50
+ }
51
+ fields.update(overrides)
52
+ return DreamRunResponse(**fields)
53
+
54
+
55
+ def _detail() -> DreamRunDetailResponse:
56
+ return DreamRunDetailResponse(
57
+ **_run().model_dump(),
58
+ created=[
59
+ {
60
+ "conclusion": {
61
+ "id": "c3",
62
+ "content": "Maya relies on caffeine to get through night shifts",
63
+ "level": "deductive",
64
+ "status": "current",
65
+ },
66
+ "sources": [
67
+ {
68
+ "id": "c1",
69
+ "content": "Maya drinks coffee daily",
70
+ "level": "explicit",
71
+ "status": "removed",
72
+ "removed_in_run": "run1",
73
+ },
74
+ {
75
+ "id": "c2",
76
+ "content": "Maya works night shifts",
77
+ "level": "explicit",
78
+ "status": "current",
79
+ },
80
+ {"id": "c0", "status": "unavailable"},
81
+ ],
82
+ "specialist": "deduction",
83
+ }
84
+ ],
85
+ removed=[
86
+ {
87
+ "id": "c1",
88
+ "content": "Maya drinks coffee daily",
89
+ "level": "explicit",
90
+ "replaced_by": "c3",
91
+ "specialist": "deduction",
92
+ }
93
+ ],
94
+ peer_card={
95
+ "before": ["IDENTITY: Name: Maya", "ATTRIBUTE: Drinks coffee"],
96
+ "after": ["IDENTITY: Name: Maya", "ATTRIBUTE: Works night shifts"],
97
+ },
98
+ )
99
+
100
+
101
+ def _client_with(**methods) -> MagicMock:
102
+ client = MagicMock()
103
+ for name, value in methods.items():
104
+ setattr(client, name, value)
105
+ return client
106
+
107
+
108
+ def test_list_filters_by_peer_and_observer(cfg, runner):
109
+ page = MagicMock(items=[_run()], page=1, pages=1)
110
+ dreams = MagicMock(return_value=page)
111
+ config = MagicMock(peer_id="maya", workspace_id="ws1")
112
+ with patch(
113
+ "honcho_cli.commands.dream.get_client",
114
+ return_value=(_client_with(dreams=dreams), config),
115
+ ):
116
+ result = runner.invoke(
117
+ app,
118
+ ["dream", "list", "-w", "ws1", "-p", "maya", "--observer", "assistant", "--limit", "5"],
119
+ )
120
+
121
+ assert result.exit_code == 0, result.stderr
122
+ dreams.assert_called_once_with(observed="maya", observer="assistant", page=1, size=5)
123
+ [row] = json.loads(result.stdout)
124
+ assert (row["id"], row["status"], row["created_count"]) == ("run1", "completed", 1)
125
+
126
+
127
+ def test_list_without_a_peer_sends_no_peer_filter(cfg, runner):
128
+ dreams = MagicMock(return_value=MagicMock(items=[], page=1, pages=0))
129
+ config = MagicMock(peer_id="", workspace_id="ws1") # the config default
130
+ with patch(
131
+ "honcho_cli.commands.dream.get_client",
132
+ return_value=(_client_with(dreams=dreams), config),
133
+ ):
134
+ result = runner.invoke(app, ["dream", "list", "-w", "ws1"])
135
+
136
+ assert result.exit_code == 0, result.stderr
137
+ dreams.assert_called_once_with(observed=None, observer=None, page=1, size=20)
138
+ assert json.loads(result.stdout) == []
139
+
140
+
141
+ def test_list_rejects_non_positive_paging(cfg, runner):
142
+ result = runner.invoke(app, ["dream", "list", "-w", "ws1", "--limit", "0"])
143
+ assert result.exit_code == 1
144
+ assert json.loads(result.stderr)["error"]["code"] == "INVALID_ARGUMENT"
145
+
146
+
147
+ def test_list_renders_full_run_ids_for_a_terminal(cfg, runner):
148
+ run_id = "kRkwKbgz4bqSRJjRXmp2_"
149
+ page = MagicMock(items=[_run(id=run_id, removed_count=0)], page=1, pages=1)
150
+ config = MagicMock(peer_id="maya", workspace_id="ws1")
151
+ with (
152
+ patch(
153
+ "honcho_cli.commands.dream.get_client",
154
+ return_value=(_client_with(dreams=MagicMock(return_value=page)), config),
155
+ ),
156
+ patch("honcho_cli.commands.dream.use_json", return_value=False),
157
+ ):
158
+ narrow = runner.invoke(app, ["dream", "list", "-w", "ws1", "-p", "maya"], env={"COLUMNS": "60"})
159
+ wide = runner.invoke(app, ["dream", "list", "-w", "ws1", "-p", "maya"], env={"COLUMNS": "160"})
160
+
161
+ assert narrow.exit_code == 0, narrow.stderr
162
+ assert run_id in narrow.stdout # the other columns wrap; the id never does
163
+ assert "maya → maya" in wide.stdout
164
+ assert "+1 -0 card" in wide.stdout
165
+
166
+
167
+ def test_inspect_json_is_the_full_detail(cfg, runner):
168
+ get_dream = MagicMock(return_value=_detail())
169
+ with patch(
170
+ "honcho_cli.commands.dream.get_client",
171
+ return_value=(_client_with(get_dream=get_dream), MagicMock()),
172
+ ):
173
+ result = runner.invoke(app, ["dream", "inspect", "run1", "-w", "ws1"])
174
+
175
+ assert result.exit_code == 0, result.stderr
176
+ get_dream.assert_called_once_with("run1")
177
+ body = json.loads(result.stdout)
178
+ assert body["created"][0]["sources"][0]["status"] == "removed"
179
+ assert body["removed"][0]["replaced_by"] == "c3"
180
+ assert body["peer_card"]["after"][1] == "ATTRIBUTE: Works night shifts"
181
+
182
+
183
+ def test_inspect_renders_changes_for_a_terminal(cfg, runner):
184
+ get_dream = MagicMock(return_value=_detail())
185
+ with (
186
+ patch(
187
+ "honcho_cli.commands.dream.get_client",
188
+ return_value=(_client_with(get_dream=get_dream), MagicMock()),
189
+ ),
190
+ patch("honcho_cli.commands.dream.use_json", return_value=False),
191
+ ):
192
+ result = runner.invoke(app, ["dream", "inspect", "run1", "-w", "ws1"])
193
+
194
+ assert result.exit_code == 0, result.stderr
195
+ out = result.stdout
196
+ assert "maya → maya" in out
197
+ assert "1m 30s" in out
198
+ assert "Maya relies on caffeine" in out
199
+ assert "removed in this dream" in out
200
+ assert "unavailable" in out
201
+ assert "replaced by: Maya relies on caffeine" in out
202
+ assert "- ATTRIBUTE: Drinks coffee" in out
203
+ assert "+ ATTRIBUTE: Works night shifts" in out
204
+
205
+
206
+ def test_inspect_says_when_retention_pruned_the_run(cfg, runner):
207
+ detail = _detail().model_copy(
208
+ update={"pruned_at": FINISHED, "removed": [], "peer_card": None}
209
+ )
210
+ get_dream = MagicMock(return_value=detail)
211
+ with (
212
+ patch(
213
+ "honcho_cli.commands.dream.get_client",
214
+ return_value=(_client_with(get_dream=get_dream), MagicMock()),
215
+ ),
216
+ patch("honcho_cli.commands.dream.use_json", return_value=False),
217
+ ):
218
+ result = runner.invoke(app, ["dream", "inspect", "run1", "-w", "ws1"])
219
+
220
+ assert result.exit_code == 0, result.stderr
221
+ assert "past retention" in result.stdout
222
+ assert "Maya relies on caffeine" in result.stdout
223
+
224
+
225
+ def test_inspect_unknown_dream_exits_with_not_found(cfg, runner):
226
+ get_dream = MagicMock(side_effect=NotFoundError("not found"))
227
+ with patch(
228
+ "honcho_cli.commands.dream.get_client",
229
+ return_value=(_client_with(get_dream=get_dream), MagicMock()),
230
+ ):
231
+ result = runner.invoke(app, ["dream", "inspect", "nope", "-w", "ws1"])
232
+
233
+ assert result.exit_code == 1
234
+ assert json.loads(result.stderr)["error"]["code"] == "DREAM_NOT_FOUND"
@@ -0,0 +1,13 @@
1
+ """The CLI's reported version must match the version it is published under."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from honcho_cli import __version__
7
+
8
+
9
+ def test_version_matches_pyproject():
10
+ pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
11
+ expected = tomllib.loads(pyproject.read_text())["project"]["version"]
12
+
13
+ assert __version__ == expected
@@ -74,21 +74,21 @@ wheels = [
74
74
 
75
75
  [[package]]
76
76
  name = "honcho-ai"
77
- version = "2.0.1"
77
+ version = "2.6.0"
78
78
  source = { registry = "https://pypi.org/simple" }
79
79
  dependencies = [
80
80
  { name = "httpx" },
81
81
  { name = "pydantic" },
82
82
  { name = "typing-extensions", marker = "python_full_version < '3.12'" },
83
83
  ]
84
- sdist = { url = "https://files.pythonhosted.org/packages/93/30/d30ba159404050d53b4b1b1c4477f9591f43af18758be1fb7dab6afbfe7d/honcho_ai-2.0.1.tar.gz", hash = "sha256:6fdeebf9454e62bc523d57888e50359e67baafdb21f68621f9c14e08dc00623a", size = 46732, upload-time = "2026-02-09T21:03:26.99Z" }
84
+ sdist = { url = "https://files.pythonhosted.org/packages/26/a7/13ad4c1b66d1a96ac66ce8cbb6e7d4d4f91cd0c4380b00caca8d27e273eb/honcho_ai-2.6.0.tar.gz", hash = "sha256:a799eb9fd3f02e34fb2a4166d8262ed407e02d276ad446e68affb370be45e47c", size = 68183, upload-time = "2026-10-14T16:30:28.525Z" }
85
85
  wheels = [
86
- { url = "https://files.pythonhosted.org/packages/e2/de/83fda0c057cfa11d6b5ed532623184591aa7dcff4a067934ba6811026229/honcho_ai-2.0.1-py3-none-any.whl", hash = "sha256:94887e61d59f353e1e1e20b395858040780f5d67ca1e9d450538646544e4e42f", size = 56780, upload-time = "2026-02-09T21:03:25.992Z" },
86
+ { url = "https://files.pythonhosted.org/packages/16/a5/e95f89a29b519e9e0293281b7fd8b6333c72ce44be8707cca4e452ac224e/honcho_ai-2.6.0-py3-none-any.whl", hash = "sha256:819cdd5775ecae69f617b14d60c3ea3c6ac9404b3821fa50cbf0b681aa2283cf", size = 79216, upload-time = "2026-10-14T16:30:27.211Z" },
87
87
  ]
88
88
 
89
89
  [[package]]
90
90
  name = "honcho-cli"
91
- version = "0.1.1"
91
+ version = "0.2.0"
92
92
  source = { editable = "." }
93
93
  dependencies = [
94
94
  { name = "click" },
@@ -107,7 +107,7 @@ dev = [
107
107
  [package.metadata]
108
108
  requires-dist = [
109
109
  { name = "click", specifier = ">=8.0.0" },
110
- { name = "honcho-ai", specifier = ">=0.1.0" },
110
+ { name = "honcho-ai", specifier = ">=2.5.0" },
111
111
  { name = "httpx", specifier = ">=0.27.0" },
112
112
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
113
113
  { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" },
@@ -1,3 +0,0 @@
1
- """Honcho CLI — a terminal for Honcho."""
2
-
3
- __version__ = "0.3.0"
File without changes
File without changes
File without changes