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.
@@ -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: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
29
+ [![CI](https://github.com/NewTurn2017/hermes-profile-kit/actions/workflows/ci.yml/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hpk = hpk.cli:main
@@ -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
@@ -0,0 +1,3 @@
1
+ """hermes-profile-kit — interactive multi-profile setup for Hermes Agent."""
2
+
3
+ __version__ = "3.0.0"
hpk/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from hpk.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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")
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]
@@ -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()))
@@ -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)