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.
Files changed (53) hide show
  1. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/CHANGELOG.md +11 -0
  2. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/PKG-INFO +5 -1
  3. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/README.md +2 -0
  4. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/pyproject.toml +4 -1
  5. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/__init__.py +1 -1
  6. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/_help.py +1 -0
  7. honcho_cli-0.3.0/src/honcho_cli/agents.py +401 -0
  8. honcho_cli-0.3.0/src/honcho_cli/commands/install.py +483 -0
  9. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/main.py +2 -0
  10. honcho_cli-0.3.0/tests/test_install.py +98 -0
  11. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/.gitignore +0 -0
  12. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/scripts/generate_cli_docs.py +0 -0
  13. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/branding.py +0 -0
  14. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/__init__.py +0 -0
  15. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/conclusion.py +0 -0
  16. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/config_cmd.py +0 -0
  17. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/message.py +0 -0
  18. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/peer.py +0 -0
  19. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/scope.py +0 -0
  20. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/session.py +0 -0
  21. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/setup.py +0 -0
  22. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/stack.py +0 -0
  23. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/commands/workspace.py +0 -0
  24. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/common.py +0 -0
  25. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/config.py +0 -0
  26. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/__init__.py +0 -0
  27. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/docker.py +0 -0
  28. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/env.py +0 -0
  29. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/health.py +0 -0
  30. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/profile.py +0 -0
  31. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/setup.py +0 -0
  32. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/templates/__init__.py +0 -0
  33. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/templates/docker-compose.yml +0 -0
  34. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/local/templates/init.sql +0 -0
  35. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/oauth.py +0 -0
  36. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/output.py +0 -0
  37. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/recall.py +0 -0
  38. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/update_check.py +0 -0
  39. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/src/honcho_cli/validation.py +0 -0
  40. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/__init__.py +0 -0
  41. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/conftest.py +0 -0
  42. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_commands.py +0 -0
  43. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_common.py +0 -0
  44. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_conclusion_attribution.py +0 -0
  45. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_config.py +0 -0
  46. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_local.py +0 -0
  47. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_oauth.py +0 -0
  48. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_output.py +0 -0
  49. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_recall.py +0 -0
  50. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_setup.py +0 -0
  51. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_start.py +0 -0
  52. {honcho_cli-0.2.1 → honcho_cli-0.3.0}/tests/test_validation.py +0 -0
  53. {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.2.1
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.2.1"
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]
@@ -1,3 +1,3 @@
1
1
  """Honcho CLI — a terminal for Honcho."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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