honcho-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Honcho CLI — a terminal for Honcho."""
2
+
3
+ __version__ = "0.1.0"
honcho_cli/_help.py ADDED
@@ -0,0 +1,130 @@
1
+ """Themed help rendering for honcho CLI.
2
+
3
+ Single source of truth for:
4
+
5
+ - Rich-utils theme constants (dim borders, brand color)
6
+ - HonchoTyperGroup: subclass applied via ``cls=`` at every Typer app in
7
+ this package — replaces Click's terse ``Usage: …`` line with
8
+ pattern/example rows and prints a curated welcome at the top-level.
9
+
10
+ Lives in its own module so every ``commands/*.py`` can import it without
11
+ pulling in ``main.py`` (which would create an import cycle).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import click
17
+ import typer.rich_utils as ru
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.table import Table
22
+ from typer.core import TyperGroup
23
+
24
+ from honcho_cli import __version__
25
+ from honcho_cli.branding import BANNER, BRAND
26
+ from honcho_cli.output import use_json
27
+
28
+
29
+ # Theme Typer's rich help renderer. Module-level side effect limited to
30
+ # styling — no behavior changes that could surprise other Typer users.
31
+ ru.STYLE_COMMANDS_PANEL_BORDER = "dim"
32
+ ru.STYLE_OPTIONS_PANEL_BORDER = "dim"
33
+ ru.STYLE_ERRORS_PANEL_BORDER = "dim"
34
+ ru.STYLE_OPTION = f"bold {BRAND}"
35
+ ru.STYLE_SWITCH = f"bold {BRAND}"
36
+ ru.STYLE_USAGE = "dim"
37
+ ru.STYLE_USAGE_COMMAND = f"bold {BRAND}"
38
+
39
+
40
+ def _cmd_table(rows: list[tuple[str, str]]) -> Table:
41
+ t = Table(show_header=False, box=None, padding=(0, 2, 0, 0), expand=False)
42
+ t.add_column("cmd", style=f"bold {BRAND}", no_wrap=True)
43
+ t.add_column("desc", style="default")
44
+ for cmd, desc in rows:
45
+ t.add_row(cmd, desc)
46
+ return t
47
+
48
+
49
+ def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel:
50
+ return Panel(
51
+ _cmd_table(rows),
52
+ title=f"[dim]{title}[/dim]",
53
+ title_align="left",
54
+ border_style="dim",
55
+ box=box.ROUNDED,
56
+ padding=(0, 1),
57
+ expand=False,
58
+ )
59
+
60
+
61
+ def print_welcome(console: Console) -> None:
62
+ """Render the curated 3-panel welcome (banner + getting started / memory / commands)."""
63
+ if use_json():
64
+ return
65
+ console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]")
66
+ console.print(f" [dim]v{__version__}[/dim]\n", highlight=False)
67
+
68
+ start_rows = [
69
+ ("honcho init", "configure API key and server URL"),
70
+ ("honcho doctor", "verify connection and workspace health"),
71
+ ]
72
+ cmd_rows = [
73
+ ("[dim]pattern[/dim]", r"[dim]honcho <command> \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"),
74
+ ("[dim]example[/dim]", "[dim]honcho peer chat \"what does alice prefer?\" -p alice -w agents[/dim]"),
75
+ ("", ""),
76
+ ("workspace", "list · create · search · delete · inspect · queue-status"),
77
+ ("peer", "list · create · search · inspect · card · chat"),
78
+ ("", "get-metadata · set-metadata · representation"),
79
+ ("session", "list · create · search · delete · inspect · add-peers"),
80
+ ("", "context · get-metadata · set-metadata · peers"),
81
+ ("", "remove-peers · representation · summaries"),
82
+ ("message", "list · create · get"),
83
+ ("conclusion", "list · create · search · delete"),
84
+ ("config", "inspect current configuration"),
85
+ ]
86
+ memory_rows = [
87
+ ("honcho peer chat \"...\" -p <peer> -w <workspace>","query the Dialectic about a peer"),
88
+ ("honcho peer inspect -p <peer> -w <workspace>","dashboard: peer card + recent conclusions + configuration"),
89
+ ("honcho peer representation -p <peer> -w <workspace>", "global peer representation"),
90
+ ("honcho peer representation -p <peer> -w <workspace> -s <session>", "session-scoped peer representation"),
91
+ ("honcho peer card -p <peer> -w <workspace>", "synthesized identity: traits, preferences, instructions"),
92
+ ("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
93
+ ]
94
+
95
+ option_rows = [
96
+ ("-w / --workspace", "scope to a workspace"),
97
+ ("-p / --peer", "scope to a peer"),
98
+ ("-s / --session", "scope to a session"),
99
+ ("--json", "force JSON output for scripts and agents"),
100
+ ("--help", "show help for any command (e.g. honcho peer --help)"),
101
+ ]
102
+
103
+ console.print(_welcome_panel("getting started", start_rows))
104
+ console.print(_welcome_panel("commands", cmd_rows))
105
+ console.print(_welcome_panel("memory", memory_rows))
106
+ console.print(_welcome_panel("options", option_rows))
107
+ console.print()
108
+
109
+
110
+ class HonchoTyperGroup(TyperGroup):
111
+ """Typer group with pattern/example usage and top-level welcome.
112
+
113
+ Applied via ``cls=`` on every ``typer.Typer(...)`` in this package,
114
+ so no class-level monkey-patching is needed.
115
+ """
116
+
117
+ def get_usage(self, ctx):
118
+ """Replace Click's 'Usage: …' with pattern/example rows."""
119
+ original = click.Command.get_usage(self, ctx)
120
+ pattern = original.replace("Usage: ", "", 1) if original.startswith("Usage: ") else original
121
+ subs = self.list_commands(ctx)
122
+ example = f"{ctx.command_path} {subs[0]}" if subs else f"{ctx.command_path} --help"
123
+ return f"pattern: {pattern}\nexample: {example}"
124
+
125
+ def format_help(self, ctx, formatter):
126
+ """Top-level --help renders the welcome; sub-groups fall through to Typer."""
127
+ if ctx.parent is None:
128
+ print_welcome(Console())
129
+ return
130
+ super().format_help(ctx, formatter)
honcho_cli/branding.py ADDED
@@ -0,0 +1,17 @@
1
+ """Honcho CLI brand constants — colours, icons, and the ASCII banner.
2
+ """
3
+
4
+ BRAND = "#B6DAFD"
5
+
6
+ BANNER = """
7
+ ██╗ ██╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗ ██████╗
8
+ ██║ ██║██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔═══██╗
9
+ ███████║██║ ██║██╔██╗ ██║██║ ███████║██║ ██║
10
+ ██╔══██║██║ ██║██║╚██╗██║██║ ██╔══██║██║ ██║
11
+ ██║ ██║╚██████╔╝██║ ╚████║╚██████╗██║ ██║╚██████╔╝
12
+ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝
13
+ """.strip("\n")
14
+
15
+ ICON_OK = "[green]✓[/green]"
16
+ ICON_FAIL = "[red]✗[/red]"
17
+ ICON_RUN = f"[{BRAND}]→[/{BRAND}]"
File without changes
@@ -0,0 +1,219 @@
1
+ """Conclusion commands: list, search, create, delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from honcho_cli.commands.workspace import _handle_error
11
+ from honcho_cli.output import print_error, print_result, status, use_json
12
+ from honcho_cli.validation import validate_resource_id
13
+
14
+ from honcho_cli._help import HonchoTyperGroup
15
+ from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
16
+
17
+ app = typer.Typer(cls=HonchoTyperGroup, help="List, search, create, and delete peer conclusions (Honcho's memory atoms).")
18
+ add_common_options(app)
19
+
20
+
21
+ def _require_observer(observer: str | None) -> str:
22
+ """Resolve observer peer ID; emit combined error if peer+workspace both missing."""
23
+ config = get_resolved_config()
24
+ obs = observer or config.peer_id
25
+ if not obs:
26
+ if not config.workspace_id:
27
+ print_error(
28
+ "NO_SCOPE",
29
+ "No peer or workspace scoped. Pass --peer/-p and --workspace/-w, or set HONCHO_PEER_ID and HONCHO_WORKSPACE_ID.",
30
+ )
31
+ else:
32
+ print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion <cmd> -p <peer>")
33
+ raise typer.Exit(1)
34
+ return obs
35
+
36
+
37
+ @app.command("list")
38
+ def list_conclusions(
39
+ observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
40
+ observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
41
+ limit: int = typer.Option(10, "--limit", help="Max results"),
42
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
43
+ peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
44
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
45
+ ) -> None:
46
+ """List conclusions."""
47
+
48
+ handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
49
+ observer = _require_observer(observer)
50
+ client, config = get_client()
51
+
52
+ p = client.peer(observer)
53
+
54
+ try:
55
+ if observed:
56
+ scope = p.conclusions_of(observed)
57
+ else:
58
+ scope = p.conclusions
59
+
60
+ conclusions = scope.list(size=limit).items
61
+ items = [
62
+ {
63
+ "id": c.id,
64
+ "content": c.content if use_json() else c.content[:200],
65
+ "workspace_id": config.workspace_id,
66
+ "observer_id": c.observer_id,
67
+ "observed_id": c.observed_id,
68
+ "session_id": c.session_id,
69
+ "created_at": str(c.created_at),
70
+ }
71
+ for c in conclusions
72
+ ]
73
+ print_result(items, columns=["id", "content", "workspace_id", "observer_id", "observed_id", "session_id", "created_at"], title="Conclusions")
74
+ except Exception as e:
75
+ _handle_error(e, "conclusion", "list")
76
+
77
+
78
+ @app.command()
79
+ def search(
80
+ query: str = typer.Argument(help="Search query"),
81
+ observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
82
+ observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
83
+ top_k: int = typer.Option(10, help="Max results"),
84
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
85
+ peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
86
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
87
+ ) -> None:
88
+ """Semantic search over conclusions."""
89
+
90
+ handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
91
+ observer = _require_observer(observer)
92
+ client, config = get_client()
93
+
94
+ p = client.peer(observer)
95
+
96
+ try:
97
+ if observed:
98
+ scope = p.conclusions_of(observed)
99
+ else:
100
+ scope = p.conclusions
101
+
102
+ results = scope.query(query, top_k=top_k)
103
+ items = [
104
+ {
105
+ "id": c.id,
106
+ "content": c.content if use_json() else c.content[:200],
107
+ "workspace_id": config.workspace_id,
108
+ "observer_id": c.observer_id,
109
+ "observed_id": c.observed_id,
110
+ "session_id": c.session_id,
111
+ "created_at": str(c.created_at),
112
+ }
113
+ for c in results
114
+ ]
115
+ print_result(items, columns=["id", "content", "workspace_id", "session_id", "created_at"], title=f"Conclusion search: {query}")
116
+ except Exception as e:
117
+ _handle_error(e, "conclusion", "search")
118
+
119
+
120
+ @app.command()
121
+ def create(
122
+ content: str = typer.Argument(help="Conclusion content or JSON payload"),
123
+ observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
124
+ observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
125
+ session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session context"),
126
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
127
+ peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
128
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
129
+ ) -> None:
130
+ """Create a conclusion."""
131
+
132
+ handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session_id)
133
+ observer = _require_observer(observer)
134
+ client, config = get_client()
135
+
136
+ # If content looks like JSON, try to parse it
137
+ try:
138
+ payload = json.loads(content)
139
+ if isinstance(payload, dict):
140
+ content = payload.get("content", content)
141
+ except json.JSONDecodeError:
142
+ pass
143
+
144
+ p = client.peer(observer)
145
+
146
+ try:
147
+ if observed:
148
+ scope = p.conclusions_of(observed)
149
+ else:
150
+ scope = p.conclusions
151
+
152
+ params: dict[str, object] = {"content": content}
153
+ if config.session_id:
154
+ params["session_id"] = config.session_id
155
+ results = scope.create([params])
156
+ result = results[0] if results else None
157
+ if result is None:
158
+ print_error("CREATE_FAILED", "Conclusion create returned no results")
159
+ raise typer.Exit(1)
160
+ print_result({
161
+ "id": result.id,
162
+ "content": result.content,
163
+ "workspace_id": config.workspace_id,
164
+ "observer_id": result.observer_id,
165
+ "observed_id": result.observed_id,
166
+ "session_id": result.session_id,
167
+ "created_at": str(result.created_at),
168
+ })
169
+ except Exception as e:
170
+ _handle_error(e, "conclusion", "create")
171
+
172
+
173
+ @app.command()
174
+ def delete(
175
+ conclusion_id: str = typer.Argument(help="Conclusion ID to delete"),
176
+ observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
177
+ observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
178
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
179
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
180
+ peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
181
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
182
+ ) -> None:
183
+ """Delete a conclusion."""
184
+
185
+ handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
186
+ validate_resource_id(conclusion_id, "conclusion")
187
+ client, config = get_client()
188
+
189
+ if not observer:
190
+ observer = config.peer_id
191
+ if not observer:
192
+ print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion <cmd> -p <peer>")
193
+ raise typer.Exit(1)
194
+
195
+ p = client.peer(observer)
196
+
197
+ if not yes:
198
+ # SDK doesn't expose a get-by-id on ConclusionScope, so we can't
199
+ # preview content cheaply — don't paginate the list just to
200
+ # decorate the prompt. Show identifying fields only.
201
+ if not use_json():
202
+ typer.echo(
203
+ f" id: {conclusion_id}\n"
204
+ f" observer: {observer}\n"
205
+ f" observed: {observed or '(self)'}"
206
+ )
207
+ typer.confirm(f"Delete conclusion '{conclusion_id}'?", abort=True)
208
+
209
+ try:
210
+ if observed:
211
+ scope = p.conclusions_of(observed)
212
+ else:
213
+ scope = p.conclusions
214
+
215
+ scope.delete(conclusion_id)
216
+ status(f"Conclusion '{conclusion_id}' deleted")
217
+ print_result({"deleted": conclusion_id})
218
+ except Exception as e:
219
+ _handle_error(e, "conclusion", conclusion_id)
@@ -0,0 +1,31 @@
1
+ """Config inspection command: ``honcho config``.
2
+
3
+ Writing to ``~/.honcho/config.json`` is done only via ``honcho init``, which
4
+ manages the two CLI-owned keys (``apiKey`` + ``environmentUrl``).
5
+ Workspace / peer / session scoping is per-command via flags / env vars, not
6
+ persisted defaults.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+
13
+ from honcho_cli._help import HonchoTyperGroup
14
+ from honcho_cli.common import handle_cmd_flags
15
+ from honcho_cli.config import CLIConfig
16
+ from honcho_cli.output import print_result
17
+
18
+ app = typer.Typer(cls=HonchoTyperGroup, help="Inspect CLI configuration.", invoke_without_command=True)
19
+
20
+
21
+ @app.callback(invoke_without_command=True)
22
+ def config(
23
+ ctx: typer.Context,
24
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
25
+ ) -> None:
26
+ """Show current config (api key redacted)."""
27
+ if ctx.invoked_subcommand is not None:
28
+ return
29
+ handle_cmd_flags(json_output=json_output)
30
+ cfg = CLIConfig.load()
31
+ print_result(cfg.redacted())
@@ -0,0 +1,161 @@
1
+ """Message commands: list, get, create."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from honcho.api_types import MessageCreateParams
12
+
13
+ from honcho_cli.commands.session import _get_session_id
14
+ from honcho_cli.commands.workspace import _handle_error
15
+ from honcho_cli.output import print_error, print_result, status
16
+ from honcho_cli.validation import validate_resource_id
17
+
18
+ from honcho_cli._help import HonchoTyperGroup
19
+ from honcho_cli.common import add_common_options, get_client, handle_cmd_flags
20
+
21
+ app = typer.Typer(cls=HonchoTyperGroup, help="List, create, and get messages within a session.")
22
+ add_common_options(app)
23
+
24
+
25
+ @app.command("list")
26
+ def list_messages(
27
+ session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
28
+ last: int = typer.Option(20, "--last", help="Number of recent messages"),
29
+ reverse: bool = typer.Option(False, "--reverse", help="Show oldest first (default is newest first)"),
30
+ brief: bool = typer.Option(False, "--brief", help="Show only IDs, peer, token count, and created_at (no content)"),
31
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
32
+ peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer ID"),
33
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
34
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
35
+ ) -> None:
36
+ """List messages in a session. Scoped to a peer with -p."""
37
+
38
+ handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
39
+ sid = _get_session_id(session_id)
40
+ client, config = get_client()
41
+ sess = client.session(sid)
42
+
43
+ try:
44
+ filters = {"peer_id": config.peer_id} if config.peer_id else None
45
+ # Fetch newest-first so [:last] always gives the most recent N messages,
46
+ # then flip to oldest-at-top / newest-at-bottom for readable display.
47
+ # --reverse keeps the raw server order (oldest first, descending in table).
48
+ msgs = sess.messages(filters=filters, reverse=True).items[:last]
49
+ if not reverse:
50
+ msgs = list(reversed(msgs))
51
+
52
+ # Detect duplicate content
53
+ content_hashes: dict[str, list[str]] = {}
54
+ for m in msgs:
55
+ h = hashlib.md5(m.content.encode()).hexdigest()
56
+ content_hashes.setdefault(h, []).append(m.id)
57
+ dupes = {h: ids for h, ids in content_hashes.items() if len(ids) > 1}
58
+ if dupes:
59
+ dupe_count = sum(len(ids) - 1 for ids in dupes.values())
60
+ status(f"Warning: {dupe_count} duplicate message(s) detected (identical content, different IDs)")
61
+
62
+ if brief:
63
+ items = [
64
+ {
65
+ "id": m.id,
66
+ "peer_id": m.peer_id,
67
+ "token_count": m.token_count,
68
+ "created_at": str(m.created_at),
69
+ }
70
+ for m in msgs
71
+ ]
72
+ print_result(items, columns=["id", "peer_id", "token_count", "created_at"], title="Messages")
73
+ else:
74
+ items = [
75
+ {
76
+ "id": m.id,
77
+ "peer_id": m.peer_id,
78
+ "content": m.content,
79
+ "token_count": m.token_count,
80
+ "metadata": m.metadata,
81
+ "created_at": str(m.created_at),
82
+ }
83
+ for m in msgs
84
+ ]
85
+ print_result(items, columns=["id", "peer_id", "content", "created_at"], title="Messages")
86
+ except Exception as e:
87
+ _handle_error(e, "message", "list")
88
+
89
+
90
+ @app.command("create")
91
+ def create_message(
92
+ content: str = typer.Argument(help="Message content"),
93
+ peer_id: str = typer.Option(..., "--peer", "-p", help="Peer ID of the message sender"),
94
+ metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the message"),
95
+ session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"),
96
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
97
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
98
+ ) -> None:
99
+ """Create a message in a session."""
100
+ handle_cmd_flags(json_output=json_output, workspace=workspace, session=session_id)
101
+ sid = _get_session_id(None)
102
+ validate_resource_id(peer_id, "peer")
103
+ client, config = get_client()
104
+ sess = client.session(sid)
105
+
106
+ parsed_metadata = None
107
+ if metadata:
108
+ try:
109
+ parsed_metadata = json.loads(metadata)
110
+ except json.JSONDecodeError as e:
111
+ print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
112
+ raise typer.Exit(1)
113
+
114
+ try:
115
+ msgs = sess.add_messages(MessageCreateParams(
116
+ peer_id=peer_id,
117
+ content=content,
118
+ metadata=parsed_metadata,
119
+ ))
120
+ msg = msgs[0]
121
+ print_result({
122
+ "id": msg.id,
123
+ "peer_id": msg.peer_id,
124
+ "content": msg.content,
125
+ "token_count": msg.token_count,
126
+ "created_at": str(msg.created_at),
127
+ })
128
+ except Exception as e:
129
+ _handle_error(e, "message", "create")
130
+
131
+
132
+ @app.command("get")
133
+ def get_message(
134
+ message_id: str = typer.Argument(help="Message ID"),
135
+ session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"),
136
+ workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
137
+ json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
138
+ ) -> None:
139
+ """Get a single message by ID."""
140
+
141
+ handle_cmd_flags(json_output=json_output, workspace=workspace)
142
+ validate_resource_id(message_id, "message")
143
+ sid = _get_session_id(session_id)
144
+ client, config = get_client()
145
+
146
+ try:
147
+ sess = client.session(sid)
148
+ msg = sess.get_message(message_id)
149
+
150
+ print_result({
151
+ "id": msg.id,
152
+ "peer_id": msg.peer_id,
153
+ "content": msg.content,
154
+ "token_count": msg.token_count,
155
+ "metadata": msg.metadata,
156
+ "created_at": str(msg.created_at),
157
+ })
158
+ except SystemExit:
159
+ raise
160
+ except Exception as e:
161
+ _handle_error(e, "message", message_id)