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.
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/CHANGELOG.md +29 -1
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/PKG-INFO +9 -2
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/README.md +7 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/pyproject.toml +2 -2
- honcho_cli-0.5.0/src/honcho_cli/__init__.py +11 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/_help.py +2 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/conclusion.py +18 -5
- honcho_cli-0.5.0/src/honcho_cli/commands/dream.py +318 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/setup.py +2 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/common.py +13 -6
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/config.py +22 -10
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/main.py +2 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_common.py +81 -1
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_conclusion_attribution.py +39 -1
- honcho_cli-0.5.0/tests/test_dream.py +234 -0
- honcho_cli-0.5.0/tests/test_version.py +13 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/uv.lock +5 -5
- honcho_cli-0.3.0/src/honcho_cli/__init__.py +0 -3
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/.gitignore +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/scripts/generate_cli_docs.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/agents.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/branding.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/__init__.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/config_cmd.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/install.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/message.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/peer.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/scope.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/session.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/stack.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/commands/workspace.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/__init__.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/docker.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/env.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/health.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/profile.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/setup.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/templates/__init__.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/templates/docker-compose.yml +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/local/templates/init.sql +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/oauth.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/output.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/recall.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/update_check.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/src/honcho_cli/validation.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/__init__.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/conftest.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_commands.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_config.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_install.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_local.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_oauth.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_output.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_recall.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_setup.py +0 -0
- {honcho_cli-0.3.0 → honcho_cli-0.5.0}/tests/test_start.py +0 -0
- {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
|
-
## [
|
|
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
|
+
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.
|
|
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
|
+
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.
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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=
|
|
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
|
|
165
|
+
"""The key handed to the SDK: HONCHO_API_KEY, else a live OAuth token, else apiKey.
|
|
164
166
|
|
|
165
|
-
An
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
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
|
|
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/
|
|
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/
|
|
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.
|
|
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 = ">=
|
|
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" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|