feat: adding honcho-cli package (#424)

* feat: adding honcho-cli package

* feat: adding more support for command-level flags, also including workarounds for getting raw SDK info

* feat: adding peer config

* feat: adding setup commands

* chore: setting up package dependencies for cli

* feat: promote init/doctor to top-level + polish wizard

* feat: make init --yes fall back to existing config

* chore: updating documentation

* chore: updating tagline

* feat: structurally updating recomended settings for CLI

* fix: style

* fix: removing redundant describe method

* fix: delete key generation commands and fixing session ID

* fix: removing defaults and changing config write path.

* chore: pagnating conclusions

* chore: require workspace

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

* chore: removing session message

* fix: CLI output shape, destructive-confirm previews, skip needless round-trips

* chore: CLI polish — peer inspect config, drop dead helper, doc/help consistency

* chore: update readme

* chore: updating tests

* chore: doc updates

* fix: config command

* chore: unused code

* fix: doctor command

* fix: removing quiet tag and fixing session key ordering

* fix: config commands and session id command

* fix: removing message_count

* fix: branding circular dependency

* fix: refactor lazy imports to use common.py correctly.

* fix: removing all lazy imports

* chore: cr fixes

* fix: config, env, flag setup

* chore: updating skill

* feat: adding workspace, session, and message create

* fix: init now supports local honcho

* chore: cr

* feat(cli): CLI surface polish — reasoning flag, peer-scoped messages, help sync

Add --reasoning/-r to peer chat (minimal..max), -p peer filter to
message list with newest-first ordering, and a curated welcome panel
with getting-started/memory/commands sections.

Sync the welcome panel and group help strings with the actual
registered commands — drop phantom 'session clone', add the 4 missing
peer commands and 7 missing session commands, fix conclusion/message/
workspace group docstrings that claimed commands that don't exist.

* feat(cli): themed, unified help system with pattern/example

Replace the hand-rolled welcome with a layered system:

- Theme typer.rich_utils (dim borders, brand color) so every --help
  inherits the voice.
- HonchoTyperGroup subclass renders a curated 3-panel welcome
  (getting started / memory / commands) with recipes Typer can't
  auto-generate.
- Unify the front door: bare 'honcho', 'honcho --help', and
  'honcho help' all render the same welcome via one code path;
  sub-groups and leaf commands still get Typer's themed renderer.
- Replace Click's 'Usage: …' line with pattern/example rows at every
  sub-group and leaf command, so the help voice stays consistent from
  top to leaves.

* refactor(cli): address review — typed exceptions, chmod 600, tighter redaction, class-based help, tests

- Replace module-level monkey-patch of TyperGroup/TyperCommand.get_usage
  with HonchoTyperGroup applied via cls= on every sub-Typer. Lives in
  a new _help.py module to avoid circular imports. No longer leaks
  behavior changes into other Typer users in the same process.
- _test_connection dispatches on the SDK's typed exceptions
  (AuthenticationError, ConnectionError, TimeoutError, APIError)
  instead of substring-matching error messages.
- Config.save() now chmods ~/.honcho/config.json to 0o600 after write
  so the plaintext API key isn't world-readable on multi-user hosts.
- Tighten api_key redaction to '***<last4>' (was 'header...last4'),
  matching setup._redact for consistency. Short keys fully masked.
- Add test_validation.py covering safe IDs, unsafe chars, path
  traversal, and empty input. Update test_config.py redaction cases
  and add 0o600 permission assertion. Fix stale patch paths in
  test_commands.py that pointed at honcho_cli.main instead of the
  command modules where get_client is actually imported.

* feat(cli): add options panel to welcome menu

Append a fourth panel listing the global flags (-w/-p/-s, --json,
--version, --help) with their env-var counterparts. Discoverable
from bare 'honcho' without needing to hunt for --help.

* chore(cli): drop --version from welcome options panel

* feat(cli): add pixel-honcho icon to banner

Prepend a 13-char ASCII rendering of honcho-pixel.svg to the HONCHO
wordmark. Uses Unicode half-blocks to pack 12 pixel rows into 6 text
rows, faithfully preserving the SVG outline (two eye dots, mouth slit,
tapering foot). Appears in bare 'honcho', 'honcho --help', 'honcho
--version', and 'honcho init'.

* fix: polish Honcho CLI wolcome panel and error messages

* fix: honcho workspace inspect speed

* chore: minor fix to session pagination

* fix: removing NDJSON output

* chore: consolidating honcho CLI's dula argv grammar onto Pattern A (command-first)

* chore: clean up imports

* fix: four `-s` consistency fixes applied

* chore: minor changes to memory rows

* fix: changing package name to honcho-cli

* fix: removing pixel face

---------

Co-authored-by: Erosika <eri@plasticlabs.ai>
This commit is contained in:
ajspig 2026-04-20 13:27:35 -04:00 committed by GitHub
parent b65d03d297
commit 3dbf0e66fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 3660 additions and 629 deletions

View File

@ -91,6 +91,8 @@ Based on interview responses, implement the integration:
### Phase 4: Verification
- If the Honcho CLI is available, run `honcho doctor` to confirm connectivity before testing the integration code
- Use `honcho peer list` and `honcho peer chat` to verify peers exist and the dialectic endpoint works independently of the integration
- Ensure all message exchanges are stored to Honcho
- Verify AI peers have `observe_me=False` (unless user specifically wants AI observation)
- Check that the workspace ID is consistent across the codebase
@ -106,6 +108,16 @@ Based on interview responses, implement the integration:
2. **Get an API key** ask the user to get a Honcho API key from <https://app.honcho.dev> and add it to the environment.
3. **Verify with the CLI** (optional but recommended). If the user has the Honcho CLI installed (`pip install honcho-cli`), they can validate their setup before writing any integration code:
```bash
honcho init # persist API key + URL to ~/.honcho/config.json
honcho doctor # verify connectivity, config, workspace health
honcho peer chat # test the dialectic endpoint interactively
```
This is the fastest way to confirm the API key and URL are correct before debugging SDK code.
## Installation
### Python (use uv)
@ -524,6 +536,8 @@ When integrating Honcho into an existing codebase:
- [ ] Pre-fetch pattern for simpler integrations
- [ ] context() for conversation history
- [ ] Store messages after each exchange to build user models
- [ ] (Optional) Run `honcho doctor` to verify connectivity before testing integration code
- [ ] (Optional) Use `honcho peer chat` to test dialectic queries independently
## Common Mistakes to Avoid

1
.gitignore vendored
View File

@ -182,6 +182,7 @@ docs/node_modules
timing_logs.csv
config.json
config.toml
.aider*

211
honcho-cli/README.md Normal file
View File

@ -0,0 +1,211 @@
```
██╗ ██╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗ ██████╗
██║ ██║██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔═══██╗
███████║██║ ██║██╔██╗ ██║██║ ███████║██║ ██║
██╔══██║██║ ██║██║╚██╗██║██║ ██╔══██║██║ ██║
██║ ██║╚██████╔╝██║ ╚████║╚██████╗██║ ██║╚██████╔╝
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝
```
# honcho-cli
A terminal for [Honcho](https://honcho.dev) — memory that reasons.
## Install
As a standalone tool (recommended):
```bash
uv tool install honcho-cli
```
As an extra on the Honcho SDK (if you want both the SDK and the CLI in one project):
```bash
uv add honcho-ai[cli]
# or
pip install honcho-ai[cli]
```
Either way, you'll get the `honcho` command on your PATH.
## Quick Start
```bash
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho doctor # verify your config + connectivity
honcho # show banner + command list
```
`honcho init` reads `apiKey` and `environmentUrl` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched.
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults.
## Commands
### Onboarding
| Command | Description |
|---------|-------------|
| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` |
| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue |
### Workspaces
| Command | Description |
|---------|-------------|
| `honcho workspace list` | List accessible workspaces |
| `honcho workspace create <id>` | Create or get a workspace |
| `honcho workspace inspect` | Peers, sessions, config for a workspace |
| `honcho workspace search <query>` | Search messages across workspace |
| `honcho workspace queue-status` | Deriver queue status (filter with `--observer` / `--sender`) |
| `honcho workspace delete <id>` | Delete a workspace. Use `--dry-run` to preview, `--cascade` to also delete sessions, `--yes` to skip the confirm prompt |
### Peers
| Command | Description |
|---------|-------------|
| `honcho peer list` | List peers in the workspace |
| `honcho peer create <id>` | Create or get a peer |
| `honcho peer inspect <id>` | Card, session count, recent conclusions |
| `honcho peer card <id>` | Raw peer card content |
| `honcho peer chat <query>` | Query the dialectic about a peer (peer via `-p` / `HONCHO_PEER_ID`) |
| `honcho peer representation <id>` | Formatted representation |
| `honcho peer search <query>` | Search a peer's messages (peer via `-p` / `HONCHO_PEER_ID`) |
| `honcho peer get-metadata <id>` / `set-metadata` | Metadata operations |
### Sessions
| Command | Description |
|---------|-------------|
| `honcho session list` | List sessions in the workspace (filter with `--peer/-p`) |
| `honcho session create <id>` | Create or get a session (optionally `--peers` to add peers, `--metadata`) |
| `honcho session inspect <id>` | Peers, message count, summaries, config |
| `honcho session context <id>` | What an agent would see |
| `honcho session summaries <id>` | Short + long summaries |
| `honcho session peers <id>` / `add-peers` / `remove-peers` | Peer management |
| `honcho session search <id> <query>` | Search messages in a session |
| `honcho session representation <id>` | Peer representation in a session |
| `honcho session get-metadata <id>` / `set-metadata` | Metadata operations |
| `honcho session delete <id>` | Destructive; requires `--yes` |
### Messages
| Command | Description |
|---------|-------------|
| `honcho message list` | List messages in a session (session via `-s` / `HONCHO_SESSION_ID`) |
| `honcho message create <content>` | Create a message (requires `--peer/-p`, session via `-s`) |
| `honcho message get <id>` | Get a single message (session via `-s` / `HONCHO_SESSION_ID`) |
### Conclusions (observations)
| Command | Description |
|---------|-------------|
| `honcho conclusion list` | List conclusions (filter with `--observer` / `--observed`) |
| `honcho conclusion search <query>` | Semantic search (filter with `--observer` / `--observed`) |
| `honcho conclusion create` | Create a conclusion |
| `honcho conclusion delete <id>` | Delete a conclusion |
### Config
| Command | Description |
|---------|-------------|
| `honcho config` | Show current config (API key redacted) |
## Agent Usage
All commands output JSON when stdout isn't a TTY, or when `--json` is forced.
Collection commands emit JSON arrays, and single-resource commands emit JSON objects:
```bash
honcho peer list --json
honcho workspace inspect --json | jq '.peers'
honcho doctor --json # machine-parseable health checklist
```
Errors are structured:
```json
{
"error": {
"code": "PEER_NOT_FOUND",
"message": "Peer 'abc' not found in workspace 'my-ws'",
"details": {"workspace_id": "my-ws", "peer_id": "abc"}
}
}
```
Non-interactive onboarding:
```bash
# Pre-seed via flags / env vars; init still prompts for anything missing
HONCHO_API_KEY=hch-v3-xxx honcho init --base-url https://api.honcho.dev
```
## Environment Variables
All `HONCHO_*` env vars work at runtime — no config file required.
Precedence (highest first): **flag → env var → config file → default**.
| Variable | Flag | Description |
|----------|------|-------------|
| `HONCHO_API_KEY` | `--api-key` (init) | Admin JWT |
| `HONCHO_BASE_URL` | `--base-url` (init) | API URL |
| `HONCHO_WORKSPACE_ID` | `-w` / `--workspace` | Workspace scope |
| `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope |
| `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope |
| `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) |
```bash
# Per-command flags
honcho peer card -w prod -p user
# Or export once per shell
export HONCHO_WORKSPACE_ID=prod
export HONCHO_PEER_ID=user
honcho peer card
# One-off against a different server
HONCHO_BASE_URL=http://localhost:8000 honcho workspace list
# CI/CD — env vars only, no config file needed
export HONCHO_API_KEY=hch-v3-xxx
export HONCHO_BASE_URL=https://api.honcho.dev
honcho workspace list
```
## Configuration
The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns two
top-level keys: `apiKey` and `environmentUrl` (the full Honcho API URL, e.g.
`https://api.honcho.dev` or `http://localhost:8000`). Everything else at the
top level — `hosts`, `sessions`, `saveMessages`, `sessionStrategy`, etc. —
is left untouched.
```json
{
"apiKey": "hch-v3-...",
"environmentUrl": "https://api.honcho.dev",
"hosts": { "claude_code": { "...": "..." } }
}
```
`workspace_id` / `peer_id` / `session_id` are per-command only — never
persisted to the config file.
## Development
Install from source in editable mode so changes are picked up live:
```bash
git clone https://github.com/plastic-labs/honcho
cd honcho
uv tool install --force --editable --from ./honcho-cli honcho-cli
```
Re-run any time — changes to `honcho-cli/src/` are reflected immediately without reinstalling.
## License
MIT

53
honcho-cli/pyproject.toml Normal file
View File

@ -0,0 +1,53 @@
[project]
name = "honcho-cli"
version = "0.1.0"
description = "A terminal for Honcho — memory that reasons."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [
{ name = "Plastic Labs", email = "hello@plasticlabs.ai" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries",
]
dependencies = [
"typer>=0.15.0",
"honcho-ai>=2.0.0",
"rich>=13.0.0",
"httpx>=0.27.0",
]
[project.urls]
Homepage = "https://github.com/plastic-labs/honcho"
Repository = "https://github.com/plastic-labs/honcho"
[project.scripts]
honcho = "honcho_cli.main:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/honcho_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-mock>=3.14.0",
]
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true

View File

@ -0,0 +1,3 @@
"""Honcho CLI — a terminal for Honcho."""
__version__ = "0.1.0"

View File

@ -0,0 +1,130 @@
"""Themed help rendering for honcho CLI.
Single source of truth for:
- Rich-utils theme constants (dim borders, brand color)
- HonchoTyperGroup: subclass applied via ``cls=`` at every Typer app in
this package — replaces Click's terse ``Usage: …`` line with
pattern/example rows and prints a curated welcome at the top-level.
Lives in its own module so every ``commands/*.py`` can import it without
pulling in ``main.py`` (which would create an import cycle).
"""
from __future__ import annotations
import click
import typer.rich_utils as ru
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from typer.core import TyperGroup
from honcho_cli import __version__
from honcho_cli.branding import BANNER, BRAND
from honcho_cli.output import use_json
# Theme Typer's rich help renderer. Module-level side effect limited to
# styling — no behavior changes that could surprise other Typer users.
ru.STYLE_COMMANDS_PANEL_BORDER = "dim"
ru.STYLE_OPTIONS_PANEL_BORDER = "dim"
ru.STYLE_ERRORS_PANEL_BORDER = "dim"
ru.STYLE_OPTION = f"bold {BRAND}"
ru.STYLE_SWITCH = f"bold {BRAND}"
ru.STYLE_USAGE = "dim"
ru.STYLE_USAGE_COMMAND = f"bold {BRAND}"
def _cmd_table(rows: list[tuple[str, str]]) -> Table:
t = Table(show_header=False, box=None, padding=(0, 2, 0, 0), expand=False)
t.add_column("cmd", style=f"bold {BRAND}", no_wrap=True)
t.add_column("desc", style="default")
for cmd, desc in rows:
t.add_row(cmd, desc)
return t
def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel:
return Panel(
_cmd_table(rows),
title=f"[dim]{title}[/dim]",
title_align="left",
border_style="dim",
box=box.ROUNDED,
padding=(0, 1),
expand=False,
)
def print_welcome(console: Console) -> None:
"""Render the curated 3-panel welcome (banner + getting started / memory / commands)."""
if use_json():
return
console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]")
console.print(f" [dim]v{__version__}[/dim]\n", highlight=False)
start_rows = [
("honcho init", "configure API key and server URL"),
("honcho doctor", "verify connection and workspace health"),
]
cmd_rows = [
("[dim]pattern[/dim]", r"[dim]honcho <command> \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"),
("[dim]example[/dim]", "[dim]honcho peer chat \"what does alice prefer?\" -p alice -w agents[/dim]"),
("", ""),
("workspace", "list · create · search · delete · inspect · queue-status"),
("peer", "list · create · search · inspect · card · chat"),
("", "get-metadata · set-metadata · representation"),
("session", "list · create · search · delete · inspect · add-peers"),
("", "context · get-metadata · set-metadata · peers"),
("", "remove-peers · representation · summaries"),
("message", "list · create · get"),
("conclusion", "list · create · search · delete"),
("config", "inspect current configuration"),
]
memory_rows = [
("honcho peer chat \"...\" -p <peer> -w <workspace>","query the Dialectic about a peer"),
("honcho peer inspect -p <peer> -w <workspace>","dashboard: peer card + recent conclusions + configuration"),
("honcho peer representation -p <peer> -w <workspace>", "global peer representation"),
("honcho peer representation -p <peer> -w <workspace> -s <session>", "session-scoped peer representation"),
("honcho peer card -p <peer> -w <workspace>", "synthesized identity: traits, preferences, instructions"),
("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
]
option_rows = [
("-w / --workspace", "scope to a workspace"),
("-p / --peer", "scope to a peer"),
("-s / --session", "scope to a session"),
("--json", "force JSON output for scripts and agents"),
("--help", "show help for any command (e.g. honcho peer --help)"),
]
console.print(_welcome_panel("getting started", start_rows))
console.print(_welcome_panel("commands", cmd_rows))
console.print(_welcome_panel("memory", memory_rows))
console.print(_welcome_panel("options", option_rows))
console.print()
class HonchoTyperGroup(TyperGroup):
"""Typer group with pattern/example usage and top-level welcome.
Applied via ``cls=`` on every ``typer.Typer(...)`` in this package,
so no class-level monkey-patching is needed.
"""
def get_usage(self, ctx):
"""Replace Click's 'Usage: …' with pattern/example rows."""
original = click.Command.get_usage(self, ctx)
pattern = original.replace("Usage: ", "", 1) if original.startswith("Usage: ") else original
subs = self.list_commands(ctx)
example = f"{ctx.command_path} {subs[0]}" if subs else f"{ctx.command_path} --help"
return f"pattern: {pattern}\nexample: {example}"
def format_help(self, ctx, formatter):
"""Top-level --help renders the welcome; sub-groups fall through to Typer."""
if ctx.parent is None:
print_welcome(Console())
return
super().format_help(ctx, formatter)

View File

@ -0,0 +1,17 @@
"""Honcho CLI brand constants — colours, icons, and the ASCII banner.
"""
BRAND = "#B6DAFD"
BANNER = """
██╗ ██╗ ██████╗ ███╗ ██╗ ██████╗██╗ ██╗ ██████╗
██║ ██║██╔═══██╗████╗ ██║██╔════╝██║ ██║██╔═══██╗
███████║██║ ██║██╔██╗ ██║██║ ███████║██║ ██║
██╔══██║██║ ██║██║╚██╗██║██║ ██╔══██║██║ ██║
██║ ██║╚██████╔╝██║ ╚████║╚██████╗██║ ██║╚██████╔╝
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝
""".strip("\n")
ICON_OK = "[green]✓[/green]"
ICON_FAIL = "[red]✗[/red]"
ICON_RUN = f"[{BRAND}]→[/{BRAND}]"

View File

@ -0,0 +1,219 @@
"""Conclusion commands: list, search, create, delete."""
from __future__ import annotations
import json
from typing import Optional
import typer
from honcho_cli.commands.workspace import _handle_error
from honcho_cli.output import print_error, print_result, status, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, search, create, and delete peer conclusions (Honcho's memory atoms).")
add_common_options(app)
def _require_observer(observer: str | None) -> str:
"""Resolve observer peer ID; emit combined error if peer+workspace both missing."""
config = get_resolved_config()
obs = observer or config.peer_id
if not obs:
if not config.workspace_id:
print_error(
"NO_SCOPE",
"No peer or workspace scoped. Pass --peer/-p and --workspace/-w, or set HONCHO_PEER_ID and HONCHO_WORKSPACE_ID.",
)
else:
print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion <cmd> -p <peer>")
raise typer.Exit(1)
return obs
@app.command("list")
def list_conclusions(
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
limit: int = typer.Option(10, "--limit", help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List conclusions."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
observer = _require_observer(observer)
client, config = get_client()
p = client.peer(observer)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
conclusions = scope.list(size=limit).items
items = [
{
"id": c.id,
"content": c.content if use_json() else c.content[:200],
"workspace_id": config.workspace_id,
"observer_id": c.observer_id,
"observed_id": c.observed_id,
"session_id": c.session_id,
"created_at": str(c.created_at),
}
for c in conclusions
]
print_result(items, columns=["id", "content", "workspace_id", "observer_id", "observed_id", "session_id", "created_at"], title="Conclusions")
except Exception as e:
_handle_error(e, "conclusion", "list")
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
top_k: int = typer.Option(10, help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Semantic search over conclusions."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
observer = _require_observer(observer)
client, config = get_client()
p = client.peer(observer)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
results = scope.query(query, top_k=top_k)
items = [
{
"id": c.id,
"content": c.content if use_json() else c.content[:200],
"workspace_id": config.workspace_id,
"observer_id": c.observer_id,
"observed_id": c.observed_id,
"session_id": c.session_id,
"created_at": str(c.created_at),
}
for c in results
]
print_result(items, columns=["id", "content", "workspace_id", "session_id", "created_at"], title=f"Conclusion search: {query}")
except Exception as e:
_handle_error(e, "conclusion", "search")
@app.command()
def create(
content: str = typer.Argument(help="Conclusion content or JSON payload"),
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session context"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create a conclusion."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session_id)
observer = _require_observer(observer)
client, config = get_client()
# If content looks like JSON, try to parse it
try:
payload = json.loads(content)
if isinstance(payload, dict):
content = payload.get("content", content)
except json.JSONDecodeError:
pass
p = client.peer(observer)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
params: dict[str, object] = {"content": content}
if config.session_id:
params["session_id"] = config.session_id
results = scope.create([params])
result = results[0] if results else None
if result is None:
print_error("CREATE_FAILED", "Conclusion create returned no results")
raise typer.Exit(1)
print_result({
"id": result.id,
"content": result.content,
"workspace_id": config.workspace_id,
"observer_id": result.observer_id,
"observed_id": result.observed_id,
"session_id": result.session_id,
"created_at": str(result.created_at),
})
except Exception as e:
_handle_error(e, "conclusion", "create")
@app.command()
def delete(
conclusion_id: str = typer.Argument(help="Conclusion ID to delete"),
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a conclusion."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
validate_resource_id(conclusion_id, "conclusion")
client, config = get_client()
if not observer:
observer = config.peer_id
if not observer:
print_error("NO_PEER", "Peer required. Pass --peer/-p: honcho conclusion <cmd> -p <peer>")
raise typer.Exit(1)
p = client.peer(observer)
if not yes:
# SDK doesn't expose a get-by-id on ConclusionScope, so we can't
# preview content cheaply — don't paginate the list just to
# decorate the prompt. Show identifying fields only.
if not use_json():
typer.echo(
f" id: {conclusion_id}\n"
f" observer: {observer}\n"
f" observed: {observed or '(self)'}"
)
typer.confirm(f"Delete conclusion '{conclusion_id}'?", abort=True)
try:
if observed:
scope = p.conclusions_of(observed)
else:
scope = p.conclusions
scope.delete(conclusion_id)
status(f"Conclusion '{conclusion_id}' deleted")
print_result({"deleted": conclusion_id})
except Exception as e:
_handle_error(e, "conclusion", conclusion_id)

View File

@ -0,0 +1,31 @@
"""Config inspection command: ``honcho config``.
Writing to ``~/.honcho/config.json`` is done only via ``honcho init``, which
manages the two CLI-owned keys (``apiKey`` + ``environmentUrl``).
Workspace / peer / session scoping is per-command via flags / env vars, not
persisted defaults.
"""
from __future__ import annotations
import typer
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import handle_cmd_flags
from honcho_cli.config import CLIConfig
from honcho_cli.output import print_result
app = typer.Typer(cls=HonchoTyperGroup, help="Inspect CLI configuration.", invoke_without_command=True)
@app.callback(invoke_without_command=True)
def config(
ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Show current config (api key redacted)."""
if ctx.invoked_subcommand is not None:
return
handle_cmd_flags(json_output=json_output)
cfg = CLIConfig.load()
print_result(cfg.redacted())

View File

@ -0,0 +1,161 @@
"""Message commands: list, get, create."""
from __future__ import annotations
import hashlib
import json
from typing import Optional
import typer
from honcho.api_types import MessageCreateParams
from honcho_cli.commands.session import _get_session_id
from honcho_cli.commands.workspace import _handle_error
from honcho_cli.output import print_error, print_result, status
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, and get messages within a session.")
add_common_options(app)
@app.command("list")
def list_messages(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
last: int = typer.Option(20, "--last", help="Number of recent messages"),
reverse: bool = typer.Option(False, "--reverse", help="Show oldest first (default is newest first)"),
brief: bool = typer.Option(False, "--brief", help="Show only IDs, peer, token count, and created_at (no content)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List messages in a session. Scoped to a peer with -p."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
filters = {"peer_id": config.peer_id} if config.peer_id else None
# Fetch newest-first so [:last] always gives the most recent N messages,
# then flip to oldest-at-top / newest-at-bottom for readable display.
# --reverse keeps the raw server order (oldest first, descending in table).
msgs = sess.messages(filters=filters, reverse=True).items[:last]
if not reverse:
msgs = list(reversed(msgs))
# Detect duplicate content
content_hashes: dict[str, list[str]] = {}
for m in msgs:
h = hashlib.md5(m.content.encode()).hexdigest()
content_hashes.setdefault(h, []).append(m.id)
dupes = {h: ids for h, ids in content_hashes.items() if len(ids) > 1}
if dupes:
dupe_count = sum(len(ids) - 1 for ids in dupes.values())
status(f"Warning: {dupe_count} duplicate message(s) detected (identical content, different IDs)")
if brief:
items = [
{
"id": m.id,
"peer_id": m.peer_id,
"token_count": m.token_count,
"created_at": str(m.created_at),
}
for m in msgs
]
print_result(items, columns=["id", "peer_id", "token_count", "created_at"], title="Messages")
else:
items = [
{
"id": m.id,
"peer_id": m.peer_id,
"content": m.content,
"token_count": m.token_count,
"metadata": m.metadata,
"created_at": str(m.created_at),
}
for m in msgs
]
print_result(items, columns=["id", "peer_id", "content", "created_at"], title="Messages")
except Exception as e:
_handle_error(e, "message", "list")
@app.command("create")
def create_message(
content: str = typer.Argument(help="Message content"),
peer_id: str = typer.Option(..., "--peer", "-p", help="Peer ID of the message sender"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the message"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create a message in a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session_id)
sid = _get_session_id(None)
validate_resource_id(peer_id, "peer")
client, config = get_client()
sess = client.session(sid)
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
try:
msgs = sess.add_messages(MessageCreateParams(
peer_id=peer_id,
content=content,
metadata=parsed_metadata,
))
msg = msgs[0]
print_result({
"id": msg.id,
"peer_id": msg.peer_id,
"content": msg.content,
"token_count": msg.token_count,
"created_at": str(msg.created_at),
})
except Exception as e:
_handle_error(e, "message", "create")
@app.command("get")
def get_message(
message_id: str = typer.Argument(help="Message ID"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get a single message by ID."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
validate_resource_id(message_id, "message")
sid = _get_session_id(session_id)
client, config = get_client()
try:
sess = client.session(sid)
msg = sess.get_message(message_id)
print_result({
"id": msg.id,
"peer_id": msg.peer_id,
"content": msg.content,
"token_count": msg.token_count,
"metadata": msg.metadata,
"created_at": str(msg.created_at),
})
except SystemExit:
raise
except Exception as e:
_handle_error(e, "message", message_id)

View File

@ -0,0 +1,308 @@
"""Peer commands: list, inspect, card, chat, search, create, metadata, representation."""
from __future__ import annotations
import json
from typing import Optional
import typer
from honcho.api_types import PeerConfig
from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list
from honcho_cli.output import print_error, print_result, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, chat with, search, and manage peers and their representations.")
add_common_options(app)
def _get_peer_id(peer_id: str | None) -> str:
config = get_resolved_config()
pid = peer_id or config.peer_id
if not pid:
if not config.workspace_id:
print_error(
"NO_SCOPE",
"No peer or workspace scoped. Pass --peer/-p and --workspace/-w, or set HONCHO_PEER_ID and HONCHO_WORKSPACE_ID.",
)
else:
print_error("NO_PEER", "No peer ID provided. Pass --peer/-p or set HONCHO_PEER_ID.")
raise typer.Exit(1)
return validate_resource_id(pid, "peer")
@app.command("list")
def list_peers(
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List all peers in the workspace."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
client, config = get_client()
try:
raw_peers = _raw_list(client.peers())
items = [
{
"id": p.id,
"metadata": p.metadata,
"configuration": _config_to_dict(p.configuration) if p.configuration else None,
"created_at": str(p.created_at),
}
for p in raw_peers
]
print_result(items, columns=["id", "metadata", "created_at"], title="Peers")
except Exception as e:
_handle_error(e, "peer", "list")
@app.command()
def inspect(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Inspect a peer: card, session count, recent conclusions."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
card = p.get_card()
peer_config = p.get_configuration()
# First page only; SyncPage.total (when the server supplies it) is
# authoritative for counts without walking every page.
session_page = p.sessions()
conclusion_page = p.conclusions.list(size=10)
session_items = session_page.items
conclusion_items = conclusion_page.items
result = {
"id": pid,
"card": card,
"configuration": _config_to_dict(peer_config) if peer_config else None,
"session_count": session_page.total,
"conclusion_count": conclusion_page.total,
"recent_conclusions": [
{"id": c.id, "content": c.content if use_json() else c.content[:200], "created_at": str(c.created_at)}
for c in conclusion_items
],
"sessions": [{"id": s.id} for s in session_items[:10]],
}
print_result(result)
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def card(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer for relationship card"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get raw peer card content."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
result = p.get_card(target=target)
print_result({"peer_id": pid, "target": target, "card": result})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def chat(
query: str = typer.Argument(help="Question to ask about the peer"),
target: Optional[str] = typer.Option(None, help="Target peer for perspective"),
reasoning: Optional[str] = typer.Option(None, "--reasoning", "-r", help="Reasoning level: minimal, low, medium, high, max"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Query the dialectic about a peer."""
_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
if reasoning and reasoning not in _REASONING_LEVELS:
from honcho_cli.output import print_error
print_error("INVALID_REASONING", f"--reasoning must be one of: {', '.join(_REASONING_LEVELS)}")
raise typer.Exit(1)
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
pid = _get_peer_id(None)
client, config = get_client()
p = client.peer(pid)
try:
response = p.chat(
query,
target=target,
session=config.session_id or None,
reasoning_level=reasoning or None,
)
print_result({"peer_id": pid, "query": query, "response": response})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
limit: int = typer.Option(10, help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Search a peer's messages."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(None)
client, config = get_client()
p = client.peer(pid)
try:
results = p.search(query, limit=limit)
items = [
{
"id": m.id,
"content": m.content if use_json() else m.content[:200],
"session_id": m.session_id,
"created_at": str(m.created_at),
}
for m in results
]
print_result(items, columns=["id", "session_id", "content", "created_at"], title=f"Peer search: {query}")
except Exception as e:
_handle_error(e, "peer", pid)
@app.command("create")
def create_peer(
peer_id: str = typer.Argument(help="Peer ID to create or get"),
observe_me: Optional[bool] = typer.Option(None, "--observe-me/--no-observe-me", help="Whether Honcho will form a representation of this peer"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the peer"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create or get a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
pid = validate_resource_id(peer_id, "peer")
client, config = get_client()
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
peer_config = PeerConfig(observe_me=observe_me) if observe_me is not None else None
try:
p = client.peer(pid, configuration=peer_config, metadata=parsed_metadata)
# Only round-trip to the server when the caller passed config or
# metadata — in that case get-or-create may have returned a
# pre-existing peer and the echoed output would lie. When no input
# was passed, skip the two extra API calls entirely.
result: dict[str, object] = {"peer_id": p.id}
if peer_config is not None or parsed_metadata is not None:
result["metadata"] = p.get_metadata()
result["configuration"] = _config_to_dict(p.get_configuration())
print_result(result)
except Exception as e:
_handle_error(e, "peer", pid)
@app.command("get-metadata")
def get_metadata(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get metadata for a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
result = p.get_metadata()
print_result({"peer_id": pid, "metadata": result})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command("set-metadata")
def set_metadata(
metadata: str = typer.Argument(help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Peer ID (uses default if omitted)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Set metadata for a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
pid = _get_peer_id(None)
client, config = get_client()
try:
parsed = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
p = client.peer(pid)
try:
p.set_metadata(parsed)
print_result({"peer_id": pid, "metadata": parsed})
except Exception as e:
_handle_error(e, "peer", pid)
@app.command()
def representation(
peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer to get representation about"),
search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"),
max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get the formatted representation for a peer."""
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer, session=session)
pid = _get_peer_id(peer_id)
client, config = get_client()
p = client.peer(pid)
try:
result = p.representation(
target=target,
session=config.session_id or None,
search_query=search_query,
max_conclusions=max_conclusions,
)
print_result({"peer_id": pid, "target": target, "representation": result})
except Exception as e:
_handle_error(e, "peer", pid)

View File

@ -0,0 +1,404 @@
"""Session commands: list, inspect, context, summaries, peers, search, representation, metadata."""
from __future__ import annotations
import json
from typing import List, Optional
import typer
from honcho import HonchoError
from honcho_cli.commands.workspace import _config_to_dict, _handle_error, _raw_list
from honcho_cli.output import print_error, print_result, status, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, create, delete, and manage conversation sessions and their peers.")
add_common_options(app)
def _get_session_id(session_id: str | None) -> str:
config = get_resolved_config()
sid = session_id or config.session_id
if not sid:
print_error("NO_SESSION", "No session ID provided. Pass --session/-s or set HONCHO_SESSION_ID.")
raise typer.Exit(1)
return validate_resource_id(sid, "session")
@app.command("list")
def list_sessions(
peer_id: Optional[str] = typer.Option(None, "--peer", "-p", help="Filter by peer"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List sessions in the workspace."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
client, config = get_client()
try:
if peer_id:
peer = client.peer(peer_id)
raw_sessions = _raw_list(peer.sessions())
else:
raw_sessions = _raw_list(client.sessions())
items = [
{
"id": s.id,
"is_active": s.is_active,
"metadata": s.metadata,
"created_at": str(s.created_at),
}
for s in raw_sessions
]
print_result(items, columns=["id", "is_active", "metadata", "created_at"], title="Sessions")
except Exception as e:
_handle_error(e, "session", "list")
@app.command("create")
def create_session(
session_id: str = typer.Argument(help="Session ID to create or get"),
peers: Optional[str] = typer.Option(None, "--peers", help="Comma-separated peer IDs to add to the session"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the session"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create or get a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = validate_resource_id(session_id, "session")
client, config = get_client()
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
peer_ids = [p.strip() for p in peers.split(",") if p.strip()] if peers else []
for pid in peer_ids:
validate_resource_id(pid, "peer")
try:
sess = client.session(sid, metadata=parsed_metadata)
if peer_ids:
sess.add_peers(peer_ids)
result: dict[str, object] = {"session_id": sess.id}
if parsed_metadata is not None:
result["metadata"] = parsed_metadata
if peer_ids:
result["peers"] = peer_ids
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def inspect(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Inspect a session: peers, message count, summaries, config."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
peers = sess.peers()
msg_page = sess.messages()
summaries = sess.summaries()
sess_config = sess.get_configuration()
result = {
"session_id": sid,
"peers": [{"id": p.id} for p in peers],
"message_count": msg_page.total,
"summaries": {
"short": summaries.short_summary if hasattr(summaries, "short_summary") else None,
"long": summaries.long_summary if hasattr(summaries, "long_summary") else None,
},
"configuration": _config_to_dict(sess_config) if sess_config else None,
}
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def context(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
tokens: Optional[int] = typer.Option(None, help="Token budget"),
summary: bool = typer.Option(True, help="Include summary"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get session context (what an agent would see)."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
ctx = sess.context(tokens=tokens, summary=summary)
result = ctx.__dict__ if hasattr(ctx, "__dict__") else ctx
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def summaries(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get session summaries (short + long)."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
s = sess.summaries()
result = {
"session_id": sid,
"short_summary": s.short_summary if hasattr(s, "short_summary") else None,
"long_summary": s.long_summary if hasattr(s, "long_summary") else None,
}
print_result(result)
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def delete(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a session and all its data. Destructive — requires --yes or interactive confirm."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
if not yes:
# Show a short preview so the user knows what's about to disappear.
# Only in interactive/TTY mode — scripted (--json) callers already
# know what they're deleting, and they still need to pass --yes.
# Narrow the except to HonchoError so auth/network failures surface
# before the user types 'y' on a destructive op.
if not use_json():
try:
peers = sess.peers()
msg_page = sess.messages()
peer_ids = [p.id for p in peers]
typer.echo(
f" session: {sid}\n"
f" peers: {', '.join(peer_ids) if peer_ids else '(none)'}\n"
f" messages: {msg_page.total}"
)
except HonchoError as preview_err:
status(f"preview unavailable: {preview_err}")
typer.confirm(f"Delete session '{sid}' and all its messages, conclusions, and queue items?", abort=True)
try:
sess.delete()
status(f"Session '{sid}' deleted")
print_result({"deleted": sid})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("peers")
def session_peers(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List peers in a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
peers = sess.peers()
items = [{"id": p.id} for p in peers]
print_result(items, columns=["id"], title=f"Session peers ({sid})")
except Exception as e:
_handle_error(e, "session", sid)
@app.command("add-peers")
def add_peers(
session_id: str = typer.Argument(help="Session ID"),
peer_ids: List[str] = typer.Argument(help="Peer IDs to add to the session"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Add peers to a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
sess.add_peers(peer_ids)
print_result({"session_id": sid, "added_peers": peer_ids})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("remove-peers")
def remove_peers(
session_id: str = typer.Argument(help="Session ID"),
peer_ids: List[str] = typer.Argument(help="Peer IDs to remove from the session"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Remove peers from a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
sess.remove_peers(peer_ids)
print_result({"session_id": sid, "removed_peers": peer_ids})
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
limit: int = typer.Option(10, help="Max results"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Search messages in a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
results = sess.search(query, limit=limit)
items = [
{
"id": m.id,
"peer_id": m.peer_id,
"content": m.content if use_json() else m.content[:200],
"created_at": str(m.created_at),
}
for m in results
]
print_result(items, columns=["id", "peer_id", "content", "created_at"], title=f"Session search: {query}")
except Exception as e:
_handle_error(e, "session", sid)
@app.command()
def representation(
peer_id: str = typer.Argument(help="Peer ID to get representation for"),
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
target: Optional[str] = typer.Option(None, help="Target peer (what peer_id knows about target)"),
search_query: Optional[str] = typer.Option(None, help="Semantic search query to filter conclusions"),
max_conclusions: Optional[int] = typer.Option(None, help="Maximum number of conclusions to include"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get the representation of a peer within a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
result = sess.representation(
peer_id,
target=target,
search_query=search_query,
max_conclusions=max_conclusions,
)
print_result({"session_id": sid, "peer_id": peer_id, "target": target, "representation": result})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("get-metadata")
def get_metadata(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get metadata for a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
sess = client.session(sid)
try:
result = sess.get_metadata()
print_result({"session_id": sid, "metadata": result})
except Exception as e:
_handle_error(e, "session", sid)
@app.command("set-metadata")
def set_metadata(
session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"),
metadata: str = typer.Option(..., "--data", "-d", help="JSON metadata to set (e.g. '{\"key\": \"value\"}')"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Set metadata for a session."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
sid = _get_session_id(session_id)
client, config = get_client()
try:
parsed = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
sess = client.session(sid)
try:
sess.set_metadata(parsed)
print_result({"session_id": sid, "metadata": parsed})
except Exception as e:
_handle_error(e, "session", sid)

View File

@ -0,0 +1,287 @@
"""Top-level onboarding and health-check commands.
`honcho init` — confirm or set apiKey + Honcho URL in ~/.honcho/config.json
`honcho doctor` — verify connectivity, config validity, queue health
"""
from __future__ import annotations
import json
import typer
from honcho import (
APIError,
AuthenticationError,
ConnectionError as HonchoConnectionError,
Honcho,
TimeoutError as HonchoTimeoutError,
)
from rich.console import Console
from rich.panel import Panel
from honcho_cli import __version__
from honcho_cli.branding import BANNER, BRAND, ICON_FAIL, ICON_OK, ICON_RUN
from honcho_cli.common import get_resolved_config
from honcho_cli.config import (
CONFIG_FILE,
DEFAULT_BASE_URL,
CLIConfig,
)
from honcho_cli.output import print_error, print_result, set_json_mode, use_json
_console = Console(stderr=True)
# --------------------------------------------------------------------------- #
# shared helpers
def _redact(api_key: str) -> str:
"""Show ``***<last4>`` — enough to compare keys without leaking the body."""
if not api_key:
return ""
if len(api_key) <= 4:
return "***"
return "***" + api_key[-4:]
def _read_file_values() -> tuple[str, str]:
"""Return (apiKey, environmentUrl) persisted on disk (or empty strings)."""
if not CONFIG_FILE.exists():
return "", ""
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return "", ""
if not isinstance(data, dict):
return "", ""
key = data.get("apiKey") if isinstance(data.get("apiKey"), str) else ""
url = data.get("environmentUrl") if isinstance(data.get("environmentUrl"), str) else ""
return key, url
def _test_connection(base_url: str, api_key: str) -> tuple[bool, str]:
"""Probe the Honcho API by listing workspaces. Returns (ok, detail).
Dispatches on the SDK's typed exception hierarchy instead of matching
substrings of error messages — robust to SDK message changes and locale.
"""
try:
list(Honcho(base_url=base_url, api_key=api_key).workspaces())
return True, "OK"
except AuthenticationError:
return False, "Unauthorized — check your API key"
except HonchoConnectionError:
return False, "Connection refused — is the server running?"
except HonchoTimeoutError:
return False, "Request timed out"
except APIError as e:
return False, f"API error ({e.status}): {e}"
except Exception as e:
return False, str(e)
def _pick(flag_val: str | None, file_val: str) -> str:
"""Return best available value. Flag/env wins over file."""
return flag_val or file_val or ""
# --------------------------------------------------------------------------- #
# honcho init
def init(
api_key: str | None = typer.Option(None, "--api-key", envvar="HONCHO_API_KEY", help="API key (admin JWT)"),
base_url: str | None = typer.Option(None, "--base-url", envvar="HONCHO_BASE_URL", help="Honcho API URL (e.g. https://api.honcho.dev, http://localhost:8000)"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Set API key and server URL in ~/.honcho/config.json.
Press Enter to keep the current value or type a replacement.
Workspace / peer / session scoping is per-command via -w / -p / -s
or HONCHO_* env vars — never persisted.
"""
if json_output:
set_json_mode(True)
file_key, file_url = _read_file_values()
key_val = _pick(api_key, file_key)
url_val = _pick(base_url, file_url).strip()
if not use_json():
_console.print()
_console.print(Panel(
f"[bold {BRAND}]{BANNER}[/bold {BRAND}]\n\n Memory that reasons",
expand=False, subtitle=f"Honcho CLI · v{__version__}",
))
_console.print()
_console.print()
final_key = _prompt_api_key(key_val)
final_url = _prompt_url(url_val)
# Persist if anything changed or if the value came from env/flag.
if final_key != file_key or final_url != file_url:
CLIConfig(base_url=final_url, api_key=final_key).save()
if not use_json():
_console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]")
_check_connection(final_url, final_key)
if use_json():
print_result({"apiKey": _redact(final_key), "baseUrl": final_url})
def _prompt_api_key(value: str) -> str:
"""Prompt for API key.
When a key already exists (from env var or config file), the user picks
between keeping it or entering a replacement. When no key exists, the
user can paste one or press Enter to skip (local dev with auth disabled
doesn't need a key).
"""
if use_json():
return value
if value:
redacted = _redact(value)
_console.print(f" [dim]Current API key: {redacted}[/dim]")
_console.print(" [dim](1)[/dim] Keep current key")
_console.print(" [dim](2)[/dim] Enter a new key")
choice = typer.prompt(" Choice", default="1", show_default=True, prompt_suffix=": ").strip()
if choice == "2":
raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip()
return raw
return value
else:
_console.print(" [dim]Not needed for local dev — press Enter to skip[/dim]")
raw = typer.prompt(" API key", default="", show_default=False, prompt_suffix=": ").strip()
return raw
def _normalize_url(url: str) -> str:
"""Strip whitespace from the URL."""
return url.strip()
def _prompt_url(value: str) -> str:
"""Prompt for Honcho URL. Shows current value as the default; Enter keeps it.
First run defaults to DEFAULT_BASE_URL. After that, whatever is saved
in config becomes the default so the user isn't fighting back to their
custom URL every time.
"""
if use_json():
if value:
return _normalize_url(value)
print_error("MISSING_VALUE", "Honcho URL is required", {})
raise typer.Exit(1)
default = _normalize_url(value) if value else DEFAULT_BASE_URL
_console.print(" [dim]Use https://api.honcho.dev for the hosted Honcho instance[/dim]")
while True:
raw = typer.prompt(" Honcho URL", default=default, show_default=True, prompt_suffix=": ").strip()
url = _normalize_url(raw)
if url.startswith(("http://", "https://")):
return url
_console.print(" [red]URL must start with http:// or https://[/red]")
def _check_connection(base_url: str, api_key: str) -> None:
if not use_json():
_console.print(f"\n {ICON_RUN} [dim]Testing connection to {base_url}...[/dim]", end=" ")
ok, detail = _test_connection(base_url, api_key)
if not ok:
if use_json():
print_error("CONNECTION_FAILED", detail, {"base_url": base_url})
else:
_console.print(f"{ICON_FAIL} [red]Failed[/red]: {detail}")
raise typer.Exit(1)
if not use_json():
_console.print(f"{ICON_OK} [green]Connected[/green]")
# --------------------------------------------------------------------------- #
# honcho doctor
def doctor(
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Verify config and connectivity. Scope with -w / -p to check workspace, peer, and queue health."""
if json_output:
set_json_mode(True)
checks: list[dict] = []
def _add(name: str, ok: bool, detail: str = "") -> None:
checks.append({"check": name, "ok": ok, "detail": detail})
if not use_json():
icon = ICON_OK if ok else ICON_FAIL
line = f" {icon} {name:<22}"
if detail:
line += f" [dim]{detail}[/dim]"
_console.print(line)
if not use_json():
_console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n")
config = get_resolved_config()
_add("Config file", CONFIG_FILE.exists(),
str(CONFIG_FILE) if CONFIG_FILE.exists() else f"{CONFIG_FILE} not found")
_add("API key configured", bool(config.api_key),
"set" if config.api_key else "missing — run `honcho init`")
if config.base_url and config.api_key:
_add("API connectivity", *_test_connection(config.base_url, config.api_key))
else:
_add("API connectivity", False, "skipped — no base_url or api_key")
# Workspace / peer / queue run only when scoped via -w / -p.
ws_ok, client = False, None
if config.workspace_id and config.api_key:
try:
client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id)
client.get_configuration()
ws_ok = True
_add("Workspace reachable", True, config.workspace_id)
except Exception as e:
_add("Workspace reachable", False, f"{config.workspace_id}: {e}")
if ws_ok:
try:
q = client.queue_status()
_add("Queue health", True, f"{q.completed_work_units}/{q.total_work_units} completed, {q.pending_work_units} pending")
except Exception:
_add("Queue health", True, "endpoint not available (non-critical)")
if config.peer_id:
if ws_ok and client is not None:
try:
client.peer(config.peer_id).get_card()
_add("Peer exists", True, config.peer_id)
except Exception as e:
_add("Peer exists", False, f"{config.peer_id}: {e}")
else:
_add("Peer exists", False, "skipped — workspace not reachable")
passed = sum(1 for c in checks if c["ok"])
total = len(checks)
if use_json():
print_result({"checks": checks, "passed": passed, "total": total})
else:
color = BRAND if passed == total else ("yellow" if passed > total // 2 else "red")
hint = "" if config.workspace_id else " [dim](pass -w / -p to include workspace, peer, queue checks)[/dim]"
_console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n")
# Config file + API connectivity are hard requirements.
critical = {"Config file", "API key configured", "API connectivity"}
if config.workspace_id:
critical.add("Workspace reachable")
if any(not c["ok"] for c in checks if c["check"] in critical):
raise typer.Exit(1)

View File

@ -0,0 +1,322 @@
"""Workspace commands: list, inspect, create, delete, search, queue-status."""
from __future__ import annotations
import json
from typing import Optional
import typer
from honcho import (
APIError,
AuthenticationError,
Honcho,
NotFoundError,
PermissionDeniedError,
ServerError,
)
from honcho_cli.output import print_error, print_result, status, use_json
from honcho_cli.validation import validate_resource_id
from honcho_cli._help import HonchoTyperGroup
from honcho_cli.common import add_common_options, get_client, get_resolved_config, handle_cmd_flags
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, inspect, delete, and search workspaces.")
add_common_options(app)
def _get_workspace_id(workspace_id: str | None) -> str:
config = get_resolved_config()
wid = workspace_id or config.workspace_id
if not wid:
print_error("NO_WORKSPACE", "No workspace ID provided. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.")
raise typer.Exit(1)
return validate_resource_id(wid, "workspace")
def _raw_list(page) -> list:
"""Collect all raw API response items across all pages of a SyncPage."""
items = list(page._raw_items)
while page.has_next_page():
page = page.get_next_page()
if page is None:
break
items.extend(page._raw_items)
return items
@app.command("list")
def list_workspaces(
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""List all accessible workspaces."""
handle_cmd_flags(json_output=json_output)
client, config = get_client(require_workspace=False)
try:
workspaces = list(client.workspaces())
items = [{"id": w} for w in workspaces]
print_result(items, columns=["id"], title="Workspaces")
except Exception as e:
_handle_error(e, "workspace", "list")
@app.command("create")
def create_workspace(
workspace_id: str = typer.Argument(help="Workspace ID to create or get"),
metadata: Optional[str] = typer.Option(None, "--metadata", help="JSON metadata to associate with the workspace"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Create or get a workspace."""
handle_cmd_flags(json_output=json_output)
wid = validate_resource_id(workspace_id, "workspace")
client, config = get_client(require_workspace=False)
ws_client = _with_workspace(client, wid)
parsed_metadata = None
if metadata:
try:
parsed_metadata = json.loads(metadata)
except json.JSONDecodeError as e:
print_error("INVALID_JSON", f"--metadata must be valid JSON: {e}", {})
raise typer.Exit(1)
try:
# Trigger get-or-create via the workspace ensure mechanism
ws_client.get_configuration()
result: dict[str, object] = {"workspace_id": wid}
if parsed_metadata is not None:
ws_client.set_metadata(parsed_metadata)
result["metadata"] = parsed_metadata
print_result(result)
except Exception as e:
_handle_error(e, "workspace", wid)
@app.command()
def inspect(
workspace_id: Optional[str] = typer.Argument(None, help="Workspace ID (uses default if omitted)"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Inspect a workspace: peers, sessions, config."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
wid = _get_workspace_id(workspace_id)
client, config = get_client(require_workspace=False)
# Override workspace if positional arg given
if workspace_id:
client = _with_workspace(client, workspace_id)
try:
ws_config = client.get_configuration()
ws_metadata = client.get_metadata()
peer_page = client.peers()
session_page = client.sessions()
raw_peers = peer_page._raw_items
raw_sessions = session_page._raw_items
result = {
"workspace_id": wid,
"metadata": ws_metadata,
"configuration": _config_to_dict(ws_config) if ws_config else None,
"peer_count": peer_page.total,
"session_count": session_page.total,
"peers": [
{"id": p.id, "metadata": p.metadata, "created_at": str(p.created_at)}
for p in raw_peers[:20]
],
"sessions": [
{"id": s.id, "is_active": s.is_active, "metadata": s.metadata, "created_at": str(s.created_at)}
for s in raw_sessions[:20]
],
}
print_result(result)
except Exception as e:
_handle_error(e, "workspace", wid)
@app.command()
def delete(
workspace_id: str = typer.Argument(help="Workspace ID to delete"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt (for scripted/agent use)"),
cascade: bool = typer.Option(False, "--cascade", help="Delete all sessions before deleting the workspace"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Delete a workspace. Use --dry-run first to see what will be deleted.
Requires --yes to skip confirmation, or will prompt interactively.
If sessions exist, requires --cascade to delete them first.
"""
handle_cmd_flags(json_output=json_output)
validate_resource_id(workspace_id, "workspace")
# workspace_id is a required positional and we rebuild the client with it
# immediately, so the default-workspace guard isn't needed here.
client, config = get_client(require_workspace=False)
ws_client = _with_workspace(client, workspace_id)
# Verify workspace exists before prompting for confirmation
try:
ws_client.get_metadata()
except Exception as e:
_handle_error(e, "workspace", workspace_id)
return
# Always fetch sessions for dry-run or cascade
raw_sessions = _raw_list(ws_client.sessions()) if (dry_run or cascade) else []
if dry_run:
print_result({
"dry_run": True,
"workspace_id": workspace_id,
"sessions_to_delete": len(raw_sessions),
"session_ids": [s.id for s in raw_sessions],
"warning": "This action cannot be undone.",
})
return
if not yes:
if cascade and raw_sessions:
typer.confirm(
f"Delete workspace '{workspace_id}' and {len(raw_sessions)} session(s)? This cannot be undone.",
abort=True,
)
else:
typer.confirm(f"Delete workspace '{workspace_id}'? This cannot be undone.", abort=True)
try:
deleted_sessions = []
if cascade and raw_sessions:
for s in raw_sessions:
ws_client.session(s.id).delete()
deleted_sessions.append(s.id)
status(f"Deleted session '{s.id}'")
ws_client.delete_workspace(workspace_id)
status(f"Workspace '{workspace_id}' deletion accepted (processing in background)")
result = {"deleted_workspace": workspace_id, "status": "accepted"}
if cascade:
result["deleted_sessions"] = deleted_sessions
print_result(result)
except Exception as e:
_handle_error(e, "workspace", workspace_id)
@app.command()
def search(
query: str = typer.Argument(help="Search query"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
limit: int = typer.Option(10, help="Max results"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Search messages across workspace."""
handle_cmd_flags(json_output=json_output, workspace=workspace)
wid = _get_workspace_id(None)
client, config = get_client()
try:
results = client.search(query, limit=limit)
items = [
{
"id": m.id,
"content": m.content if use_json() else m.content[:200],
"peer_id": m.peer_id,
"session_id": m.session_id,
"created_at": str(m.created_at),
}
for m in results
]
print_result(items, columns=["id", "peer_id", "session_id", "content"], title=f"Search: {query}")
except Exception as e:
_handle_error(e, "workspace", wid)
@app.command("queue-status")
def queue_status(
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
observer: Optional[str] = typer.Option(None, help="Filter by observer peer"),
sender: Optional[str] = typer.Option(None, help="Filter by sender peer"),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Get queue processing status."""
handle_cmd_flags(json_output=json_output, workspace=workspace, session=session)
_get_workspace_id(None)
client, config = get_client()
try:
result = client.queue_status(observer=observer, sender=sender, session=config.session_id or None)
print_result(result.__dict__ if hasattr(result, "__dict__") else result)
except Exception as e:
_handle_error(e, "queue", "status")
def _with_workspace(client, workspace_id: str):
"""Return a new client pointed at a different workspace."""
return Honcho(
base_url=str(client.base_url),
api_key=client._http.api_key if hasattr(client._http, "api_key") else None,
workspace_id=workspace_id,
)
def _config_to_dict(config) -> dict:
"""Convert a config object to a dict, handling nested objects."""
if hasattr(config, "__dict__"):
result = {}
for k, v in config.__dict__.items():
if k.startswith("_"):
continue
result[k] = _config_to_dict(v) if hasattr(v, "__dict__") and not isinstance(v, str) else v
return result
return config
def _handle_error(e: Exception, resource: str, resource_id: str) -> None:
"""Handle SDK exceptions with structured error output.
Dispatches on the SDK's typed exception hierarchy
(``honcho.http.exceptions``) and falls back to its ``status`` field for
any APIError subclass we don't enumerate. Substring matching on the
message is used only as a last-ditch fallback for non-SDK exceptions.
"""
if isinstance(e, NotFoundError):
print_error(
f"{resource.upper()}_NOT_FOUND",
f"{resource.title()} '{resource_id}' not found",
{resource: resource_id},
)
raise typer.Exit(1)
if isinstance(e, AuthenticationError):
print_error("AUTH_ERROR", f"Authentication failed: {e}", {})
raise typer.Exit(3)
if isinstance(e, PermissionDeniedError):
print_error("PERMISSION_ERROR", f"Permission denied: {e}", {})
raise typer.Exit(3)
if isinstance(e, ServerError):
print_error("SERVER_ERROR", f"Server error: {e}", {resource: resource_id})
raise typer.Exit(2)
if isinstance(e, APIError):
# Catch-all for typed API errors we haven't special-cased
# (BadRequest, Conflict, UnprocessableEntity, RateLimit, ...).
print_error(
"API_ERROR",
f"API error ({e.status}): {e}",
{resource: resource_id, "status": e.status},
)
raise typer.Exit(1)
print_error("UNKNOWN_ERROR", str(e), {resource: resource_id})
raise typer.Exit(1)

View File

@ -0,0 +1,112 @@
"""Shared runtime state, client factory, and command-level flag helpers.
Flags --json, -w, -p, -s are documented at **command-level** (the canonical
form demonstrated in the welcome panel, README, and skill files):
honcho workspace list -w granola --json
They also parse at group-level and top-level for flexibility. All three
positions resolve identically and are idempotent — command-level is a
no-op if the same flag was already set at an outer level.
"""
from __future__ import annotations
from typing import Optional
import typer
from honcho import Honcho
from honcho_cli.config import CLIConfig, get_client_kwargs
from honcho_cli.output import print_error, set_json_mode
from honcho_cli.validation import validate_resource_id
# Global overrides from flags (commands read these)
_global_overrides: dict[str, str | None] = {
"workspace": None,
"peer": None,
"session": None,
}
def get_resolved_config():
"""Get config with global flag overrides applied.
Overrides flow through ``validate_resource_id`` so that a malformed
``-w``/``-p``/``-s`` value fails fast with a structured error rather than
reaching the API and surfacing as an opaque ``UNKNOWN_ERROR``.
"""
config = CLIConfig.load()
if _global_overrides["workspace"]:
config.workspace_id = validate_resource_id(_global_overrides["workspace"], "workspace")
if _global_overrides["peer"]:
config.peer_id = validate_resource_id(_global_overrides["peer"], "peer")
if _global_overrides["session"]:
config.session_id = validate_resource_id(_global_overrides["session"], "session")
return config
def get_client(*, require_workspace: bool = True):
"""Create a Honcho client from resolved config.
By default, refuses to build a client when no workspace is scoped — the
SDK's get-or-create semantics would otherwise silently operate on an empty
workspace. Commands that legitimately run without a workspace (e.g.
``workspace list``) pass ``require_workspace=False``.
"""
config = get_resolved_config()
if require_workspace and not config.workspace_id:
print_error(
"NO_WORKSPACE",
"No workspace scoped. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.",
)
raise typer.Exit(1)
return Honcho(**get_client_kwargs(config)), config
def handle_cmd_flags(
json_output: bool = False,
workspace: str | None = None,
peer: str | None = None,
session: str | None = None,
**_kwargs,
) -> None:
"""Apply command-level flags. Idempotent if already set by group callback."""
if json_output:
set_json_mode(True)
if workspace:
_global_overrides["workspace"] = workspace
if peer:
_global_overrides["peer"] = peer
if session:
_global_overrides["session"] = session
def add_common_options(app: typer.Typer) -> None:
"""Add a callback to a sub-app that accepts --json, -w, -p, -s."""
@app.callback(invoke_without_command=True)
def _callback(
ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
) -> None:
if json_output:
set_json_mode(True)
if workspace:
_global_overrides["workspace"] = workspace
if peer:
_global_overrides["peer"] = peer
if session:
_global_overrides["session"] = session
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())

View File

@ -0,0 +1,150 @@
"""Configuration management for Honcho CLI.
Config stored at ``~/.honcho/config.json`` with env var overrides.
The CLI owns exactly two top-level keys in that file:
apiKey -- Honcho admin JWT
environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev)
All other top-level keys (``hosts``, ``sessions``, ``saveMessages``,
``sessionStrategy``, …) are written by sibling Honcho tools and are
preserved untouched on save.
Workspace / peer / session scoping is intentionally *not* persisted here —
pass ``-w`` / ``-p`` / ``-s`` flags or set ``HONCHO_WORKSPACE_ID`` /
``HONCHO_PEER_ID`` / ``HONCHO_SESSION_ID`` per command instead.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, fields
from pathlib import Path
CONFIG_DIR = Path.home() / ".honcho"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_BASE_URL = "https://api.honcho.dev"
# Env var mapping for runtime overrides.
#
# Resolution order: flag > env var > config file > default.
ENV_MAP: dict[str, str] = {
"api_key": "HONCHO_API_KEY",
"base_url": "HONCHO_BASE_URL",
"workspace_id": "HONCHO_WORKSPACE_ID",
"peer_id": "HONCHO_PEER_ID",
"session_id": "HONCHO_SESSION_ID",
}
@dataclass
class CLIConfig:
"""CLI configuration with layered resolution: flag > env > file > default.
``workspace_id`` / ``peer_id`` / ``session_id`` exist on this dataclass so
flag/env overrides flow through ``get_client_kwargs()``, but they are
never read from or written to the config file — they're per-command.
"""
base_url: str = DEFAULT_BASE_URL
api_key: str = ""
workspace_id: str = ""
peer_id: str = ""
session_id: str = ""
@classmethod
def load(cls) -> CLIConfig:
"""Load config from file, then overlay env vars."""
config = cls()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
data = {}
if isinstance(data, dict):
url = data.get("environmentUrl")
if isinstance(url, str) and url:
config.base_url = url
key = data.get("apiKey")
if isinstance(key, str):
config.api_key = key
for fld_name, env_var in ENV_MAP.items():
val = os.environ.get(env_var)
if val:
setattr(config, fld_name, val)
elif val == "":
# SDK reads these env vars directly and crashes on empty
# strings with a Pydantic ValidationError. Drop them so the
# SDK falls back to kwargs / defaults.
os.environ.pop(env_var, None)
return config
def save(self) -> None:
"""Write ``apiKey`` + ``environmentUrl`` to config.json.
Preserves unrelated top-level keys (``hosts``, ``sessions``,
``saveMessages``, ``sessionStrategy``, …) that other tools write.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
data: dict = {}
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data = loaded
except (json.JSONDecodeError, OSError):
data = {}
data["environmentUrl"] = self.base_url
if self.api_key:
data["apiKey"] = self.api_key
else:
data.pop("apiKey", None)
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
# API key in plaintext — restrict to the owner on multi-user hosts.
try:
os.chmod(CONFIG_FILE, 0o600)
except OSError:
pass
def redacted(self) -> dict[str, str]:
"""Return config dict with api_key redacted.
Only includes fields that have a value set — per-command fields
(workspace_id, peer_id, session_id) are omitted when empty.
"""
d: dict[str, str] = {}
for fld in fields(self):
val = getattr(self, fld.name)
if not val:
continue
if fld.name == "api_key":
# Show ``***<last4>`` only — enough to compare keys without
# leaking the header or body of the JWT.
d[fld.name] = "***" + val[-4:] if len(val) > 4 else "***"
else:
d[fld.name] = val
return d
def get_client_kwargs(config: CLIConfig) -> dict:
"""Build kwargs for Honcho client from config."""
kwargs: dict = {}
if config.base_url:
kwargs["base_url"] = config.base_url
if config.api_key:
kwargs["api_key"] = config.api_key
if config.workspace_id:
kwargs["workspace_id"] = config.workspace_id
return kwargs

View File

@ -0,0 +1,96 @@
"""Honcho CLI — a terminal for Honcho.
Entry point and top-level command group.
"""
from __future__ import annotations
import os
import sys
import typer
from rich.console import Console
from honcho_cli import __version__
from honcho_cli._help import HonchoTyperGroup, print_welcome
from honcho_cli.branding import BANNER
from honcho_cli.output import set_json_mode
app = typer.Typer(
name="honcho",
cls=HonchoTyperGroup,
help="A terminal for Honcho — memory that reasons.",
invoke_without_command=True,
pretty_exceptions_enable=False,
add_completion=False,
)
def _json_requested_early() -> bool:
"""Best-effort JSON detection before Typer parses flags.
version_callback is eager and fires before set_json_mode() runs, so we
can't call use_json() here. Mirror its logic against argv/env/TTY.
"""
return (
"--json" in sys.argv
or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true")
or not sys.stdout.isatty()
)
def version_callback(value: bool) -> None:
if value:
if not _json_requested_early():
print(BANNER)
print(f" honcho-cli {__version__}")
raise typer.Exit()
@app.callback()
def main(
ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
version: bool = typer.Option(False, "--version", "-V", callback=version_callback, is_eager=True, help="Show version"),
) -> None:
"""Honcho CLI — admin & debugging tool for Honcho workspaces."""
set_json_mode(json_output)
if ctx.invoked_subcommand is None:
print_welcome(Console())
raise typer.Exit()
# Register top-level commands
from honcho_cli.commands.setup import doctor, init
app.command()(init)
app.command()(doctor)
@app.command("help", hidden=True)
def help_cmd(ctx: typer.Context) -> None:
"""Show help message."""
Console().print(ctx.parent.get_help() if ctx.parent else "")
raise typer.Exit()
# Register command groups
from honcho_cli.commands.config_cmd import app as config_app
from honcho_cli.commands.conclusion import app as conclusion_app
from honcho_cli.commands.message import app as message_app
from honcho_cli.commands.peer import app as peer_app
from honcho_cli.commands.session import app as session_app
from honcho_cli.commands.workspace import app as workspace_app
app.add_typer(peer_app, name="peer")
app.add_typer(session_app, name="session")
app.add_typer(message_app, name="message")
app.add_typer(conclusion_app, name="conclusion")
app.add_typer(workspace_app, name="workspace")
app.add_typer(config_app, name="config")
if __name__ == "__main__":
app()

View File

@ -0,0 +1,104 @@
"""Output formatting: JSON, tables, and structured errors.
Detects TTY to auto-switch between human-readable and machine-parseable output.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
from rich.console import Console
from rich.table import Table
console = Console(stderr=True)
stdout_console = Console()
def is_tty() -> bool:
"""Check if stdout is a TTY."""
return sys.stdout.isatty()
# Global state for --json flag
_force_json = False
def set_json_mode(enabled: bool) -> None:
global _force_json
_force_json = enabled
def use_json() -> bool:
"""Should we output JSON?"""
return _force_json or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true") or not is_tty()
def print_json(data: Any) -> None:
"""Print a single JSON value to stdout."""
print(json.dumps(data, indent=2, default=str))
def print_table(columns: list[str], rows: list[list[str]], title: str | None = None) -> None:
"""Print a rich table to stdout."""
table = Table(title=title, show_header=True, header_style="bold")
for col in columns:
table.add_column(col)
for row in rows:
table.add_row(*row)
stdout_console.print(table)
def print_result(data: Any, columns: list[str] | None = None, title: str | None = None) -> None:
"""Print data as JSON or table depending on mode.
For lists, uses JSON arrays in JSON mode or tables in TTY mode.
For dicts, uses JSON or key-value display.
"""
if use_json():
print_json(data)
else:
if isinstance(data, list) and columns:
rows = []
for item in data:
row = [str(item.get(col, "")) if isinstance(item, dict) else str(item) for col in columns]
rows.append(row)
print_table(columns, rows, title=title)
elif isinstance(data, dict):
table = Table(show_header=False)
table.add_column("Field", style="bold")
table.add_column("Value")
for k, v in data.items():
val = json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v)
table.add_row(k, val)
stdout_console.print(table)
else:
stdout_console.print(data)
def print_error(code: str, message: str, details: dict | None = None) -> None:
"""Print structured error."""
err = {
"error": {
"code": code,
"message": message,
}
}
if details:
err["error"]["details"] = details
if use_json():
print(json.dumps(err, default=str), file=sys.stderr)
else:
console.print(f"[red]Error[/red] ({code}): {message}")
if details:
for k, v in details.items():
console.print(f" {k}: {v}")
def status(msg: str) -> None:
"""Print a status message to stderr."""
console.print(f"[dim]{msg}[/dim]")

View File

@ -0,0 +1,50 @@
---
name: honcho-cli
version: 0.1.0
description: A terminal for Honcho — memory that reasons.
---
# Honcho CLI — Agent Interface
## Overview
`honcho` is a CLI for administering and debugging Honcho workspaces. It wraps the Honcho Python SDK with agent-friendly defaults: JSON output, structured errors, input validation.
## Output Modes
- **TTY**: Human-readable tables (default when interactive)
- **Piped/scripted**: JSON automatically
- `--json`: Force JSON output
## Exit Codes
- 0: Success
- 1: Client error (bad input, not found)
- 2: Server error
- 3: Auth error
## Config
Shared with other Honcho tools at `~/.honcho/config.json`. The CLI owns only
`apiKey` and `environmentUrl` at the top level. Host-specific
entries under `hosts` are untouched.
Run `honcho init` to confirm or set those two values. Workspace / peer /
session are per-command — pass them via flags or env vars:
```bash
honcho peer card -w my-workspace -p my-peer
# or
export HONCHO_WORKSPACE_ID=my-workspace
export HONCHO_PEER_ID=my-peer
honcho peer card
```
## Command Groups
- `honcho config` — Manage CLI configuration
- `honcho workspace` — Inspect, delete, search workspaces
- `honcho peer` — Inspect, card, chat, search peers
- `honcho session` — Inspect, messages, context, summaries
- `honcho message` — List and get messages
- `honcho conclusion` — List, search, create, delete conclusions

View File

@ -0,0 +1,54 @@
---
name: honcho-cli-debug
version: 0.1.0
description: Debug Honcho peer representations and memory
---
# Honcho CLI — Debug Skills
## Rules
- Check queue status when derivation seems stalled
- Compare peer card with conclusions to understand memory state
## Debugging Memory Issues
### Peer not learning?
```bash
# Check if observation is enabled
honcho peer inspect <peer_id> --json | jq '.configuration'
# Check queue — are messages being processed?
honcho workspace queue-status --json
# Check what conclusions exist
honcho conclusion list --observer <peer_id> --json
honcho conclusion search "expected topic" --observer <peer_id> --json
```
### Session context looks wrong?
```bash
# See raw context
honcho session context <session_id> --json
# Check summaries
honcho session summaries <session_id> --json
# Check message history
honcho message list <session_id> --last 50 --json
```
### Dialectic giving bad answers?
```bash
# Check what the peer card says
honcho peer card <peer_id> --json
# Check conclusions for the specific topic
honcho conclusion search "topic" --observer <peer_id> --json
# Try the dialectic directly
honcho peer chat <peer_id> "what do you know about X?" --json
```

View File

@ -0,0 +1,53 @@
---
name: honcho-cli-inspect
version: 0.1.0
description: Inspect Honcho workspace state for debugging
---
# Honcho CLI — Inspection Skills
## Rules
- Always use `--json` when processing output programmatically
- Run `honcho peer inspect` before `honcho peer chat` to understand context
- Use `honcho session context` to see exactly what an agent receives
- Never run `honcho workspace delete` without `honcho workspace inspect` first
## Inspection Workflow
### 1. Understand the workspace
```bash
honcho workspace inspect --json
```
### 2. Find the peer
```bash
honcho peer list --json
honcho peer inspect <peer_id> --json
```
### 3. Check peer's memory
```bash
honcho peer card <peer_id> --json
honcho conclusion list --observer <peer_id> --json
honcho conclusion search "topic" --observer <peer_id> --json
```
### 4. Debug a session
```bash
honcho session inspect <session_id> --json
honcho message list <session_id> --last 20 --json
honcho session context <session_id> --json
honcho session summaries <session_id> --json
```
### 5. Search across workspace
```bash
honcho workspace search "query" --json
honcho peer search <peer_id> "query" --json
```

View File

@ -0,0 +1,44 @@
"""Input hardening: validate resource IDs and workspace names.
Agents hallucinate bad IDs. Catch them early with clear errors.
"""
from __future__ import annotations
import re
from honcho_cli.output import print_error
UNSAFE_CHARS = re.compile(r'[?#%\x00-\x1f\x7f/\\]')
def validate_resource_id(value: str, resource_type: str = "resource") -> str:
"""Validate a resource ID. Returns the value if valid, raises SystemExit on invalid."""
if not value:
_fail(
"EMPTY_ID",
f"Empty {resource_type} ID provided",
{resource_type: ""},
)
if UNSAFE_CHARS.search(value):
_fail(
"INVALID_ID",
f"Invalid {resource_type} ID: contains unsafe characters (?, #, %, control chars, path separators)",
{resource_type: value},
)
if ".." in value:
_fail(
"INVALID_ID",
f"Invalid {resource_type} ID: contains path traversal",
{resource_type: value},
)
return value
def _fail(code: str, message: str, details: dict) -> None:
"""Print structured error and exit."""
print_error(code, message, details)
raise SystemExit(1)

View File

View File

@ -0,0 +1,195 @@
"""Command-level tests: init flow, destructive confirms, JSON output contract, exit codes.
Uses Typer's CliRunner against the real `app`. stdout is not a TTY under
CliRunner, so `use_json()` returns True and the CLI emits JSON —
which is exactly what scripts and agents consume.
"""
from __future__ import annotations
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
from honcho_cli.main import app
@pytest.fixture
def cfg(tmp_path, monkeypatch):
"""Isolated config file + clean HONCHO_* env."""
f = tmp_path / "config.json"
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f)
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
monkeypatch.delenv(k)
return f
@pytest.fixture
def runner():
return CliRunner()
# --------------------------------------------------------------------------- #
# 1. `honcho init` end-to-end
class TestInit:
def test_first_run_writes_exact_shape(self, cfg, runner):
"""First run with --api-key + --base-url writes apiKey + environmentUrl only."""
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
result = runner.invoke(
app,
["init", "--api-key", "test-key-123", "--base-url", "http://localhost:8000"],
)
assert result.exit_code == 0, result.stderr
assert json.loads(cfg.read_text()) == {
"environmentUrl": "http://localhost:8000",
"apiKey": "test-key-123",
}
def test_preserves_foreign_keys(self, cfg, runner):
"""Second run must not clobber sibling-tool keys (`hosts`, `sessions`, ...)."""
cfg.write_text(json.dumps({
"apiKey": "old",
"environmentUrl": "http://old.example",
"hosts": {"claude_code": {"peerName": "user"}},
"sessions": {"/Users/user": "home-chat"},
"sessionStrategy": "chat-instance",
}))
with patch("honcho_cli.commands.setup._test_connection", return_value=(True, "OK")):
result = runner.invoke(
app,
["init", "--api-key", "new-key", "--base-url", "https://api.honcho.dev"],
)
assert result.exit_code == 0, result.stderr
on_disk = json.loads(cfg.read_text())
assert on_disk["apiKey"] == "new-key"
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
assert on_disk["hosts"] == {"claude_code": {"peerName": "user"}}
assert on_disk["sessions"] == {"/Users/user": "home-chat"}
assert on_disk["sessionStrategy"] == "chat-instance"
# --------------------------------------------------------------------------- #
# 2. Destructive-confirm guards
class TestDestructiveConfirm:
def test_workspace_delete_aborts_on_no(self, cfg, runner):
"""`workspace delete` without --yes: 'n' at prompt → no API call, non-zero exit."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
fake = MagicMock()
fake.sessions.return_value = MagicMock(has_next_page=lambda: False, _raw_items=[])
with patch("honcho_cli.commands.workspace.get_client", return_value=(fake, MagicMock())), \
patch("honcho_cli.commands.workspace._with_workspace", return_value=fake):
result = runner.invoke(app, ["workspace", "delete", "ws1"], input="n\n")
assert result.exit_code != 0
fake.delete_workspace.assert_not_called()
def test_session_delete_aborts_on_no(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
session = MagicMock()
client = MagicMock()
client.session.return_value = session
config = MagicMock(session_id="s1", workspace_id="ws1")
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
result = runner.invoke(app, ["session", "delete", "s1"], input="n\n")
assert result.exit_code != 0
session.delete.assert_not_called()
# --------------------------------------------------------------------------- #
# 3. JSON output contract — scripts pipe these
class TestJsonContract:
def test_workspace_list_json_array_shape(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
client = MagicMock()
client.workspaces.return_value = ["ws-a", "ws-b"]
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, MagicMock())):
result = runner.invoke(app, ["workspace", "list"])
assert result.exit_code == 0, result.stderr
assert json.loads(result.stdout) == [{"id": "ws-a"}, {"id": "ws-b"}]
def test_workspace_search_preserves_full_content_in_json_mode(self, cfg, runner):
cfg.write_text(json.dumps({
"apiKey": "k",
"environmentUrl": "http://localhost:8000",
"workspace_id": "ws1",
}))
message = MagicMock(
id="msg1",
content="x" * 250,
peer_id="peer1",
session_id="sess1",
created_at="2026-01-01T00:00:00Z",
)
client = MagicMock()
client.search.return_value = [message]
config = MagicMock(workspace_id="ws1")
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
result = runner.invoke(app, ["workspace", "search", "topic", "-w", "ws1"])
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert payload == [{
"id": "msg1",
"content": "x" * 250,
"peer_id": "peer1",
"session_id": "sess1",
"created_at": "2026-01-01T00:00:00Z",
}]
def test_message_get_returns_single_json_object(self, cfg, runner):
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
msg = MagicMock(
id="msg1",
peer_id="peer1",
content="hello",
token_count=7,
metadata={"kind": "demo"},
created_at="2026-01-01T00:00:00Z",
)
session = MagicMock()
session.get_message.return_value = msg
client = MagicMock()
client.session.return_value = session
config = MagicMock(session_id="sess1", workspace_id="ws1")
with patch("honcho_cli.commands.message.get_client", return_value=(client, config)):
result = runner.invoke(app, ["message", "get", "msg1", "-s", "sess1", "-w", "ws1"])
assert result.exit_code == 0, result.stderr
assert json.loads(result.stdout) == {
"id": "msg1",
"peer_id": "peer1",
"content": "hello",
"token_count": 7,
"metadata": {"kind": "demo"},
"created_at": "2026-01-01T00:00:00Z",
}
# --------------------------------------------------------------------------- #
# 4. Exit codes on error
class TestExitCodes:
def test_no_workspace_scoped_exits_nonzero_with_code(self, cfg, runner):
"""Running a workspace-scoped command with no workspace → NO_WORKSPACE on stderr, exit 1."""
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
result = runner.invoke(app, ["peer", "list"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "NO_WORKSPACE"
def test_not_found_exits_nonzero_with_code(self, cfg, runner):
"""SDK NotFoundError → structured error, exit 1."""
from honcho import NotFoundError
cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
client = MagicMock()
client.peer.return_value.get_card.side_effect = NotFoundError("not found")
config = MagicMock(peer_id="missing", session_id="", workspace_id="ws1")
with patch("honcho_cli.commands.peer.get_client", return_value=(client, config)):
result = runner.invoke(app, ["peer", "inspect", "missing", "-w", "ws1"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "PEER_NOT_FOUND"

View File

@ -0,0 +1,113 @@
"""Tests for config management."""
import json
import os
import pytest
from honcho_cli.config import CLIConfig
@pytest.fixture
def cfg_path(tmp_path, monkeypatch):
"""Redirect CONFIG_FILE to tmp_path and clear HONCHO_* env vars."""
f = tmp_path / "config.json"
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
for key in [k for k in os.environ if k.startswith("HONCHO_")]:
monkeypatch.delenv(key)
return f
class TestLoad:
def test_defaults_when_no_file(self, cfg_path):
loaded = CLIConfig.load()
assert loaded.base_url == "https://api.honcho.dev"
assert loaded.api_key == ""
assert loaded.workspace_id == ""
def test_malformed_file_uses_defaults(self, cfg_path):
cfg_path.write_text("not-json{{{")
assert CLIConfig.load().api_key == ""
def test_reads_environment_url(self, cfg_path):
cfg_path.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
loaded = CLIConfig.load()
assert loaded.base_url == "http://localhost:8000"
assert loaded.api_key == "k"
def test_api_key_and_base_url_from_env(self, cfg_path, monkeypatch):
"""HONCHO_API_KEY and HONCHO_BASE_URL override config file at runtime."""
cfg_path.write_text(json.dumps({"environmentUrl": "https://api.honcho.dev"}))
monkeypatch.setenv("HONCHO_API_KEY", "env-key")
monkeypatch.setenv("HONCHO_BASE_URL", "http://localhost:8000")
loaded = CLIConfig.load()
assert loaded.api_key == "env-key"
assert loaded.base_url == "http://localhost:8000"
class TestSave:
def test_writes_only_cli_owned_keys(self, cfg_path):
"""apiKey + environmentUrl are written; workspace/peer/session are not."""
CLIConfig(
base_url="http://localhost:8000",
api_key="test-key-123",
workspace_id="my-ws", # must NOT be persisted
peer_id="user",
session_id="s1",
).save()
assert json.loads(cfg_path.read_text()) == {
"environmentUrl": "http://localhost:8000",
"apiKey": "test-key-123",
}
def test_preserves_foreign_keys(self, cfg_path):
"""Other tools' top-level keys (hosts, sessions, ...) are untouched."""
seed = {
"apiKey": "old-key",
"environmentUrl": "https://api.honcho.dev",
"saveMessages": True,
"sessions": {"/Users/user": "home-chat"},
"hosts": {"claude_code": {"peerName": "user", "workspace": "agents"}},
"sessionStrategy": "chat-instance",
}
cfg_path.write_text(json.dumps(seed))
cfg = CLIConfig.load()
cfg.api_key = "new-key"
cfg.save()
on_disk = json.loads(cfg_path.read_text())
assert on_disk["apiKey"] == "new-key"
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
for k in ("saveMessages", "sessions", "hosts", "sessionStrategy"):
assert on_disk[k] == seed[k]
@pytest.mark.parametrize(
"api_key, expected",
[
# Long JWT: only last 4 chars visible, masked prefix.
("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdef", "***cdef"),
# Short value > 4 chars: still only last 4.
("abcdef", "***cdef"),
# 4 or fewer chars: fully masked — don't leak the whole key.
("abcd", "***"),
("x", "***"),
],
)
def test_api_key_redaction_shows_last4_only(api_key, expected):
"""Redacted api_key must show ``***<last4>`` at most, never the header/body."""
assert CLIConfig(api_key=api_key).redacted()["api_key"] == expected
def test_api_key_redaction_empty_omitted():
"""Empty api_key is omitted from redacted output entirely."""
assert "api_key" not in CLIConfig(api_key="").redacted()
def test_save_sets_600_permissions(cfg_path):
"""Config with plaintext API key must be owner-readable only on POSIX."""
import stat
CLIConfig(base_url="http://localhost:8000", api_key="sekret").save()
mode = stat.S_IMODE(os.stat(cfg_path).st_mode)
# chmod(0o600) → rw- --- ---
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"

View File

@ -0,0 +1,57 @@
"""Tests for resource-ID validation.
Agents hallucinate IDs; ``validate_resource_id`` is the defense in depth
between that and the API. These tests pin the accept/reject rules.
"""
from __future__ import annotations
import pytest
from honcho_cli.validation import validate_resource_id
class TestAccepts:
@pytest.mark.parametrize(
"value",
[
"eri",
"my-peer-01",
"workspace_name",
"UPPER",
"with.dots",
"123abc",
"a",
"long-id-with-many-parts_v2",
],
)
def test_safe_id_round_trips(self, value):
assert validate_resource_id(value, "peer") == value
class TestRejects:
def test_empty_string(self):
with pytest.raises(SystemExit):
validate_resource_id("", "peer")
@pytest.mark.parametrize(
"value",
[
"bad/slash",
"bad\\backslash",
"bad?query",
"bad#hash",
"bad%encoded",
"with\x00null",
"with\x1fctrl",
"with\x7fdel",
],
)
def test_unsafe_chars(self, value):
with pytest.raises(SystemExit):
validate_resource_id(value, "peer")
@pytest.mark.parametrize("value", ["..", "../etc", "foo/..", "a..b"])
def test_path_traversal(self, value):
with pytest.raises(SystemExit):
validate_resource_id(value, "peer")

407
honcho-cli/uv.lock Normal file
View File

@ -0,0 +1,407 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "honcho-ai"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
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" }
wheels = [
{ 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" },
]
[[package]]
name = "honcho-cli"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "honcho-ai" },
{ name = "httpx" },
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-mock" },
]
[package.metadata]
requires-dist = [
{ name = "honcho-ai", specifier = ">=0.1.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" },
{ name = "rich", specifier = ">=13.0.0" },
{ name = "typer", specifier = ">=0.15.0" },
]
provides-extras = ["dev"]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]

View File

@ -62,10 +62,12 @@ dev = [
[tool.uv.workspace]
members = [
"sdks/python",
"honcho-cli",
]
[tool.uv.sources]
honcho-ai = { workspace = true }
honcho-cli = { workspace = true }
[tool.ruff.lint]
# from https://docs.astral.sh/ruff/linter/#rule-selection example

View File

@ -29,6 +29,9 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
[project.optional-dependencies]
cli = ["honcho-cli>=0.1.0"]
[project.urls]
Homepage = "https://github.com/plastic-labs/honcho"
Repository = "https://github.com/plastic-labs/honcho"

688
uv.lock

File diff suppressed because it is too large Load Diff