honcho-cli 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/CHANGELOG.md +11 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/PKG-INFO +5 -1
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/README.md +2 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/pyproject.toml +4 -1
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/__init__.py +1 -1
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/_help.py +1 -0
- honcho_cli-0.3.0/src/honcho_cli/agents.py +401 -0
- honcho_cli-0.3.0/src/honcho_cli/commands/install.py +483 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/main.py +2 -0
- honcho_cli-0.3.0/tests/test_install.py +98 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/.gitignore +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/scripts/generate_cli_docs.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/branding.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/__init__.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/conclusion.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/config_cmd.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/message.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/peer.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/scope.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/session.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/setup.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/stack.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/workspace.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/common.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/config.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/__init__.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/docker.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/env.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/health.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/profile.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/setup.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/templates/__init__.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/templates/docker-compose.yml +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/templates/init.sql +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/oauth.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/output.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/recall.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/update_check.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/validation.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/__init__.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/conftest.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_commands.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_common.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_conclusion_attribution.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_config.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_local.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_oauth.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_output.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_recall.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_setup.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_start.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_validation.py +0 -0
- {honcho_cli-0.2.1 → honcho_cli-0.3.0}/uv.lock +0 -0
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-10-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `honcho install` finds the AI agents on this machine, shows which already have Honcho, and offers a checkbox picker. Claude Code, Hermes Agent, OpenClaw and Pi get their Honcho plugin and Codex the Honcho MCP server and skill, each through the agent's own commands and the API key in `~/.honcho/config.json`. If that file has no `peerName` yet, it asks what agents should call you (default: your OS username) and saves it there for every agent to share. Other agents it finds link to their setup guide, and `honcho install grok-bot` copies a setup prompt for Grok Bot. Run from inside Claude Code or Codex, it sets up that agent without prompting. `honcho install all` or `honcho install <id>...` skips the picker; `--list` and `--json` only report
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- Requires `certifi` 2026.10.19 or newer, the first CA bundle without the emSign root behind the compromised eMudhra intermediates. Upgrading the CLI now upgrades the bundle it verifies TLS with, including under `pip install -U`, which otherwise leaves dependencies alone
|
|
19
|
+
- A local stack keeps the Honcho image it pinned on first `honcho start`, so a stack set up before this release can still be running an image with the old bundle. Move to the patched image with `honcho stop`, then `honcho start --image ghcr.io/plastic-labs/honcho:v3.4.1`; data is kept
|
|
20
|
+
|
|
10
21
|
## [0.2.1] - 2026-10-20
|
|
11
22
|
|
|
12
23
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: honcho-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A terminal for Honcho — memory that reasons.
|
|
5
5
|
Project-URL: Homepage, https://github.com/plastic-labs/honcho
|
|
6
6
|
Project-URL: Repository, https://github.com/plastic-labs/honcho
|
|
@@ -13,9 +13,11 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Topic :: Software Development :: Libraries
|
|
15
15
|
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: certifi>=2026.10.19
|
|
16
17
|
Requires-Dist: click>=8.0.0
|
|
17
18
|
Requires-Dist: honcho-ai>=2.5.0
|
|
18
19
|
Requires-Dist: httpx>=0.27.0
|
|
20
|
+
Requires-Dist: questionary>=2.1.0
|
|
19
21
|
Requires-Dist: rich>=13.0.0
|
|
20
22
|
Requires-Dist: typer>=0.15.0
|
|
21
23
|
Provides-Extra: dev
|
|
@@ -50,6 +52,7 @@ uv tool install honcho-cli
|
|
|
50
52
|
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
|
|
51
53
|
honcho start --setup basic # local stack: LLM provider key + Docker
|
|
52
54
|
honcho doctor # verify config + connectivity
|
|
55
|
+
honcho install # find your AI agents and add Honcho to them
|
|
53
56
|
honcho # show banner + command list
|
|
54
57
|
```
|
|
55
58
|
|
|
@@ -92,6 +95,7 @@ honcho stop --wipe # also delete volumes
|
|
|
92
95
|
| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. |
|
|
93
96
|
| `honcho status` | Show every local stack (or `--profile` for one). |
|
|
94
97
|
| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue |
|
|
98
|
+
| `honcho install` | Find the AI agents on this machine, show which already have Honcho, and pick which to add it to. Claude Code, Hermes, OpenClaw and Pi get their Honcho plugin and Codex the Honcho MCP server, each through the agent's own commands and the API key in `~/.honcho/config.json`. Other agents link to their setup guide; `honcho install grok-bot` copies a prompt to paste into a Grok Bot. `honcho install all` or `honcho install <id>...` skips the picker; `--list` only reports. |
|
|
95
99
|
|
|
96
100
|
### Workspaces
|
|
97
101
|
|
|
@@ -25,6 +25,7 @@ uv tool install honcho-cli
|
|
|
25
25
|
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
|
|
26
26
|
honcho start --setup basic # local stack: LLM provider key + Docker
|
|
27
27
|
honcho doctor # verify config + connectivity
|
|
28
|
+
honcho install # find your AI agents and add Honcho to them
|
|
28
29
|
honcho # show banner + command list
|
|
29
30
|
```
|
|
30
31
|
|
|
@@ -67,6 +68,7 @@ honcho stop --wipe # also delete volumes
|
|
|
67
68
|
| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. |
|
|
68
69
|
| `honcho status` | Show every local stack (or `--profile` for one). |
|
|
69
70
|
| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue |
|
|
71
|
+
| `honcho install` | Find the AI agents on this machine, show which already have Honcho, and pick which to add it to. Claude Code, Hermes, OpenClaw and Pi get their Honcho plugin and Codex the Honcho MCP server, each through the agent's own commands and the API key in `~/.honcho/config.json`. Other agents link to their setup guide; `honcho install grok-bot` copies a prompt to paste into a Grok Bot. `honcho install all` or `honcho install <id>...` skips the picker; `--list` only reports. |
|
|
70
72
|
|
|
71
73
|
### Workspaces
|
|
72
74
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "honcho-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "A terminal for Honcho — memory that reasons."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -22,6 +22,9 @@ dependencies = [
|
|
|
22
22
|
"honcho-ai>=2.5.0",
|
|
23
23
|
"rich>=13.0.0",
|
|
24
24
|
"httpx>=0.27.0",
|
|
25
|
+
"questionary>=2.1.0",
|
|
26
|
+
# First bundle without emSign ECC Root CA - G3 (eMudhra intermediate key compromise)
|
|
27
|
+
"certifi>=2026.10.19",
|
|
25
28
|
]
|
|
26
29
|
|
|
27
30
|
[project.urls]
|
|
@@ -70,6 +70,7 @@ def print_welcome(console: Console) -> None:
|
|
|
70
70
|
("honcho init", "configure API key and server URL"),
|
|
71
71
|
("honcho start [--setup basic]", "run a local Honcho stack (Docker)"),
|
|
72
72
|
("honcho doctor", "verify connection and workspace health"),
|
|
73
|
+
("honcho install", "find your AI agents and add Honcho to them"),
|
|
73
74
|
]
|
|
74
75
|
stack_rows = [
|
|
75
76
|
("honcho start / status / stop", "lifecycle for the local Docker stack"),
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Which AI agents are on this machine, and how ``honcho install`` adds Honcho to each.
|
|
2
|
+
|
|
3
|
+
Mirrors the "General agents" and "Coding agents" sections of the docs
|
|
4
|
+
integrations catalog (``docs/snippets/integrations-catalog.jsx``).
|
|
5
|
+
|
|
6
|
+
Finding an agent works like vercel-labs/skills: a plain filesystem check, its
|
|
7
|
+
binary on PATH or its config directory on disk, plus the variables an agent
|
|
8
|
+
sets when it is the one running ``honcho install``. Nothing is parsed.
|
|
9
|
+
``honcho install`` never edits an agent's config files either; it only runs
|
|
10
|
+
the agent's own commands. Three kinds:
|
|
11
|
+
|
|
12
|
+
- ``plugin``: the agent has an official Honcho plugin, installed with the
|
|
13
|
+
agent's own plugin commands.
|
|
14
|
+
- ``mcp``: the hosted Honcho MCP server, registered with the agent's own
|
|
15
|
+
``mcp add`` command, plus the honcho-memory skill.
|
|
16
|
+
- ``suggest``: ``honcho install`` changes nothing and points at the setup guide.
|
|
17
|
+
Cloud agents (Grok Bot) get a setup prompt to paste instead.
|
|
18
|
+
|
|
19
|
+
Whether Honcho is already set up comes from asking the agent itself (its
|
|
20
|
+
``plugin list`` / ``mcp get`` / ``config get`` command), never from reading
|
|
21
|
+
its files.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Literal
|
|
36
|
+
|
|
37
|
+
DOCS = "https://honcho.dev/docs/v3/guides"
|
|
38
|
+
CATALOG_URL = f"{DOCS}/overview"
|
|
39
|
+
MCP_DOCS = f"{DOCS}/integrations/mcp"
|
|
40
|
+
MCP_URL = "https://mcp.honcho.dev"
|
|
41
|
+
|
|
42
|
+
GROK_BOT_PROMPT = (
|
|
43
|
+
"Set up Honcho memory using the plugin at "
|
|
44
|
+
"https://github.com/plastic-labs/honcho/tree/main/integrations/cursor: "
|
|
45
|
+
"connect its MCP server (https://mcp.honcho.dev, sign in with OAuth), "
|
|
46
|
+
"install its skills, then run honcho-setup."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Credentials:
|
|
52
|
+
"""What install commands need from ``~/.honcho/config.json``."""
|
|
53
|
+
|
|
54
|
+
api_key: str
|
|
55
|
+
base_url: str
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def hosted(self) -> bool:
|
|
59
|
+
return self.base_url.rstrip("/") == "https://api.honcho.dev"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
# locations
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _home() -> Path:
|
|
67
|
+
return Path.home()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _env_path(var: str, default: Path) -> Path:
|
|
71
|
+
val = os.environ.get(var)
|
|
72
|
+
return Path(val).expanduser() if val else default
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _exists(path: Path) -> bool:
|
|
76
|
+
"""``Path.exists`` that treats an unreadable parent as missing (3.11/3.12 raise)."""
|
|
77
|
+
try:
|
|
78
|
+
return path.exists()
|
|
79
|
+
except OSError:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _app_support() -> Path:
|
|
84
|
+
"""Per-user application data dir: macOS Application Support, Windows APPDATA, Linux XDG."""
|
|
85
|
+
if sys.platform == "darwin":
|
|
86
|
+
return _home() / "Library" / "Application Support"
|
|
87
|
+
if sys.platform == "win32":
|
|
88
|
+
return _env_path("APPDATA", _home() / "AppData" / "Roaming")
|
|
89
|
+
return _env_path("XDG_CONFIG_HOME", _home() / ".config")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _xdg_config() -> Path:
|
|
93
|
+
return _env_path("XDG_CONFIG_HOME", _home() / ".config")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _vscode_user() -> Path:
|
|
97
|
+
return _app_support() / "Code" / "User"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _hermes_home() -> Path:
|
|
101
|
+
return _env_path("HERMES_HOME", _home() / ".hermes")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _zed_dir() -> Path:
|
|
105
|
+
return _app_support() / "Zed" if sys.platform == "win32" else _xdg_config() / "zed"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _goose_dir() -> Path:
|
|
109
|
+
if sys.platform == "win32":
|
|
110
|
+
return _app_support() / "Block" / "goose" / "config"
|
|
111
|
+
return _xdg_config() / "goose"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _dsh_present() -> bool:
|
|
115
|
+
"""``dsh`` is also Debian's distributed shell; only count DeepSeek's npm one."""
|
|
116
|
+
exe = shutil.which("dsh")
|
|
117
|
+
if not exe:
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
return "deepseek" in os.path.realpath(exe).lower()
|
|
121
|
+
except OSError:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
# asking an agent whether Honcho is set up
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _output(argv: list[str]) -> tuple[int, str] | None:
|
|
130
|
+
"""Run one of the agent's own status commands. ``None`` when it couldn't run."""
|
|
131
|
+
exe = shutil.which(argv[0])
|
|
132
|
+
if not exe:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
proc = subprocess.run(
|
|
136
|
+
[exe, *argv[1:]], capture_output=True, text=True, errors="replace", timeout=60, stdin=subprocess.DEVNULL
|
|
137
|
+
)
|
|
138
|
+
except (OSError, subprocess.SubprocessError):
|
|
139
|
+
return None
|
|
140
|
+
return proc.returncode, proc.stdout
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _json_list(out: tuple[int, str] | None, key: str | None = None) -> list[dict[str, object]] | None:
|
|
144
|
+
"""The list of objects a ``--json`` status command printed, or None if it didn't."""
|
|
145
|
+
if out is None or out[0] != 0:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(out[1])
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
if key is not None:
|
|
152
|
+
data = data.get(key) if isinstance(data, dict) else None
|
|
153
|
+
return [d for d in data if isinstance(d, dict)] if isinstance(data, list) else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _claude_code_has_honcho() -> bool | None:
|
|
157
|
+
out = _output(["claude", "plugin", "list", "--json"])
|
|
158
|
+
return None if out is None or out[0] != 0 else "honcho@honcho" in out[1]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _codex_has_honcho() -> bool | None:
|
|
162
|
+
out = _output(["codex", "mcp", "get", "honcho"])
|
|
163
|
+
return None if out is None else out[0] == 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _hermes_has_honcho() -> bool | None:
|
|
167
|
+
out = _output(["hermes", "config", "get", "memory.provider"])
|
|
168
|
+
return None if out is None or out[0] != 0 else out[1].strip() == "honcho"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _hermes_has_plugin() -> bool:
|
|
172
|
+
plugins = _json_list(_output(["hermes", "plugins", "list", "--json", "--user"]))
|
|
173
|
+
return any(p.get("name") == "honcho" for p in plugins or [])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _openclaw_has_honcho() -> bool | None:
|
|
177
|
+
plugins = _json_list(_output(["openclaw", "plugins", "list", "--json"]), key="plugins")
|
|
178
|
+
return None if plugins is None else any(p.get("id") == "openclaw-honcho" for p in plugins)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _pi_has_honcho() -> bool | None:
|
|
182
|
+
out = _output(["pi", "list"])
|
|
183
|
+
return None if out is None or out[0] != 0 else "@honcho-ai/pi-honcho" in out[1]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --------------------------------------------------------------------------- #
|
|
187
|
+
# install commands — each is the agent's own CLI
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Quiet(list[str]):
|
|
191
|
+
"""An install step that isn't the agent's own CLI; its output shows only if it fails."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _claude_code_install(_: Credentials) -> list[list[str]]:
|
|
195
|
+
return [
|
|
196
|
+
["claude", "plugin", "marketplace", "add", "plastic-labs/claude-honcho"],
|
|
197
|
+
["claude", "plugin", "install", "honcho@honcho"],
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _codex_install(creds: Credentials) -> list[list[str]]:
|
|
202
|
+
# `codex mcp add --url` reads a bearer token only from an env var, so the
|
|
203
|
+
# key from the config goes in mcp-remote's header instead. This entry is the
|
|
204
|
+
# one copy of the key outside ~/.honcho/config.json.
|
|
205
|
+
return [
|
|
206
|
+
[
|
|
207
|
+
"codex", "mcp", "add", "honcho", "--",
|
|
208
|
+
"npx", "-y", "mcp-remote", MCP_URL,
|
|
209
|
+
"--header", f"Authorization:Bearer {creds.api_key}",
|
|
210
|
+
"--header", "X-Honcho-Plugin:codex",
|
|
211
|
+
],
|
|
212
|
+
# the skill that tells Codex when to recall and record, installed by vercel-labs/skills
|
|
213
|
+
Quiet(["npx", "-y", "skills", "add", "plastic-labs/honcho", "--skill", "honcho-memory",
|
|
214
|
+
"--agent", "codex", "--global", "--yes"]),
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _hermes_install(_: Credentials) -> list[list[str]]:
|
|
219
|
+
# The plugin reads the key from ~/.honcho/config.json, so switching the
|
|
220
|
+
# provider on replaces Hermes' interactive `memory setup`.
|
|
221
|
+
enable = ["hermes", "config", "set", "memory.provider", "honcho"]
|
|
222
|
+
if _hermes_has_plugin():
|
|
223
|
+
return [enable]
|
|
224
|
+
return [["hermes", "plugins", "install", "honcho", "--enable", "--yes-deps"], enable]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _hermes_notes() -> list[str]:
|
|
228
|
+
# Hermes reads these before ~/.honcho/config.json, so a key in one of them wins.
|
|
229
|
+
files = dict.fromkeys([_hermes_home() / "honcho.json", _home() / ".hermes" / "honcho.json"])
|
|
230
|
+
return [f"Hermes reads {f} before ~/.honcho/config.json, so a key there wins." for f in files if _exists(f)]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _openclaw_install(_: Credentials) -> list[list[str]]:
|
|
234
|
+
# The plugin reads the key from ~/.honcho/config.json. From ClawHub rather
|
|
235
|
+
# than npm: OpenClaw cancels an npm-sourced install without a terminal, and
|
|
236
|
+
# without --accept-capabilities it stops to ask for consent either way.
|
|
237
|
+
# `plugins uninstall` leaves the entry behind as enabled: false, so a
|
|
238
|
+
# reinstall would stay disabled without the explicit enable. Message capture
|
|
239
|
+
# also needs conversation access, which the plugin's own warning says to
|
|
240
|
+
# turn on this way.
|
|
241
|
+
return [
|
|
242
|
+
["openclaw", "plugins", "install", "clawhub:@honcho-ai/openclaw-honcho", "--accept-capabilities"],
|
|
243
|
+
["openclaw", "plugins", "enable", "openclaw-honcho"],
|
|
244
|
+
["openclaw", "config", "set", "plugins.entries.openclaw-honcho.hooks.allowConversationAccess", "true"],
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --------------------------------------------------------------------------- #
|
|
249
|
+
# registry
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class Agent:
|
|
254
|
+
id: str
|
|
255
|
+
name: str
|
|
256
|
+
kind: Literal["plugin", "mcp", "suggest"]
|
|
257
|
+
docs_url: str
|
|
258
|
+
bins: tuple[str, ...] = ()
|
|
259
|
+
paths: Callable[[], list[Path]] = field(default=lambda: [])
|
|
260
|
+
present: Callable[[], bool] | None = None # overrides bins/paths when set
|
|
261
|
+
cloud: bool = False # runs elsewhere: never found here, always offered
|
|
262
|
+
caller_env: tuple[str, ...] = () # set in the shell of this agent's tool calls
|
|
263
|
+
has_honcho: Callable[[], bool | None] = field(default=lambda: None)
|
|
264
|
+
install: Callable[[Credentials], list[list[str]]] | None = None
|
|
265
|
+
requires: tuple[str, ...] = ()
|
|
266
|
+
hosted_only: bool = False # the hosted MCP server can't reach a self-hosted Honcho
|
|
267
|
+
activate: str | None = None # extra step after install, beyond restarting the agent
|
|
268
|
+
notes: Callable[[], list[str]] = field(default=lambda: []) # warnings to show once installed
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
AGENTS: tuple[Agent, ...] = (
|
|
272
|
+
# ---- installed by `honcho install`
|
|
273
|
+
Agent(
|
|
274
|
+
id="claude-code", name="Claude Code", kind="plugin",
|
|
275
|
+
docs_url=f"{DOCS}/integrations/claude-code",
|
|
276
|
+
bins=("claude",), paths=lambda: [_env_path("CLAUDE_CONFIG_DIR", _home() / ".claude")],
|
|
277
|
+
caller_env=("CLAUDECODE", "CLAUDE_CODE"),
|
|
278
|
+
has_honcho=_claude_code_has_honcho, install=_claude_code_install,
|
|
279
|
+
requires=("claude", "node"), # the plugin's hooks run on node
|
|
280
|
+
),
|
|
281
|
+
Agent(
|
|
282
|
+
id="codex", name="Codex", kind="mcp", docs_url=f"{MCP_DOCS}#codex",
|
|
283
|
+
bins=("codex",), paths=lambda: [_env_path("CODEX_HOME", _home() / ".codex")],
|
|
284
|
+
caller_env=("CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_CI"),
|
|
285
|
+
has_honcho=_codex_has_honcho, install=_codex_install,
|
|
286
|
+
requires=("codex", "npx"), hosted_only=True,
|
|
287
|
+
),
|
|
288
|
+
Agent(
|
|
289
|
+
id="hermes", name="Hermes Agent", kind="plugin",
|
|
290
|
+
docs_url=f"{DOCS}/integrations/hermes",
|
|
291
|
+
bins=("hermes",), paths=lambda: [_hermes_home()],
|
|
292
|
+
has_honcho=_hermes_has_honcho, install=_hermes_install,
|
|
293
|
+
requires=("hermes",), notes=_hermes_notes,
|
|
294
|
+
),
|
|
295
|
+
Agent(
|
|
296
|
+
id="openclaw", name="OpenClaw", kind="plugin",
|
|
297
|
+
docs_url=f"{DOCS}/integrations/openclaw",
|
|
298
|
+
bins=("openclaw",), paths=lambda: [_env_path("OPENCLAW_STATE_DIR", _home() / ".openclaw")],
|
|
299
|
+
has_honcho=_openclaw_has_honcho, install=_openclaw_install,
|
|
300
|
+
requires=("openclaw",), activate="openclaw gateway restart",
|
|
301
|
+
),
|
|
302
|
+
Agent(
|
|
303
|
+
id="pi", name="Pi", kind="plugin", docs_url=f"{DOCS}/integrations/pi",
|
|
304
|
+
bins=("pi",), paths=lambda: [_home() / ".pi" / "agent"],
|
|
305
|
+
has_honcho=_pi_has_honcho, install=lambda _: [["pi", "install", "npm:@honcho-ai/pi-honcho"]],
|
|
306
|
+
requires=("pi",),
|
|
307
|
+
),
|
|
308
|
+
# ---- set up from the guide
|
|
309
|
+
Agent(
|
|
310
|
+
id="grok-bot", name="Grok Bot", kind="suggest",
|
|
311
|
+
docs_url=f"{DOCS}/integrations/grok-bot", cloud=True,
|
|
312
|
+
),
|
|
313
|
+
Agent(
|
|
314
|
+
id="opencode", name="OpenCode", kind="suggest", docs_url=f"{DOCS}/integrations/opencode",
|
|
315
|
+
bins=("opencode",), paths=lambda: [_env_path("OPENCODE_CONFIG_DIR", _xdg_config() / "opencode")],
|
|
316
|
+
),
|
|
317
|
+
Agent(
|
|
318
|
+
id="kilo", name="Kilo Code", kind="suggest", docs_url=f"{DOCS}/integrations/kilo",
|
|
319
|
+
bins=("kilo",), paths=lambda: [_xdg_config() / "kilo"],
|
|
320
|
+
),
|
|
321
|
+
Agent(
|
|
322
|
+
id="dsh", name="DeepSeek Harness", kind="suggest",
|
|
323
|
+
docs_url=f"{DOCS}/integrations/deepseek-harness", present=_dsh_present,
|
|
324
|
+
),
|
|
325
|
+
Agent(
|
|
326
|
+
id="cursor", name="Cursor", kind="suggest", docs_url=f"{DOCS}/integrations/cursor",
|
|
327
|
+
bins=("cursor", "cursor-agent"), paths=lambda: [_home() / ".cursor"],
|
|
328
|
+
),
|
|
329
|
+
Agent(
|
|
330
|
+
id="claude-desktop", name="Claude Desktop", kind="suggest", docs_url=f"{MCP_DOCS}#claude-desktop",
|
|
331
|
+
paths=lambda: [_app_support() / "Claude"] if sys.platform in ("darwin", "win32") else [],
|
|
332
|
+
),
|
|
333
|
+
Agent(
|
|
334
|
+
id="windsurf", name="Windsurf", kind="suggest", docs_url=f"{MCP_DOCS}#windsurf",
|
|
335
|
+
bins=("windsurf",), paths=lambda: [_home() / ".codeium" / "windsurf"],
|
|
336
|
+
),
|
|
337
|
+
Agent(
|
|
338
|
+
id="cline", name="Cline", kind="suggest", docs_url=f"{MCP_DOCS}#cline",
|
|
339
|
+
paths=lambda: [_vscode_user() / "globalStorage" / "saoudrizwan.claude-dev"],
|
|
340
|
+
),
|
|
341
|
+
Agent(
|
|
342
|
+
id="vscode", name="VS Code", kind="suggest", docs_url=f"{MCP_DOCS}#vs-code-copilot-chat",
|
|
343
|
+
bins=("code",), paths=lambda: [_vscode_user()],
|
|
344
|
+
),
|
|
345
|
+
Agent(
|
|
346
|
+
id="zed", name="Zed", kind="suggest", docs_url=f"{MCP_DOCS}#zed",
|
|
347
|
+
bins=("zed", "zeditor"), paths=lambda: [_zed_dir()],
|
|
348
|
+
),
|
|
349
|
+
Agent(
|
|
350
|
+
id="goose", name="Goose", kind="suggest", docs_url=f"{MCP_DOCS}#goose",
|
|
351
|
+
# config dir only: "goose" is also the binary of a common Go migration tool
|
|
352
|
+
paths=lambda: [_goose_dir()],
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass
|
|
358
|
+
class Detection:
|
|
359
|
+
agent: Agent
|
|
360
|
+
found: bool
|
|
361
|
+
installed: bool | None = None # None = can't tell
|
|
362
|
+
blocked: str | None = None # why the install can't run here
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def calling_agent() -> Agent | None:
|
|
366
|
+
"""The agent whose tool call is running this command, from env vars it sets."""
|
|
367
|
+
named = os.environ.get("AI_AGENT", "").strip().lower()
|
|
368
|
+
for agent in AGENTS:
|
|
369
|
+
if agent.id == named or any(os.environ.get(v) for v in agent.caller_env):
|
|
370
|
+
return agent
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _found(agent: Agent) -> bool:
|
|
375
|
+
if agent.present is not None:
|
|
376
|
+
return agent.present()
|
|
377
|
+
return any(shutil.which(b) for b in agent.bins) or any(_exists(p) for p in agent.paths())
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def detect(agent: Agent, creds: Credentials | None = None) -> Detection:
|
|
381
|
+
if agent.cloud or not _found(agent):
|
|
382
|
+
return Detection(agent, found=False)
|
|
383
|
+
if agent.install is None:
|
|
384
|
+
return Detection(agent, found=True)
|
|
385
|
+
missing = [b for b in agent.requires if not shutil.which(b)]
|
|
386
|
+
if missing:
|
|
387
|
+
return Detection(agent, found=True, blocked=f"needs {', '.join(missing)} on PATH")
|
|
388
|
+
try:
|
|
389
|
+
installed = agent.has_honcho()
|
|
390
|
+
except Exception:
|
|
391
|
+
installed = None
|
|
392
|
+
blocked = None
|
|
393
|
+
if not installed and agent.hosted_only and creds is not None and not creds.hosted:
|
|
394
|
+
blocked = f"only works with api.honcho.dev; see {agent.docs_url}"
|
|
395
|
+
return Detection(agent, found=True, installed=installed, blocked=blocked)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def detect_all(creds: Credentials | None = None) -> list[Detection]:
|
|
399
|
+
"""Detect every agent. Status commands run in parallel; some take seconds."""
|
|
400
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
401
|
+
return list(pool.map(lambda a: detect(a, creds), AGENTS))
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""``honcho install`` — find the agents on this machine and add Honcho to them.
|
|
2
|
+
|
|
3
|
+
Finding agents is a filesystem check (see ``honcho_cli.agents``). Adding Honcho
|
|
4
|
+
runs only each agent's own commands: plugin installs for Claude Code, Hermes,
|
|
5
|
+
OpenClaw and Pi, ``codex mcp add`` and the honcho-memory skill for Codex. All
|
|
6
|
+
of them use the API key in ``~/.honcho/config.json``. Other agents are pointed
|
|
7
|
+
at their setup guide, and Grok Bot gets a prompt to paste into a Bot.
|
|
8
|
+
|
|
9
|
+
On a terminal with no arguments it shows a checkbox picker. Run from inside an
|
|
10
|
+
agent's tool call (Claude Code, Codex), it adds Honcho to that agent and never
|
|
11
|
+
prompts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import getpass
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable, Sequence
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
|
|
30
|
+
from honcho_cli.agents import (
|
|
31
|
+
AGENTS,
|
|
32
|
+
CATALOG_URL,
|
|
33
|
+
GROK_BOT_PROMPT,
|
|
34
|
+
Agent,
|
|
35
|
+
Credentials,
|
|
36
|
+
Detection,
|
|
37
|
+
Quiet,
|
|
38
|
+
calling_agent,
|
|
39
|
+
detect_all,
|
|
40
|
+
)
|
|
41
|
+
from honcho_cli.branding import BRAND, ICON_FAIL, ICON_OK, ICON_RUN
|
|
42
|
+
from honcho_cli.config import CONFIG_FILE, DEFAULT_BASE_URL, CLIConfig
|
|
43
|
+
from honcho_cli.output import print_error, print_result, set_json_mode, use_json
|
|
44
|
+
|
|
45
|
+
_console = Console(stderr=True)
|
|
46
|
+
|
|
47
|
+
Runner = Callable[[Sequence[str], bool], int]
|
|
48
|
+
|
|
49
|
+
KEY_PLACEHOLDER = "<api-key>"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run(argv: Sequence[str], quiet: bool) -> int:
|
|
53
|
+
"""Run one install command with the terminal attached; return its exit code.
|
|
54
|
+
|
|
55
|
+
argv[0] is resolved through PATH first so Windows npm ``.cmd`` shims run
|
|
56
|
+
without a shell. In JSON mode the child's stdout goes to stderr, keeping
|
|
57
|
+
our stdout for the JSON summary. A quiet step's output is captured and
|
|
58
|
+
shown only if it fails.
|
|
59
|
+
"""
|
|
60
|
+
exe = shutil.which(argv[0]) or argv[0]
|
|
61
|
+
try:
|
|
62
|
+
if quiet:
|
|
63
|
+
proc = subprocess.run(
|
|
64
|
+
[exe, *argv[1:]], check=False, capture_output=True, text=True, errors="replace",
|
|
65
|
+
stdin=subprocess.DEVNULL,
|
|
66
|
+
)
|
|
67
|
+
if proc.returncode != 0:
|
|
68
|
+
sys.stderr.write(proc.stdout + proc.stderr)
|
|
69
|
+
return proc.returncode
|
|
70
|
+
stdout = None
|
|
71
|
+
if use_json():
|
|
72
|
+
with contextlib.suppress(AttributeError, OSError, ValueError):
|
|
73
|
+
stdout = sys.stderr.fileno()
|
|
74
|
+
return subprocess.run([exe, *argv[1:]], check=False, stdout=stdout).returncode
|
|
75
|
+
except OSError:
|
|
76
|
+
return 127
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Swapped out in tests so nothing is actually installed.
|
|
80
|
+
runner: Runner = _run
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _mask(text: str, key: str) -> str:
|
|
84
|
+
if not key or key == KEY_PLACEHOLDER:
|
|
85
|
+
return text
|
|
86
|
+
return text.replace(key, f"{key[:4]}…{key[-4:]}" if len(key) > 12 else "****")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fmt(commands: list[list[str]], key: str) -> str:
|
|
90
|
+
return _mask(" && ".join(shlex.join(argv) for argv in commands), key)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
# the API key and user peer every install uses
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_config() -> dict[str, Any] | None:
|
|
98
|
+
"""The config file as a dict: empty when missing, None when it exists but can't be parsed."""
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
return {}
|
|
103
|
+
except (OSError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
return data if isinstance(data, dict) else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _saved_field(key: str) -> str:
|
|
109
|
+
"""A string field from the config file itself (not the env vars CLIConfig overlays)."""
|
|
110
|
+
value = (_read_config() or {}).get(key)
|
|
111
|
+
return value if isinstance(value, str) else ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _saved_key() -> str:
|
|
115
|
+
return _saved_field("apiKey")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _save_key(cfg: CLIConfig, key: str) -> None:
|
|
119
|
+
cfg.api_key = key
|
|
120
|
+
cfg.save()
|
|
121
|
+
_console.print(f" {ICON_OK} [dim]Saved the API key to {CONFIG_FILE}[/dim]")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _ensure_api_key(interactive: bool) -> str | None:
|
|
125
|
+
"""The key in ``~/.honcho/config.json``, asking for one (or saving HONCHO_API_KEY) if absent.
|
|
126
|
+
|
|
127
|
+
Every agent reads its key from that file or gets it copied from there, so
|
|
128
|
+
nothing is installed without one. A key only in HONCHO_API_KEY is saved to
|
|
129
|
+
the file, because the OpenClaw and Hermes gateways run as services and never
|
|
130
|
+
see the shell's environment. A browser (device) login stores an OAuth token
|
|
131
|
+
instead, which the plugins can't use.
|
|
132
|
+
"""
|
|
133
|
+
env_key = os.environ.get("HONCHO_API_KEY", "").strip()
|
|
134
|
+
key = _saved_key()
|
|
135
|
+
if key:
|
|
136
|
+
if env_key and env_key != key:
|
|
137
|
+
_console.print(
|
|
138
|
+
f" [yellow]![/yellow] HONCHO_API_KEY differs from the key in {CONFIG_FILE}. "
|
|
139
|
+
"Agents read the file, so that key is the one used."
|
|
140
|
+
)
|
|
141
|
+
return key
|
|
142
|
+
from honcho_cli.commands.setup import _test_connection
|
|
143
|
+
|
|
144
|
+
cfg = CLIConfig.load()
|
|
145
|
+
if env_key:
|
|
146
|
+
ok, detail = _test_connection(cfg.base_url, env_key)
|
|
147
|
+
if ok:
|
|
148
|
+
_save_key(cfg, env_key)
|
|
149
|
+
return env_key
|
|
150
|
+
_console.print(f" {ICON_FAIL} HONCHO_API_KEY was rejected: {detail}")
|
|
151
|
+
|
|
152
|
+
if cfg.oauth and cfg.oauth.access_token:
|
|
153
|
+
why = "You're signed in with a browser login, but agents need an API key."
|
|
154
|
+
else:
|
|
155
|
+
why = f"Agents read it from {CONFIG_FILE}."
|
|
156
|
+
if not interactive:
|
|
157
|
+
_console.print(f" {ICON_FAIL} No Honcho API key yet. {why} Run: honcho init --api-key <key>")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
hosted = cfg.base_url.rstrip("/") == DEFAULT_BASE_URL
|
|
161
|
+
where = "Create one at https://app.honcho.dev and paste it here" if hosted else f"Paste an API key for {cfg.base_url}"
|
|
162
|
+
_console.print(f" [yellow]![/yellow] No Honcho API key yet. {why}")
|
|
163
|
+
_console.print(f" [dim]{where}. Press Enter to cancel.[/dim]")
|
|
164
|
+
while True:
|
|
165
|
+
key = typer.prompt(" API key", default="", show_default=False, hide_input=True).strip()
|
|
166
|
+
if not key:
|
|
167
|
+
return None
|
|
168
|
+
ok, detail = _test_connection(cfg.base_url, key)
|
|
169
|
+
if ok:
|
|
170
|
+
_save_key(cfg, key)
|
|
171
|
+
return key
|
|
172
|
+
_console.print(f" {ICON_FAIL} {detail}. Try again, or press Enter to cancel.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _default_peer_name() -> str:
|
|
176
|
+
try:
|
|
177
|
+
return getpass.getuser() or "user"
|
|
178
|
+
except Exception: # no username in the environment or the password database
|
|
179
|
+
return "user"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _ensure_peer_name(interactive: bool) -> None:
|
|
183
|
+
"""A shared ``peerName`` in ``~/.honcho/config.json``, asking for one if absent.
|
|
184
|
+
|
|
185
|
+
Hermes saves nothing from CLI turns until it knows the user's peer, and the
|
|
186
|
+
other plugins fall back to the same field, so every agent names the user the
|
|
187
|
+
same way. Defaults to the OS username, as the Claude Code plugin does.
|
|
188
|
+
"""
|
|
189
|
+
if _saved_field("peerName"):
|
|
190
|
+
return
|
|
191
|
+
data = _read_config()
|
|
192
|
+
if data is None:
|
|
193
|
+
_console.print(f" [yellow]![/yellow] Couldn't read {CONFIG_FILE}, so no peerName was saved.")
|
|
194
|
+
return
|
|
195
|
+
default = _default_peer_name()
|
|
196
|
+
name = typer.prompt(" What should your agents call you?", default=default).strip() if interactive else ""
|
|
197
|
+
data["peerName"] = name or default
|
|
198
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
200
|
+
with contextlib.suppress(OSError):
|
|
201
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
202
|
+
_console.print(f" {ICON_OK} [dim]Agents will call you {data['peerName']} (peerName in {CONFIG_FILE})[/dim]")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
# rendering
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _preview(d: Detection, creds: Credentials) -> str:
|
|
210
|
+
assert d.agent.install is not None
|
|
211
|
+
return _fmt(d.agent.install(creds), creds.api_key)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _print_status(detections: list[Detection], creds: Credentials, caller: Agent | None) -> None:
|
|
215
|
+
installable = [d for d in detections if d.found and d.agent.install]
|
|
216
|
+
guides = [d for d in detections if d.found and not d.agent.install]
|
|
217
|
+
missing = [d.agent.name for d in detections if not d.found and not d.agent.cloud]
|
|
218
|
+
width = max((len(d.agent.name) for d in installable + guides), default=0) + 2
|
|
219
|
+
|
|
220
|
+
_console.print()
|
|
221
|
+
if caller:
|
|
222
|
+
_console.print(f" [dim]Running inside {caller.name}: setting up {caller.name} only, no prompts.[/dim]\n")
|
|
223
|
+
if installable:
|
|
224
|
+
_console.print(" [bold]Agents found on this machine[/bold]\n")
|
|
225
|
+
for d in installable:
|
|
226
|
+
if d.installed:
|
|
227
|
+
icon, detail = ICON_OK, "[green]Honcho installed[/green]"
|
|
228
|
+
elif d.blocked:
|
|
229
|
+
icon, detail = "[yellow]![/yellow]", f"[yellow]{d.blocked}[/yellow]"
|
|
230
|
+
else:
|
|
231
|
+
note = " [dim](couldn't check if already added)[/dim]" if d.installed is None else ""
|
|
232
|
+
icon, detail = f"[{BRAND}]●[/{BRAND}]", f"{_preview(d, creds)}{note}"
|
|
233
|
+
_console.print(f" {icon} {d.agent.name:<{width}}{detail}", highlight=False)
|
|
234
|
+
else:
|
|
235
|
+
names = ", ".join(a.name for a in AGENTS if a.install)
|
|
236
|
+
_console.print(f" [bold]None of these is on this machine: {names}[/bold]")
|
|
237
|
+
if guides:
|
|
238
|
+
_console.print("\n [bold]Also found[/bold] [dim](set up from the guide)[/dim]\n")
|
|
239
|
+
for d in guides:
|
|
240
|
+
_console.print(f" [dim]○[/dim] {d.agent.name:<{width}}[dim]{d.agent.docs_url}[/dim]", highlight=False)
|
|
241
|
+
_console.print("\n [dim]Grok Bot: `honcho install grok-bot` copies a setup prompt to paste into any Bot.[/dim]")
|
|
242
|
+
if missing:
|
|
243
|
+
_console.print(f" [dim]Not found: {', '.join(missing)}[/dim]")
|
|
244
|
+
_console.print(f" [dim]Full list: {CATALOG_URL}[/dim]\n")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _as_json(detections: list[Detection], creds: Credentials) -> list[dict[str, Any]]:
|
|
248
|
+
return [
|
|
249
|
+
{
|
|
250
|
+
"id": d.agent.id,
|
|
251
|
+
"name": d.agent.name,
|
|
252
|
+
"kind": d.agent.kind,
|
|
253
|
+
"found": d.found,
|
|
254
|
+
"installed": d.installed,
|
|
255
|
+
"blocked": d.blocked,
|
|
256
|
+
"command": _preview(d, creds) if d.found and d.agent.install and not d.installed else None,
|
|
257
|
+
"docs_url": d.agent.docs_url,
|
|
258
|
+
}
|
|
259
|
+
for d in detections
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --------------------------------------------------------------------------- #
|
|
264
|
+
# picker + installs
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _pick(candidates: list[Detection]) -> list[Detection] | None:
|
|
268
|
+
"""Checkbox picker. Returns the ticked rows, or None if the user cancelled."""
|
|
269
|
+
import questionary
|
|
270
|
+
|
|
271
|
+
style = questionary.Style([
|
|
272
|
+
("qmark", f"fg:{BRAND} bold"),
|
|
273
|
+
("pointer", f"fg:{BRAND} bold"),
|
|
274
|
+
("highlighted", f"fg:{BRAND} bold"),
|
|
275
|
+
("selected", f"fg:{BRAND}"),
|
|
276
|
+
("instruction", "fg:#808080"),
|
|
277
|
+
("disabled", "fg:#808080 italic"),
|
|
278
|
+
])
|
|
279
|
+
choices = [
|
|
280
|
+
questionary.Choice(
|
|
281
|
+
title=d.agent.name if d.agent.install else f"{d.agent.name} (copies a setup prompt)",
|
|
282
|
+
value=d.agent.id,
|
|
283
|
+
checked=bool(d.agent.install) and not d.blocked,
|
|
284
|
+
disabled=d.blocked or None,
|
|
285
|
+
)
|
|
286
|
+
for d in candidates
|
|
287
|
+
]
|
|
288
|
+
picked = questionary.checkbox(
|
|
289
|
+
"Add Honcho to which agents?",
|
|
290
|
+
choices=choices,
|
|
291
|
+
instruction="(space to toggle, a for all, enter to confirm)",
|
|
292
|
+
style=style,
|
|
293
|
+
).ask()
|
|
294
|
+
if picked is None:
|
|
295
|
+
return None
|
|
296
|
+
by_id = {d.agent.id: d for d in candidates}
|
|
297
|
+
return [by_id[i] for i in picked]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _copy(text: str) -> bool:
|
|
301
|
+
for argv in (["pbcopy"], ["wl-copy"], ["xclip", "-selection", "clipboard"], ["clip"]):
|
|
302
|
+
exe = shutil.which(argv[0])
|
|
303
|
+
if not exe:
|
|
304
|
+
continue
|
|
305
|
+
try:
|
|
306
|
+
subprocess.run([exe, *argv[1:]], input=text, text=True, check=True, timeout=5)
|
|
307
|
+
return True
|
|
308
|
+
except (OSError, subprocess.SubprocessError):
|
|
309
|
+
continue
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _show_guide(agent: Agent) -> None:
|
|
314
|
+
if agent.id != "grok-bot":
|
|
315
|
+
_console.print(f" {ICON_RUN} {agent.name}: follow {agent.docs_url}")
|
|
316
|
+
return
|
|
317
|
+
copied = " (copied to your clipboard)" if _copy(GROK_BOT_PROMPT) else ""
|
|
318
|
+
_console.print(f"\n {ICON_RUN} [bold]Grok Bot[/bold]: paste this into any Bot{copied}:\n")
|
|
319
|
+
_console.print(f" {GROK_BOT_PROMPT}\n", highlight=False, soft_wrap=True)
|
|
320
|
+
_console.print(f" [dim]The Bot signs you in to Honcho itself. Guide: {agent.docs_url}[/dim]")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _install_one(agent: Agent, creds: Credentials) -> bool:
|
|
324
|
+
assert agent.install is not None
|
|
325
|
+
for argv in agent.install(creds):
|
|
326
|
+
shown = _fmt([argv], creds.api_key)
|
|
327
|
+
_console.print(f"\n {ICON_RUN} [bold]{agent.name}[/bold] [dim]{shown}[/dim]", highlight=False)
|
|
328
|
+
code = runner(argv, isinstance(argv, Quiet))
|
|
329
|
+
if code != 0:
|
|
330
|
+
_console.print(f" {ICON_FAIL} {agent.name}: exited {code}. Retry with: {shown}", highlight=False)
|
|
331
|
+
return False
|
|
332
|
+
if agent.has_honcho() is False:
|
|
333
|
+
_console.print(f" {ICON_FAIL} {agent.name}: the commands ran, but {agent.name} doesn't report Honcho yet.")
|
|
334
|
+
return False
|
|
335
|
+
_console.print(f" {ICON_OK} {agent.name}: Honcho added")
|
|
336
|
+
for note in agent.notes():
|
|
337
|
+
_console.print(f" [yellow]![/yellow] {note}", highlight=False)
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _run_installs(chosen: list[Detection], interactive: bool, results: dict[str, str]) -> int:
|
|
342
|
+
"""Install or show each row, recording the outcome in results."""
|
|
343
|
+
if not chosen:
|
|
344
|
+
_console.print(" Nothing to install.")
|
|
345
|
+
return 0
|
|
346
|
+
creds: Credentials | None = None
|
|
347
|
+
if any(d.agent.install for d in chosen):
|
|
348
|
+
key = _ensure_api_key(interactive)
|
|
349
|
+
if key is None:
|
|
350
|
+
for d in chosen:
|
|
351
|
+
if d.agent.install:
|
|
352
|
+
results[d.agent.id] = "no_api_key"
|
|
353
|
+
_console.print(" Nothing installed: every agent here needs the API key.")
|
|
354
|
+
return 1
|
|
355
|
+
_ensure_peer_name(interactive)
|
|
356
|
+
creds = Credentials(api_key=key, base_url=CLIConfig.load().base_url)
|
|
357
|
+
|
|
358
|
+
failed: list[str] = []
|
|
359
|
+
done: list[Agent] = []
|
|
360
|
+
for d in chosen:
|
|
361
|
+
if not d.agent.install:
|
|
362
|
+
_show_guide(d.agent)
|
|
363
|
+
results[d.agent.id] = "shown"
|
|
364
|
+
continue
|
|
365
|
+
assert creds is not None
|
|
366
|
+
ok = _install_one(d.agent, creds)
|
|
367
|
+
results[d.agent.id] = "installed" if ok else "failed"
|
|
368
|
+
if ok:
|
|
369
|
+
done.append(d.agent)
|
|
370
|
+
else:
|
|
371
|
+
failed.append(d.agent.name)
|
|
372
|
+
_console.print()
|
|
373
|
+
if done:
|
|
374
|
+
_console.print(f" Restart {', '.join(a.name for a in done)} to load Honcho.")
|
|
375
|
+
for agent in done:
|
|
376
|
+
if agent.activate:
|
|
377
|
+
_console.print(f" {agent.name}: run [bold]{agent.activate}[/bold] to activate.")
|
|
378
|
+
if failed:
|
|
379
|
+
_console.print(f" [red]{len(failed)} failed:[/red] {', '.join(failed)}")
|
|
380
|
+
return 1
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _resolve_targets(
|
|
385
|
+
targets: list[str], detections: list[Detection], results: dict[str, str]
|
|
386
|
+
) -> tuple[list[Detection], bool]:
|
|
387
|
+
"""Turn ids (or ``all``) into rows to install. Returns (rows, any_problem)."""
|
|
388
|
+
if "all" in targets:
|
|
389
|
+
chosen = []
|
|
390
|
+
for d in detections:
|
|
391
|
+
if not (d.found and d.agent.install) or d.installed:
|
|
392
|
+
continue
|
|
393
|
+
if d.blocked:
|
|
394
|
+
results[d.agent.id] = "blocked"
|
|
395
|
+
_console.print(f" [dim]Skipping {d.agent.name}: {d.blocked}[/dim]")
|
|
396
|
+
else:
|
|
397
|
+
chosen.append(d)
|
|
398
|
+
return chosen, False
|
|
399
|
+
|
|
400
|
+
by_id = {d.agent.id: d for d in detections}
|
|
401
|
+
chosen: list[Detection] = []
|
|
402
|
+
problem = False
|
|
403
|
+
for t in dict.fromkeys(targets):
|
|
404
|
+
d = by_id[t]
|
|
405
|
+
if not d.agent.install:
|
|
406
|
+
chosen.append(d)
|
|
407
|
+
elif not d.found:
|
|
408
|
+
results[t] = "not_found"
|
|
409
|
+
problem = True
|
|
410
|
+
_console.print(f" {ICON_FAIL} {d.agent.name} isn't on this machine.")
|
|
411
|
+
elif d.installed:
|
|
412
|
+
results[t] = "already_installed"
|
|
413
|
+
_console.print(f" {ICON_OK} {d.agent.name} already has Honcho.")
|
|
414
|
+
elif d.blocked:
|
|
415
|
+
results[t] = "blocked"
|
|
416
|
+
problem = True
|
|
417
|
+
_console.print(f" {ICON_FAIL} {d.agent.name}: {d.blocked}.")
|
|
418
|
+
else:
|
|
419
|
+
chosen.append(d)
|
|
420
|
+
return chosen, problem
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# --------------------------------------------------------------------------- #
|
|
424
|
+
# command
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def install(
|
|
428
|
+
targets: list[str] | None = typer.Argument(
|
|
429
|
+
None,
|
|
430
|
+
help=f"Agents to set up without the picker, or 'all' for every one found. Ids: {', '.join(a.id for a in AGENTS)}",
|
|
431
|
+
show_default=False,
|
|
432
|
+
),
|
|
433
|
+
list_only: bool = typer.Option(False, "--list", help="Only show what was found; install nothing"),
|
|
434
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
435
|
+
) -> None:
|
|
436
|
+
"""Find the AI agents on this machine and add Honcho to them.
|
|
437
|
+
|
|
438
|
+
Claude Code, Hermes, OpenClaw and Pi get their Honcho plugin, and Codex the
|
|
439
|
+
Honcho MCP server and skill, each through the agent's own commands and the
|
|
440
|
+
API key in ~/.honcho/config.json. Other agents link to their setup guide.
|
|
441
|
+
"""
|
|
442
|
+
if json_output:
|
|
443
|
+
set_json_mode(True)
|
|
444
|
+
|
|
445
|
+
creds = Credentials(api_key=_saved_key() or KEY_PLACEHOLDER, base_url=CLIConfig.load().base_url)
|
|
446
|
+
detections = detect_all(creds)
|
|
447
|
+
caller = None if targets or list_only else calling_agent()
|
|
448
|
+
if caller:
|
|
449
|
+
targets = [caller.id]
|
|
450
|
+
|
|
451
|
+
if use_json() and (not targets or list_only):
|
|
452
|
+
print_result(_as_json(detections, creds))
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty() and not use_json() and caller is None
|
|
456
|
+
if not use_json():
|
|
457
|
+
_print_status(detections, creds, caller)
|
|
458
|
+
if list_only:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
results: dict[str, str] = {}
|
|
462
|
+
if targets:
|
|
463
|
+
known = {a.id for a in AGENTS}
|
|
464
|
+
unknown = [t for t in targets if t != "all" and t not in known]
|
|
465
|
+
if unknown:
|
|
466
|
+
print_error("UNKNOWN_AGENT", f"Unknown agent: {', '.join(unknown)}", {"known": sorted(known)})
|
|
467
|
+
raise typer.Exit(2)
|
|
468
|
+
chosen, problem = _resolve_targets(targets, detections, results)
|
|
469
|
+
code = _run_installs(chosen, interactive, results)
|
|
470
|
+
if use_json():
|
|
471
|
+
print_result([{"id": k, "status": v} for k, v in results.items()])
|
|
472
|
+
raise typer.Exit(1 if problem or code else 0)
|
|
473
|
+
|
|
474
|
+
candidates = [d for d in detections if d.found and d.agent.install and not d.installed]
|
|
475
|
+
candidates += [d for d in detections if d.agent.cloud]
|
|
476
|
+
if not interactive:
|
|
477
|
+
_console.print(" No terminal to ask on. Run `honcho install all`, or `honcho install <id>`.\n")
|
|
478
|
+
raise typer.Exit(1)
|
|
479
|
+
chosen = _pick(candidates)
|
|
480
|
+
if chosen is None:
|
|
481
|
+
_console.print(" Cancelled.")
|
|
482
|
+
raise typer.Exit(1)
|
|
483
|
+
raise typer.Exit(_run_installs(chosen, interactive, results))
|
|
@@ -65,11 +65,13 @@ def main(
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
# Register top-level commands
|
|
68
|
+
from honcho_cli.commands.install import install
|
|
68
69
|
from honcho_cli.commands.setup import doctor, init
|
|
69
70
|
from honcho_cli.commands.stack import start, status, stop
|
|
70
71
|
|
|
71
72
|
app.command()(init)
|
|
72
73
|
app.command()(doctor)
|
|
74
|
+
app.command()(install)
|
|
73
75
|
app.command()(start)
|
|
74
76
|
app.command()(stop)
|
|
75
77
|
app.command()(status)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""`honcho install`: agent detection, and which install commands run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from honcho_cli.agents import AGENTS
|
|
11
|
+
from honcho_cli.main import app
|
|
12
|
+
from typer.testing import CliRunner
|
|
13
|
+
|
|
14
|
+
CATALOG = Path(__file__).resolve().parents[2] / "docs" / "snippets" / "integrations-catalog.jsx"
|
|
15
|
+
KEY = "hch-test-0123456789abcdef"
|
|
16
|
+
|
|
17
|
+
# Fake agent CLIs. Claude Code already lists the Honcho plugin; Codex reports
|
|
18
|
+
# the MCP server only once something has registered it.
|
|
19
|
+
STUBS = {
|
|
20
|
+
"claude": 'case "$*" in "plugin list --json") echo \'[{"id":"honcho@honcho"}]\';; esac',
|
|
21
|
+
"codex": '[ "$*" = "mcp get honcho" ] && { [ -f "$HOME/codex-has-honcho" ] || exit 1; }; exit 0',
|
|
22
|
+
"node": "exit 0",
|
|
23
|
+
"npx": "exit 0",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def machine(tmp_path, monkeypatch):
|
|
29
|
+
"""A fake home with Claude Code (Honcho installed), Codex (not yet) and a Cursor config dir."""
|
|
30
|
+
home = tmp_path / "home"
|
|
31
|
+
bin_dir = tmp_path / "bin"
|
|
32
|
+
bin_dir.mkdir()
|
|
33
|
+
for name, body in STUBS.items():
|
|
34
|
+
exe = bin_dir / name
|
|
35
|
+
exe.write_text(f"#!/bin/sh\n{body}\n")
|
|
36
|
+
exe.chmod(0o755)
|
|
37
|
+
(home / ".cursor").mkdir(parents=True)
|
|
38
|
+
config = home / ".honcho" / "config.json"
|
|
39
|
+
config.parent.mkdir()
|
|
40
|
+
config.write_text(json.dumps({"apiKey": KEY}))
|
|
41
|
+
|
|
42
|
+
monkeypatch.setenv("HOME", str(home))
|
|
43
|
+
monkeypatch.setenv("PATH", str(bin_dir))
|
|
44
|
+
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", config)
|
|
45
|
+
monkeypatch.setattr("honcho_cli.commands.install.CONFIG_FILE", config)
|
|
46
|
+
for var in ("HONCHO_API_KEY", "HONCHO_BASE_URL", "XDG_CONFIG_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR",
|
|
47
|
+
"OPENCODE_CONFIG_DIR", "HERMES_HOME", "OPENCLAW_STATE_DIR", "APPDATA", "AI_AGENT",
|
|
48
|
+
*(v for a in AGENTS for v in a.caller_env)):
|
|
49
|
+
monkeypatch.delenv(var, raising=False)
|
|
50
|
+
return home
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_detects_agents_and_installs_only_what_is_missing(machine, monkeypatch):
|
|
54
|
+
runner = CliRunner()
|
|
55
|
+
|
|
56
|
+
rows = {r["id"]: r for r in json.loads(runner.invoke(app, ["install", "--json"]).output)}
|
|
57
|
+
assert rows["claude-code"]["found"] and rows["claude-code"]["installed"] is True
|
|
58
|
+
assert rows["codex"]["found"] and rows["codex"]["installed"] is False
|
|
59
|
+
assert rows["codex"]["command"].startswith("codex mcp add honcho -- npx -y mcp-remote https://mcp.honcho.dev")
|
|
60
|
+
assert KEY not in rows["codex"]["command"] # shown masked
|
|
61
|
+
assert rows["cursor"]["found"] and rows["cursor"]["kind"] == "suggest"
|
|
62
|
+
assert not rows["hermes"]["found"] and not rows["openclaw"]["found"]
|
|
63
|
+
|
|
64
|
+
ran: list[tuple[list[str], bool]] = []
|
|
65
|
+
|
|
66
|
+
def fake_run(argv, quiet):
|
|
67
|
+
ran.append((list(argv), quiet))
|
|
68
|
+
if argv[:3] == ["codex", "mcp", "add"]:
|
|
69
|
+
(machine / "codex-has-honcho").touch()
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
monkeypatch.setattr("honcho_cli.commands.install.runner", fake_run)
|
|
73
|
+
result = runner.invoke(app, ["install", "all"])
|
|
74
|
+
|
|
75
|
+
# Claude Code already has Honcho and Cursor is set up from its guide, so only Codex runs:
|
|
76
|
+
# its own `mcp add` on the terminal, then the skill install with its output hidden.
|
|
77
|
+
assert result.exit_code == 0, result.output
|
|
78
|
+
assert [(argv[:3], quiet) for argv, quiet in ran] == [
|
|
79
|
+
(["codex", "mcp", "add"], False),
|
|
80
|
+
(["npx", "-y", "skills"], True),
|
|
81
|
+
]
|
|
82
|
+
assert f"Authorization:Bearer {KEY}" in ran[0][0]
|
|
83
|
+
# A shared user peer is saved next to the key, keeping what was already there.
|
|
84
|
+
saved = json.loads((machine / ".honcho" / "config.json").read_text())
|
|
85
|
+
assert saved["apiKey"] == KEY and saved["peerName"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.skipif(not CATALOG.exists(), reason="docs catalog not in this checkout")
|
|
89
|
+
def test_every_catalog_agent_is_detected():
|
|
90
|
+
"""Adding an agent to the docs catalog should prompt adding it here."""
|
|
91
|
+
sections = CATALOG.read_text().split('title: "')[1:]
|
|
92
|
+
names = {
|
|
93
|
+
name
|
|
94
|
+
for section in sections
|
|
95
|
+
if section.startswith(("General agents", "Coding agents"))
|
|
96
|
+
for name in re.findall(r'name: "([^"]+)"', section)
|
|
97
|
+
}
|
|
98
|
+
assert names <= {a.name for a in AGENTS}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|