hermes-profile-kit 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hermes_profile_kit-3.0.0.dist-info/METADATA +89 -0
- hermes_profile_kit-3.0.0.dist-info/RECORD +30 -0
- hermes_profile_kit-3.0.0.dist-info/WHEEL +5 -0
- hermes_profile_kit-3.0.0.dist-info/entry_points.txt +2 -0
- hermes_profile_kit-3.0.0.dist-info/licenses/LICENSE +21 -0
- hermes_profile_kit-3.0.0.dist-info/top_level.txt +1 -0
- hpk/__init__.py +3 -0
- hpk/__main__.py +4 -0
- hpk/cli.py +176 -0
- hpk/codegen/__init__.py +0 -0
- hpk/codegen/argparse_walker.py +158 -0
- hpk/codegen/cmd_index.py +12 -0
- hpk/codegen/validate.py +25 -0
- hpk/hermes.py +60 -0
- hpk/manifest.py +151 -0
- hpk/plugins.py +36 -0
- hpk/profiles.py +64 -0
- hpk/py.typed +0 -0
- hpk/tokens/__init__.py +30 -0
- hpk/tokens/anthropic.py +27 -0
- hpk/tokens/base.py +30 -0
- hpk/tokens/brave.py +13 -0
- hpk/tokens/discord.py +29 -0
- hpk/tokens/exa.py +13 -0
- hpk/tokens/openai_codex.py +54 -0
- hpk/tokens/slack.py +69 -0
- hpk/tokens/telegram.py +31 -0
- hpk/ui.py +38 -0
- hpk/verify.py +43 -0
- hpk/wizard.py +178 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-profile-kit
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Interactive multi-profile setup utility for Hermes Agent
|
|
5
|
+
Author: NewTurn2017
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: click>=8.1
|
|
11
|
+
Requires-Dist: packaging>=23
|
|
12
|
+
Requires-Dist: questionary>=2.0
|
|
13
|
+
Requires-Dist: rich>=13
|
|
14
|
+
Requires-Dist: pyyaml>=6
|
|
15
|
+
Requires-Dist: pydantic>=2
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-mock>=3; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
21
|
+
Requires-Dist: types-PyYAML; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# hermes-profile-kit
|
|
25
|
+
|
|
26
|
+
Interactive multi-profile setup utility for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
|
|
27
|
+
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
[](https://github.com/NewTurn2017/hermes-profile-kit/actions/workflows/ci.yml)
|
|
30
|
+
|
|
31
|
+
> 🇰🇷 [한국어 README](README.ko.md)
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pipx install hermes-profile-kit
|
|
37
|
+
hpk setup
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The wizard walks you through 4 profiles (`coder` / `assistant` / `research` / `community-bot`), prompts for the right tokens per channel (Anthropic, Telegram, Slack, Discord, Brave, Exa), and optionally enables recommended plugins (Honcho memory, Brave search tool).
|
|
41
|
+
|
|
42
|
+
## What hpk does (and doesn't)
|
|
43
|
+
|
|
44
|
+
- ✅ Creates and configures four isolated Hermes profiles.
|
|
45
|
+
- ✅ Walks you through BotFather, Slack app, Discord devportal flows.
|
|
46
|
+
- ✅ Atomic, idempotent `.env` writes (chmod 600). Re-running is safe.
|
|
47
|
+
- ✅ Daily upstream-sync via GitHub Actions — kit stays current with Hermes changes.
|
|
48
|
+
- ❌ Does not install Hermes itself (see [Hermes installation](https://github.com/NousResearch/hermes-agent#installation)).
|
|
49
|
+
- ❌ Does not start gateway services automatically.
|
|
50
|
+
- ❌ Does not invoke any hermes command that isn't verified in upstream.
|
|
51
|
+
|
|
52
|
+
## How it stays correct
|
|
53
|
+
|
|
54
|
+
`hpk` never embeds a hermes command that hasn't been observed in the upstream argparse tree. CI AST-parses `hermes_cli/main.py` daily, regenerates `docs/commands.md` and `build/cmd_index.json`, and opens a PR when drift is detected.
|
|
55
|
+
|
|
56
|
+
## Profiles
|
|
57
|
+
|
|
58
|
+
| Profile | Role | Model tier | Channels |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `coder` | Full-stack dev assistant | Sonnet | CLI |
|
|
61
|
+
| `assistant` | Personal daily assistant | Sonnet | CLI + Telegram |
|
|
62
|
+
| `research` | Web-search-backed research | Opus | CLI |
|
|
63
|
+
| `community-bot` | Korean dev community helper | Haiku | Telegram + Discord |
|
|
64
|
+
|
|
65
|
+
## Customization
|
|
66
|
+
|
|
67
|
+
| Goal | Edit |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Change model | `~/.hermes/profiles/<name>/config.yaml` |
|
|
70
|
+
| Change persona | `~/.hermes/profiles/<name>/SOUL.md` |
|
|
71
|
+
| Add new profile | `profiles/<name>/{SOUL.md,config.yaml,.env.example}` + add to `manifest.yaml` → `hpk setup` |
|
|
72
|
+
| Enable a plugin | Add to `manifest.yaml` `plugins:` + reference from `recommended_plugins` |
|
|
73
|
+
|
|
74
|
+
API keys go in `~/.hermes/profiles/<name>/.env`. They're plain text with `chmod 600` — the kit deliberately does not pretend to encrypt them.
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
hpk setup [profile...] # interactive wizard
|
|
80
|
+
hpk verify # doctor + FILL_IN scan
|
|
81
|
+
hpk doctor # hpk's own health
|
|
82
|
+
hpk reset [profile...] # remove kit-created profiles
|
|
83
|
+
hpk plugin list # show recommended_plugins
|
|
84
|
+
hpk sync --dry-run # local drift check
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
hermes_profile_kit-3.0.0.dist-info/licenses/LICENSE,sha256=iTBxQ9WZ2-L-KeZMp8W-407SjHn6qfJUre0ttsxOcRs,1088
|
|
2
|
+
hpk/__init__.py,sha256=SHKS8O7Zr5v3GtKtenjHsi8UzlQ-pqiEloZsaoQGGE8,102
|
|
3
|
+
hpk/__main__.py,sha256=FZiPysFNO838bO1S3ReezODEpqTCxbCo7xrdbg3HOXk,64
|
|
4
|
+
hpk/cli.py,sha256=tRG4P2ss6jXxyxRu9MeCFGOyWHZ_eztgYIR1c7j8Oxc,5457
|
|
5
|
+
hpk/hermes.py,sha256=0RsBq9XuRoNX61C1Xv4n_QN0jaB13ycss0ou6ESD2ag,1778
|
|
6
|
+
hpk/manifest.py,sha256=iIxLmDy960tQqlNpqS7ZzAhZ0ImXI-6ivYnbNbp1hC4,4696
|
|
7
|
+
hpk/plugins.py,sha256=RBagxzlF7RzteZQhIMWjm0yFdUrdvr7pDtGS30KISDw,1334
|
|
8
|
+
hpk/profiles.py,sha256=A7pqO3jgbUqxv8cI50A7EypbHHBji6DRRnzg6hB9HaM,2038
|
|
9
|
+
hpk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
hpk/ui.py,sha256=Fc0olJay5kTe3WK0iI-4rrWZZRhwwsgjbcQ_ePowD1E,811
|
|
11
|
+
hpk/verify.py,sha256=r1gsnpWEw_Tci4EH3JsvkZoPqPUp69vD9HrzSEn0lbY,1399
|
|
12
|
+
hpk/wizard.py,sha256=co7TpS0cF-TU3TgYq3hPxG0_ADXom6cVcJOEXWPH9_4,6219
|
|
13
|
+
hpk/codegen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
hpk/codegen/argparse_walker.py,sha256=xG_qjHHuXoAuZh-dg7dbzoMP7r2edZsXFenIHMXVzB4,5450
|
|
15
|
+
hpk/codegen/cmd_index.py,sha256=t23QycYW85T-3aXnetm02l459zF2uDNYUIlihljEExg,380
|
|
16
|
+
hpk/codegen/validate.py,sha256=pC6Z-yJUfJcfFEel3VgUC_myaXLdOQDq1jBfjjQ21Fw,962
|
|
17
|
+
hpk/tokens/__init__.py,sha256=OV-ow_cjpwVY70Xy5cCi7khakwzSAaSesJXENLbNktQ,1007
|
|
18
|
+
hpk/tokens/anthropic.py,sha256=cGqck4UpE9iGQFZjhWDrFIUXzGdH4lxBdfbCt3a5eAc,860
|
|
19
|
+
hpk/tokens/base.py,sha256=pzFX1YY3ZZic4CflHhvZ5kfPy_3BHQnk7AFvlnpjXH0,714
|
|
20
|
+
hpk/tokens/brave.py,sha256=cWPwyPSBVcude3le1gQO0GfOvniL5_tzaAB_bllV8NU,392
|
|
21
|
+
hpk/tokens/discord.py,sha256=GHAZSV8VUkX5tCY86xNlBVUEhB95Bl0koZeuNUTgr1w,908
|
|
22
|
+
hpk/tokens/exa.py,sha256=Ty4cwnG82Zi5cEMBE9ZsD50Rgvqri3n4SjcSXXdMVMY,356
|
|
23
|
+
hpk/tokens/openai_codex.py,sha256=4nucup4V3NaYdq88kWSq2s0DGklJZgUG8qhQRhXuT_M,1906
|
|
24
|
+
hpk/tokens/slack.py,sha256=PrUpsR_HzerVxSnW9UKeljptBn_sW6ylovLxrRqdghM,2284
|
|
25
|
+
hpk/tokens/telegram.py,sha256=j7FdJ5sVpTzFgNSpifnwR-_Kqjsmets_TDvapyrjaLA,1008
|
|
26
|
+
hermes_profile_kit-3.0.0.dist-info/METADATA,sha256=_dJBcFic2z9BLwiAHKHF98JWyxvmtbyvmXIztqzViow,3471
|
|
27
|
+
hermes_profile_kit-3.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
28
|
+
hermes_profile_kit-3.0.0.dist-info/entry_points.txt,sha256=mTD3trrxMLeXb8GSa2I7FMWMkZcGnpdnvMSJgbiZdvE,37
|
|
29
|
+
hermes_profile_kit-3.0.0.dist-info/top_level.txt,sha256=0z09FxxjgTbwE_GASKtD2h_UYtNVRrXvwOkNermRB58,4
|
|
30
|
+
hermes_profile_kit-3.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hermes-profile-kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hpk
|
hpk/__init__.py
ADDED
hpk/__main__.py
ADDED
hpk/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from hpk import __version__, ui, verify, wizard
|
|
9
|
+
from hpk.manifest import Manifest, ManifestValidationError, load_manifest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _manifest_path() -> Path:
|
|
13
|
+
return Path.cwd() / "manifest.yaml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load() -> Manifest:
|
|
17
|
+
try:
|
|
18
|
+
return load_manifest(_manifest_path())
|
|
19
|
+
except ManifestValidationError as e:
|
|
20
|
+
ui.err(f"manifest invalid: {e}")
|
|
21
|
+
sys.exit(40)
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
ui.err(f"manifest.yaml not found at {_manifest_path()}")
|
|
24
|
+
sys.exit(40)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group(invoke_without_command=True)
|
|
28
|
+
@click.version_option(version=__version__, prog_name="hpk")
|
|
29
|
+
@click.pass_context
|
|
30
|
+
def main(ctx: click.Context) -> None:
|
|
31
|
+
"""hpk — interactive multi-profile setup for Hermes Agent."""
|
|
32
|
+
if ctx.invoked_subcommand is None:
|
|
33
|
+
ctx.invoke(setup)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command()
|
|
37
|
+
@click.argument("profile", nargs=-1)
|
|
38
|
+
@click.option("--force", is_flag=True, help="Overwrite SOUL.md/config.yaml even if present.")
|
|
39
|
+
@click.option("--skip-tokens", is_flag=True)
|
|
40
|
+
@click.option("--skip-plugins", is_flag=True)
|
|
41
|
+
def setup(
|
|
42
|
+
profile: tuple[str, ...],
|
|
43
|
+
force: bool,
|
|
44
|
+
skip_tokens: bool,
|
|
45
|
+
skip_plugins: bool,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Interactive multi-profile setup."""
|
|
48
|
+
manifest = _load()
|
|
49
|
+
try:
|
|
50
|
+
wizard.run_wizard(
|
|
51
|
+
manifest,
|
|
52
|
+
targets=list(profile),
|
|
53
|
+
force=force,
|
|
54
|
+
skip_tokens=skip_tokens,
|
|
55
|
+
skip_plugins=skip_plugins,
|
|
56
|
+
)
|
|
57
|
+
except wizard.HermesNotInstalledError as e:
|
|
58
|
+
ui.err(str(e))
|
|
59
|
+
sys.exit(10)
|
|
60
|
+
except wizard.HermesVersionTooOldError as e:
|
|
61
|
+
ui.err(str(e))
|
|
62
|
+
sys.exit(11)
|
|
63
|
+
except wizard.PreflightError as e:
|
|
64
|
+
ui.err(str(e))
|
|
65
|
+
sys.exit(30)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@main.command()
|
|
69
|
+
@click.argument("profile", nargs=-1)
|
|
70
|
+
def verify_cmd(profile: tuple[str, ...]) -> None:
|
|
71
|
+
"""Run hermes doctor + FILL_IN scan."""
|
|
72
|
+
manifest = _load()
|
|
73
|
+
names = list(profile) or [p.name for p in manifest.profiles]
|
|
74
|
+
r = verify.run_verify(names)
|
|
75
|
+
for name in r.passing:
|
|
76
|
+
ui.ok(f"{name}: doctor green")
|
|
77
|
+
for name, reason in r.failing:
|
|
78
|
+
ui.err(f"{name}: {reason}")
|
|
79
|
+
for name, rows in r.fill_in_remaining.items():
|
|
80
|
+
for line, key in rows:
|
|
81
|
+
ui.warn(f"{name}/.env:{line}: {key} still FILL_IN")
|
|
82
|
+
sys.exit(0 if r.ok else 30)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@main.command()
|
|
86
|
+
def doctor() -> None:
|
|
87
|
+
"""Check hpk's own health: hermes presence, manifest validity, codegen freshness."""
|
|
88
|
+
manifest = _load()
|
|
89
|
+
from hpk import hermes as _h
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
v = _h.get_version()
|
|
93
|
+
ui.ok(f"hermes {v}")
|
|
94
|
+
except _h.HermesNotFoundError:
|
|
95
|
+
ui.err("hermes not found")
|
|
96
|
+
sys.exit(10)
|
|
97
|
+
ui.ok(f"manifest valid; pinned to {manifest.upstream.pinned_commit}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@main.command()
|
|
101
|
+
@click.argument("profile", nargs=-1)
|
|
102
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation.")
|
|
103
|
+
@click.option("--backup", is_flag=True, help="Export profile before deleting.")
|
|
104
|
+
def reset(profile: tuple[str, ...], yes: bool, backup: bool) -> None:
|
|
105
|
+
"""Remove profiles created by this kit (never touches default ~/.hermes/)."""
|
|
106
|
+
manifest = _load()
|
|
107
|
+
names = list(profile) or [p.name for p in manifest.profiles]
|
|
108
|
+
if not yes:
|
|
109
|
+
click.confirm(f"Really delete profiles: {', '.join(names)}?", abort=True)
|
|
110
|
+
from hpk import hermes as _h
|
|
111
|
+
|
|
112
|
+
for n in names:
|
|
113
|
+
if backup:
|
|
114
|
+
_h.run_raw(["hermes", "profile", "export", n])
|
|
115
|
+
_h.run_raw(["hermes", "profile", "delete", n, "--yes"])
|
|
116
|
+
ui.ok(f"deleted {n}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@main.group()
|
|
120
|
+
def plugin() -> None:
|
|
121
|
+
"""List, enable, or disable manifest-declared recommended plugins."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@plugin.command("list")
|
|
125
|
+
def plugin_list() -> None:
|
|
126
|
+
manifest = _load()
|
|
127
|
+
for p in manifest.profiles:
|
|
128
|
+
ids = [rp.id for rp in p.recommended_plugins]
|
|
129
|
+
ui.console.print(f" {p.name}: {ids or '(none)'}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@plugin.command("enable")
|
|
133
|
+
@click.argument("profile")
|
|
134
|
+
@click.argument("plugin_id")
|
|
135
|
+
def plugin_enable(profile: str, plugin_id: str) -> None:
|
|
136
|
+
manifest = _load()
|
|
137
|
+
plugins_catalog = manifest.plugins
|
|
138
|
+
if plugin_id not in plugins_catalog:
|
|
139
|
+
ui.err(f"unknown plugin: {plugin_id}")
|
|
140
|
+
sys.exit(40)
|
|
141
|
+
from hpk import plugins as _p
|
|
142
|
+
|
|
143
|
+
_p.run_plugin(plugins_catalog[plugin_id], profile=profile)
|
|
144
|
+
ui.ok(f"enabled {plugin_id} for {profile}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@plugin.command("disable")
|
|
148
|
+
@click.argument("profile")
|
|
149
|
+
@click.argument("plugin_id")
|
|
150
|
+
def plugin_disable(profile: str, plugin_id: str) -> None:
|
|
151
|
+
ui.warn(f"manual disable required for {plugin_id} on {profile}; see plugin docs")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@main.command()
|
|
155
|
+
@click.option(
|
|
156
|
+
"--upstream",
|
|
157
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
158
|
+
help="Path to a local hermes-agent clone. Without it, sync prints guidance and exits.",
|
|
159
|
+
)
|
|
160
|
+
@click.option("--dry-run", is_flag=True, help="Run --check mode (no writes).")
|
|
161
|
+
def sync(upstream: Path | None, dry_run: bool) -> None:
|
|
162
|
+
"""Local upstream-drift check (CI does it daily). Requires an upstream clone."""
|
|
163
|
+
import subprocess as _sp
|
|
164
|
+
|
|
165
|
+
if upstream is None:
|
|
166
|
+
ui.warn("hpk sync needs --upstream PATH (a local hermes-agent clone).")
|
|
167
|
+
ui.warn("CI's daily upstream-sync workflow does this automatically.")
|
|
168
|
+
sys.exit(0)
|
|
169
|
+
cmd = [sys.executable, "scripts/regen_docs.py", "--upstream", str(upstream)]
|
|
170
|
+
if dry_run:
|
|
171
|
+
cmd.append("--check")
|
|
172
|
+
r = _sp.run(cmd)
|
|
173
|
+
sys.exit(50 if r.returncode else 0)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
main.add_command(verify_cmd, name="verify")
|
hpk/codegen/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""AST-walk a Python source string/file containing an argparse-based CLI and
|
|
2
|
+
produce a stable flat list of {path, params, help, hidden} nodes per (sub)command.
|
|
3
|
+
|
|
4
|
+
Used at CI time only. Handles the upstream hermes_cli/main.py shape:
|
|
5
|
+
- argparse.ArgumentParser() at module/function scope
|
|
6
|
+
- nested subparsers via parser.add_subparsers() then xxx.add_parser("name", ...)
|
|
7
|
+
- params via parser.add_argument("--flag", ...) or parser.add_argument("pos", ...)
|
|
8
|
+
- help=argparse.SUPPRESS marks command or argument as hidden
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_suppress(node: ast.expr) -> bool:
|
|
18
|
+
return (
|
|
19
|
+
isinstance(node, ast.Attribute)
|
|
20
|
+
and isinstance(node.value, ast.Name)
|
|
21
|
+
and node.value.id == "argparse"
|
|
22
|
+
and node.attr == "SUPPRESS"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _str_kwarg(call: ast.Call, name: str) -> str | None:
|
|
27
|
+
for kw in call.keywords:
|
|
28
|
+
if (
|
|
29
|
+
kw.arg == name
|
|
30
|
+
and isinstance(kw.value, ast.Constant)
|
|
31
|
+
and isinstance(kw.value.value, str)
|
|
32
|
+
):
|
|
33
|
+
return kw.value.value
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bool_kwarg(call: ast.Call, name: str) -> bool:
|
|
38
|
+
for kw in call.keywords:
|
|
39
|
+
if kw.arg == name and isinstance(kw.value, ast.Constant):
|
|
40
|
+
return bool(kw.value.value)
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _str_action(call: ast.Call) -> str | None:
|
|
45
|
+
for kw in call.keywords:
|
|
46
|
+
if (
|
|
47
|
+
kw.arg == "action"
|
|
48
|
+
and isinstance(kw.value, ast.Constant)
|
|
49
|
+
and isinstance(kw.value.value, str)
|
|
50
|
+
):
|
|
51
|
+
return kw.value.value
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _has_suppress_help(call: ast.Call) -> bool:
|
|
56
|
+
for kw in call.keywords:
|
|
57
|
+
if kw.arg == "help" and _is_suppress(kw.value):
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _param_from_add_argument(call: ast.Call) -> dict[str, Any] | None:
|
|
63
|
+
"""Build a param dict from a parser.add_argument(...) call. Returns None if unparseable."""
|
|
64
|
+
if not call.args or not isinstance(call.args[0], ast.Constant):
|
|
65
|
+
return None
|
|
66
|
+
first = call.args[0].value
|
|
67
|
+
if not isinstance(first, str):
|
|
68
|
+
return None
|
|
69
|
+
is_option = first.startswith("-")
|
|
70
|
+
name = first.lstrip("-").replace("-", "_")
|
|
71
|
+
opts: list[str] = []
|
|
72
|
+
for a in call.args:
|
|
73
|
+
if isinstance(a, ast.Constant) and isinstance(a.value, str):
|
|
74
|
+
opts.append(a.value)
|
|
75
|
+
action = _str_action(call)
|
|
76
|
+
return {
|
|
77
|
+
"name": name,
|
|
78
|
+
"opts": opts,
|
|
79
|
+
"is_flag": is_option and action in ("store_true", "store_false"),
|
|
80
|
+
"required": _bool_kwarg(call, "required"),
|
|
81
|
+
"type": "STRING",
|
|
82
|
+
"help": None if _has_suppress_help(call) else _str_kwarg(call, "help"),
|
|
83
|
+
"hidden": _has_suppress_help(call),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def walk_argparse(source: str) -> list[dict[str, Any]]:
|
|
88
|
+
"""Return a flat list of {path, params, help, hidden} for every leaf subparser.
|
|
89
|
+
|
|
90
|
+
The root ArgumentParser is not emitted; only its (recursively) descendant
|
|
91
|
+
subparsers register as nodes.
|
|
92
|
+
"""
|
|
93
|
+
tree = ast.parse(source)
|
|
94
|
+
|
|
95
|
+
parser_path: dict[str, str] = {} # parser-var → its path ("" for root)
|
|
96
|
+
subparsers_parent: dict[str, str] = {} # subparsers-var → parent parser's path
|
|
97
|
+
nodes: dict[str, dict[str, Any]] = {} # parser-var → node dict
|
|
98
|
+
order: list[str] = [] # discovery order of subparser-vars
|
|
99
|
+
|
|
100
|
+
# Pass 1: discover parser/subparsers/sub-parser variables and collect nodes.
|
|
101
|
+
for n in ast.walk(tree):
|
|
102
|
+
if not isinstance(n, ast.Assign) or len(n.targets) != 1:
|
|
103
|
+
continue
|
|
104
|
+
target = n.targets[0]
|
|
105
|
+
if not isinstance(target, ast.Name):
|
|
106
|
+
continue
|
|
107
|
+
var = target.id
|
|
108
|
+
val = n.value
|
|
109
|
+
if not isinstance(val, ast.Call) or not isinstance(val.func, ast.Attribute):
|
|
110
|
+
continue
|
|
111
|
+
func = val.func
|
|
112
|
+
if not isinstance(func.value, ast.Name):
|
|
113
|
+
continue
|
|
114
|
+
attr = func.attr
|
|
115
|
+
recv = func.value.id
|
|
116
|
+
|
|
117
|
+
if attr == "ArgumentParser" and recv == "argparse":
|
|
118
|
+
parser_path[var] = ""
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if attr == "add_subparsers" and recv in parser_path:
|
|
122
|
+
subparsers_parent[var] = parser_path[recv]
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
if attr == "add_parser" and recv in subparsers_parent:
|
|
126
|
+
if not val.args or not isinstance(val.args[0], ast.Constant):
|
|
127
|
+
continue
|
|
128
|
+
cmd = val.args[0].value
|
|
129
|
+
if not isinstance(cmd, str):
|
|
130
|
+
continue
|
|
131
|
+
parent = subparsers_parent[recv]
|
|
132
|
+
path = f"{parent} {cmd}".strip()
|
|
133
|
+
parser_path[var] = path
|
|
134
|
+
nodes[var] = {
|
|
135
|
+
"path": path,
|
|
136
|
+
"params": [],
|
|
137
|
+
"help": None if _has_suppress_help(val) else _str_kwarg(val, "help"),
|
|
138
|
+
"hidden": _has_suppress_help(val),
|
|
139
|
+
}
|
|
140
|
+
order.append(var)
|
|
141
|
+
|
|
142
|
+
# Pass 2: attach add_argument calls to their parser nodes.
|
|
143
|
+
for n in ast.walk(tree):
|
|
144
|
+
if not isinstance(n, ast.Expr) or not isinstance(n.value, ast.Call):
|
|
145
|
+
continue
|
|
146
|
+
call = n.value
|
|
147
|
+
if not isinstance(call.func, ast.Attribute) or call.func.attr != "add_argument":
|
|
148
|
+
continue
|
|
149
|
+
if not isinstance(call.func.value, ast.Name):
|
|
150
|
+
continue
|
|
151
|
+
recv = call.func.value.id
|
|
152
|
+
if recv not in nodes:
|
|
153
|
+
continue
|
|
154
|
+
param = _param_from_add_argument(call)
|
|
155
|
+
if param is not None:
|
|
156
|
+
nodes[recv]["params"].append(param)
|
|
157
|
+
|
|
158
|
+
return [nodes[v] for v in order]
|
hpk/codegen/cmd_index.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def dump(nodes: list[dict[str, Any]], path: Path) -> None:
|
|
7
|
+
sorted_nodes = sorted(nodes, key=lambda n: n["path"])
|
|
8
|
+
path.write_text(json.dumps(sorted_nodes, indent=2, sort_keys=True) + "\n")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load(path: Path) -> list[dict[str, Any]]:
|
|
12
|
+
return cast(list[dict[str, Any]], json.loads(path.read_text()))
|
hpk/codegen/validate.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from hpk.manifest import Plugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _normalize_cmd(cmd: str) -> str:
|
|
9
|
+
"""Strip 'hermes ' prefix and '{profile}' substitution markers for comparison."""
|
|
10
|
+
parts = [p for p in cmd.split() if p not in ("hermes", "{profile}")]
|
|
11
|
+
return " ".join(parts)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_missing_commands(plugins: dict[str, Plugin], cmd_index: list[dict[str, Any]]) -> list[str]:
|
|
15
|
+
"""Return plugin ids whose upstream_command is not present in cmd_index."""
|
|
16
|
+
paths = {n["path"] for n in cmd_index}
|
|
17
|
+
missing: list[str] = []
|
|
18
|
+
for pid, plugin in plugins.items():
|
|
19
|
+
if plugin.upstream_command is None:
|
|
20
|
+
# Kit-local plugins (install_path) have nothing to validate against upstream.
|
|
21
|
+
continue
|
|
22
|
+
normalized = _normalize_cmd(plugin.upstream_command)
|
|
23
|
+
if not any(p == normalized or normalized.endswith(f" {p}") for p in paths):
|
|
24
|
+
missing.append(pid)
|
|
25
|
+
return missing
|
hpk/hermes.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Subprocess wrapper around the installed `hermes` binary.
|
|
2
|
+
|
|
3
|
+
Never imports hermes internals. Every interaction goes through subprocess so
|
|
4
|
+
the kit stays decoupled from upstream API changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HermesNotFoundError(RuntimeError):
|
|
16
|
+
"""`hermes` binary is not on PATH."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HermesVersionError(RuntimeError):
|
|
20
|
+
"""Installed hermes does not meet a required version constraint."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run(cmd: Sequence[str], *, check: bool = False) -> subprocess.CompletedProcess[str]:
|
|
24
|
+
if shutil.which("hermes") is None:
|
|
25
|
+
raise HermesNotFoundError("hermes binary not found on PATH")
|
|
26
|
+
return subprocess.run(list(cmd), capture_output=True, text=True, check=check)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_VERSION_RE = re.compile(r"Hermes Agent v(\d+\.\d+\.\d+)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_version() -> str:
|
|
33
|
+
"""Return the installed Hermes version as `X.Y.Z`."""
|
|
34
|
+
r = _run(["hermes", "--version"])
|
|
35
|
+
m = _VERSION_RE.search(r.stdout)
|
|
36
|
+
if not m:
|
|
37
|
+
raise HermesVersionError(f"unparseable version output: {r.stdout!r}")
|
|
38
|
+
return m.group(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def profile_exists(name: str) -> bool:
|
|
42
|
+
r = _run(["hermes", "profile", "show", name])
|
|
43
|
+
return r.returncode == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_profile_create(name: str) -> subprocess.CompletedProcess[str]:
|
|
47
|
+
return _run(["hermes", "profile", "create", name], check=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_doctor(profile: str | None = None) -> subprocess.CompletedProcess[str]:
|
|
51
|
+
cmd: list[str] = ["hermes"]
|
|
52
|
+
if profile is not None:
|
|
53
|
+
cmd += ["-p", profile]
|
|
54
|
+
cmd += ["doctor"]
|
|
55
|
+
return _run(cmd)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_raw(cmd: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
59
|
+
"""Escape hatch for plugins.py to invoke arbitrary verified hermes commands."""
|
|
60
|
+
return _run(cmd)
|