python-click-subcommand-option-wiring
Files: ~/gap014/hermes_h3_cli.py, ~/gap014/test_gap014.py
The bug (GAP-014): --config was declared only on the Click group, so hermes-h3 deploy --config x.toml was rejected as "no such option" — per-command config was undiscoverable and unusable.
The fix — two parts:
1. Shared _config_option decorator, applied right after @command. Click resolves decorators bottom-up, so the order is @command → @_config_option() → @click.pass_context, yielding callback signature fn(ctx, config) (ctx via pass_context, config as the option keyword):
def _config_option() -> Callable[..., Callable[..., Any]]:
"""Shared --config option for hermes-h3 subcommands.
Apply directly after @<group>.command() so it belongs to the subcommand."""
return click.option(
"--config",
type=click.Path(dir_okay=False, path_type=Path),
default=DEFAULT_CONFIG,
show_default=True,
help="Path to the Hermes H3 config file (overrides group --config).",
callback=_store_config_path,
)
@cli.command()
@_config_option() # <-- right after @command
@click.pass_context
def deploy(ctx: click.Context, config: Path) -> None:
"""Deploy a workload to the Hermes H3 mesh."""
cfg: Path = ctx.obj["config_path"] # single source of truth
click.echo(f"[deploy] config={cfg}")
click.echo(f"[deploy] endpoint={_read_endpoint(cfg)}")
2. The callback writes into ctx.obj["config_path"], overriding the group value. Click child contexts inherit the parent's ctx.obj (Context.__init__: if obj is None and parent is not None: obj = parent.obj), so the group and all subcommands share one dict; whoever parses last wins:
def _store_config_path(ctx, param, value):
"""Only persist an *explicitly typed* path. Click invokes option
callbacks even for default values (iter_params_for_processing processes
every declared param, handle_parse_result runs process_value on the
default too) — without this guard, a bare `h3 deploy` would fire the
callback with the default and clobber the group-level value."""
if (value is not None
and ctx.get_parameter_source(param.name)
is click.core.ParameterSource.COMMANDLINE):
ctx.ensure_object(dict)
ctx.obj["config_path"] = Path(value).expanduser()
return value
The group keeps its own --config (same callback) so both orderings work; its command callback seeds the fallback: ctx.obj.setdefault("config_path", DEFAULT_CONFIG).
Resulting semantics:
| Invocation | Resolved config |
|---|---|
h3 --config a.toml deploy |
a.toml (group order, preserved) |
h3 deploy --config b.toml |
b.toml (per-command order — the fix) |
h3 --config a.toml deploy --config b.toml |
b.toml (subcommand overrides group) |
h3 deploy |
~/.hermes/h3.toml (default) |
Verified both via `click.testing.CliRunner` (unit, in-process) and a **live subprocess** (`python3 hermes_h3_cli.py ...`) against two real custom config files whose `endpoint=` values were genuinely loaded — proving the file is used, not just echoed:
```
$ h3 --config a.toml deploy
[deploy] config=/tmp/.../a.toml
[deploy] endpoint=https://alpha.h3.internal
$ h3 deploy --config b.toml # GAP-014 fixed
[deploy] config=/tmp/.../b.toml
[deploy] endpoint=https://beta.h3.internal
$ h3 --config a.toml deploy --config b.toml
[deploy] config=/tmp/.../b.toml # B overrides A
[deploy] endpoint=https://beta.h3.internal
```
Discoverability confirmed in help output — `--config` now appears on the group **and** on every subcommand:
```
$ h3 deploy --help
Options:
--config FILE Path to the Hermes H3 config file (overrides group --config).
[default: ~/.hermes/h3.toml]
```
**Edge cases tested (21/21 pass):**
- Both orderings on `deploy`; `--config` on `inspect` and `ping` too (all commands share the decorator)
- Subcommand-overrides-group with both flags present; group value *not* clobbered when subcommand omits `--config` (the `ParameterSource.COMMANDLINE` guard — this was the subtle Click gotcha found during testing: default-valued callbacks fire even when the option isn't typed)
- No `--config` anywhere → default path + default endpoint
- Repeated per-command `--config` → last occurrence wins
- No subcommand → clean usage/help, no crash
- `~` expansion in per-command `--config`
- Live subprocess (real argv parsing) for all three core orderings
---{"model": "claude", "problem_class": "python-click-subcommand-option-wiring", "result": "passed", "tests": 21}Files: ~/gap014/hermes_h3_cli.py, ~/gap014/test_gap014.py
The bug (GAP-014): --config was declared only on the Click group, so hermes-h3 deploy --config x.toml was rejected as "no such option" — per-command config was undiscoverable and unusable.
The fix — two parts:
1. Shared _config_option decorator, applied right after @command. Click resolves decorators bottom-up, so the order is @command → @_config_option() → @click.pass_context, yielding callback signature fn(ctx, config) (ctx via pass_context, config as the option keyword):
def _config_option() -> Callable[..., Callable[..., Any]]:
"""Shared --config option for hermes-h3 subcommands.
Apply directly after @<group>.command() so it belongs to the subcommand."""
return click.option(
"--config",
type=click.Path(dir_okay=False, path_type=Path),
default=DEFAULT_CONFIG,
show_default=True,
help="Path to the Hermes H3 config file (overrides group --config).",
callback=_store_config_path,
)
@cli.command()
@_config_option() # <-- right after @command
@click.pass_context
def deploy(ctx: click.Context, config: Path) -> None:
"""Deploy a workload to the Hermes H3 mesh."""
cfg: Path = ctx.obj["config_path"] # single source of truth
click.echo(f"[deploy] config={cfg}")
click.echo(f"[deploy] endpoint={_read_endpoint(cfg)}")
2. The callback writes into ctx.obj["config_path"], overriding the group value. Click child contexts inherit the parent's ctx.obj (Context.__init__: if obj is None and parent is not None: obj = parent.obj), so the group and all subcommands share one dict; whoever parses last wins:
def _store_config_path(ctx, param, value):
"""Only persist an *explicitly typed* path. Click invokes option
callbacks even for default values (iter_params_for_processing processes
every declared param, handle_parse_result runs process_value on the
default too) — without this guard, a bare `h3 deploy` would fire the
callback with the default and clobber the group-level value."""
if (value is not None
and ctx.get_parameter_source(param.name)
is click.core.ParameterSource.COMMANDLINE):
ctx.ensure_object(dict)
ctx.obj["config_path"] = Path(value).expanduser()
return value
The group keeps its own --config (same callback) so both orderings work; its command callback seeds the fallback: ctx.obj.setdefault("config_path", DEFAULT_CONFIG).
Resulting semantics:
| Invocation | Resolved config |
|---|---|
h3 --config a.toml deploy |
a.toml (group order, preserved) |
h3 deploy --config b.toml |
b.toml (per-command order — the fix) |
h3 --config a.toml deploy --config b.toml |
b.toml (subcommand overrides group) |
h3 deploy |
~/.hermes/h3.toml (default) |
Verified both via `click.testing.CliRunner` (unit, in-process) and a **live subprocess** (`python3 hermes_h3_cli.py ...`) against two real custom config files whose `endpoint=` values were genuinely loaded — proving the file is used, not just echoed:
```
$ h3 --config a.toml deploy
[deploy] config=/tmp/.../a.toml
[deploy] endpoint=https://alpha.h3.internal
$ h3 deploy --config b.toml # GAP-014 fixed
[deploy] config=/tmp/.../b.toml
[deploy] endpoint=https://beta.h3.internal
$ h3 --config a.toml deploy --config b.toml
[deploy] config=/tmp/.../b.toml # B overrides A
[deploy] endpoint=https://beta.h3.internal
```
Discoverability confirmed in help output — `--config` now appears on the group **and** on every subcommand:
```
$ h3 deploy --help
Options:
--config FILE Path to the Hermes H3 config file (overrides group --config).
[default: ~/.hermes/h3.toml]
```
**Edge cases tested (21/21 pass):**
- Both orderings on `deploy`; `--config` on `inspect` and `ping` too (all commands share the decorator)
- Subcommand-overrides-group with both flags present; group value *not* clobbered when subcommand omits `--config` (the `ParameterSource.COMMANDLINE` guard — this was the subtle Click gotcha found during testing: default-valued callbacks fire even when the option isn't typed)
- No `--config` anywhere → default path + default endpoint
- Repeated per-command `--config` → last occurrence wins
- No subcommand → clean usage/help, no crash
- `~` expansion in per-command `--config`
- Live subprocess (real argv parsing) for all three core orderings
---{"model": "claude", "problem_class": "python-click-subcommand-option-wiring", "result": "passed", "tests": 21}