kd-cli 2.11.0__tar.gz → 2.11.1__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 (88) hide show
  1. {kd_cli-2.11.0/kd_cli.egg-info → kd_cli-2.11.1}/PKG-INFO +1 -1
  2. kd_cli-2.11.1/kd/_option_chain.py +414 -0
  3. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/auth.py +1 -0
  4. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cache.py +326 -376
  5. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/command_base.py +1 -1
  6. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/namespaces.py +9 -0
  7. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/input.py +17 -21
  8. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/main.py +246 -192
  9. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/article/__init__.py +3 -14
  10. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/cache/__init__.py +0 -2
  11. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/issue/__init__.py +8 -66
  12. kd_cli-2.11.1/kd/cli/namespaces/primer/__init__.py +979 -0
  13. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/query/__init__.py +5 -16
  14. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/system/__init__.py +136 -162
  15. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/tag/__init__.py +14 -30
  16. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/output.py +4 -3
  17. kd_cli-2.11.1/kd/cli/rows.py +78 -0
  18. kd_cli-2.11.1/kd/cli/tree.py +24 -0
  19. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/config.py +19 -415
  20. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/config_schema.py +1 -1
  21. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/debug.py +1 -1
  22. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/exceptions.py +1 -1
  23. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/history.py +3 -6
  24. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/locking.py +14 -11
  25. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/primer.py +83 -115
  26. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/CHANGELOG.md +10 -0
  27. {kd_cli-2.11.0 → kd_cli-2.11.1/kd_cli.egg-info}/PKG-INFO +1 -1
  28. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/SOURCES.txt +2 -0
  29. {kd_cli-2.11.0 → kd_cli-2.11.1}/pyproject.toml +7 -1
  30. kd_cli-2.11.0/kd/cli/namespaces/primer/__init__.py +0 -1058
  31. kd_cli-2.11.0/kd/cli/tree.py +0 -65
  32. {kd_cli-2.11.0 → kd_cli-2.11.1}/LICENSE +0 -0
  33. {kd_cli-2.11.0 → kd_cli-2.11.1}/MANIFEST.in +0 -0
  34. {kd_cli-2.11.0 → kd_cli-2.11.1}/README.md +0 -0
  35. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/__init__.py +0 -0
  36. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/__init__.py +0 -0
  37. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/articles.py +0 -0
  38. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/http.py +0 -0
  39. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/hub.py +0 -0
  40. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/saved_queries.py +0 -0
  41. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/tags.py +0 -0
  42. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/api/youtrack.py +0 -0
  43. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/article_marker.py +0 -0
  44. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/__init__.py +0 -0
  45. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/__init__.py +0 -0
  46. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/command_parser.py +0 -0
  47. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/confirm.py +0 -0
  48. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/core/option_registry.py +0 -0
  49. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/__init__.py +0 -0
  50. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/alias/__init__.py +0 -0
  51. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/init.py +0 -0
  52. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/project/__init__.py +0 -0
  53. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/release_notes.py +0 -0
  54. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/search/__init__.py +0 -0
  55. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/time/__init__.py +0 -0
  56. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/cli/namespaces/user/__init__.py +0 -0
  57. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/client.py +0 -0
  58. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/config_read.py +0 -0
  59. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/connection.py +0 -0
  60. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/__init__.py +0 -0
  61. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/article.py +0 -0
  62. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/assist.py +0 -0
  63. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/attachment.py +0 -0
  64. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/comment.py +0 -0
  65. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/common.py +0 -0
  66. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/issue.py +0 -0
  67. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/project.py +0 -0
  68. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/saved_query.py +0 -0
  69. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/search.py +0 -0
  70. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/tag.py +0 -0
  71. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/tag_entities.py +0 -0
  72. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/user.py +0 -0
  73. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/models/work_item.py +0 -0
  74. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/operating_guide.py +0 -0
  75. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/query_parser.py +0 -0
  76. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/release_notes.py +0 -0
  77. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/__init__.py +0 -0
  78. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/command-synonyms.yaml +0 -0
  79. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/config-template.yaml +0 -0
  80. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/error-messages.yaml +0 -0
  81. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/operating-guide.md +0 -0
  82. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/primers/__init__.py +0 -0
  83. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd/resources/primers/hitl.yaml +0 -0
  84. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/dependency_links.txt +0 -0
  85. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/entry_points.txt +0 -0
  86. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/requires.txt +0 -0
  87. {kd_cli-2.11.0 → kd_cli-2.11.1}/kd_cli.egg-info/top_level.txt +0 -0
  88. {kd_cli-2.11.0 → kd_cli-2.11.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.11.0
3
+ Version: 2.11.1
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -0,0 +1,414 @@
1
+ """The 10-layer command-option resolution chain and shadow detection.
2
+
3
+ The layers are listed on :func:`resolve_command_defaults`. :mod:`kd.config`
4
+ re-exports the public names and is imported here only at call time, so either
5
+ module may be imported first.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from kd.debug import DebugLog
15
+
16
+ if TYPE_CHECKING:
17
+ from kd.cli.core.option_registry import OptionDeclaration
18
+ from kd.config import ResolvedConfig
19
+
20
+
21
+ # Keys that exist as both flat config keys and command/output options.
22
+ # These participate in levels 5/7 of the command option resolution chain.
23
+ _FLAT_OPTION_KEYS = {"limit", "output"}
24
+
25
+
26
+ def resolve_command_defaults(
27
+ command_key: str,
28
+ project: str | None,
29
+ raw_options: dict[str, Any],
30
+ config: ResolvedConfig,
31
+ declarations: list[OptionDeclaration],
32
+ *,
33
+ debug_log: DebugLog | None = None,
34
+ ) -> dict[str, Any]:
35
+ """Resolve command option defaults from the config hierarchy.
36
+
37
+ Returns a dict of option values for options **not** present in
38
+ *raw_options* (CLI flags always win — level 1).
39
+
40
+ Resolution per option (first non-None wins):
41
+
42
+ 1. CLI flag — skipped (present in *raw_options*)
43
+ 2. Env var — N/A for command options
44
+ 3. Project command default — ``projects.<proj>.defaults.<cmd>.<opt>``
45
+ 4. Local command default — ``defaults.<cmd>.<opt>`` in local config
46
+ 5. Local flat key — for ``limit``/``output`` only
47
+ 6. Connection command default — ``connections.<name>.defaults.<cmd>.<opt>``
48
+ 7. Connection flat key — for ``limit``/``output`` only
49
+ 8. Global command default — ``defaults.<cmd>.<opt>`` in global config
50
+ 9. Global flat key — for ``limit``/``output`` only
51
+ 10. Built-in default — from ``OptionDeclaration.default``
52
+ """
53
+ d = debug_log or DebugLog()
54
+ resolved: dict[str, Any] = {}
55
+
56
+ for decl in declarations:
57
+ opt = decl.name
58
+
59
+ # Level 1: CLI flag already set — skip
60
+ if opt in raw_options:
61
+ continue
62
+
63
+ value = _resolve_single_option(opt, command_key, project, config, d)
64
+ if value is not None:
65
+ resolved[opt] = value
66
+
67
+ return resolved
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class LayerState:
72
+ """State of a single layer in the 10-layer command-option resolution chain.
73
+
74
+ ``value`` is ``None`` when the layer does not contribute — either because
75
+ the option is not configured there, or because the layer never applies to
76
+ this option class (e.g. flat layers 5/7/9 for non-flat options). ``status``
77
+ disambiguates those cases and marks which layer wins vs. is shadowed.
78
+ """
79
+
80
+ layer: int
81
+ source: str
82
+ origin: str | None
83
+ value: Any
84
+ status: str # "winning" | "shadowed" | "not set" | "n/a" | "invalid"
85
+
86
+
87
+ def walk_option_layers(
88
+ opt: str,
89
+ command_key: str,
90
+ project: str | None,
91
+ config: ResolvedConfig,
92
+ *,
93
+ cli_value: Any = None,
94
+ builtin_default: Any = None,
95
+ local_config_path: str | None = None,
96
+ global_config_path: str | None = None,
97
+ ) -> list[LayerState]:
98
+ """Walk the 10-layer command-option resolution chain for *opt*.
99
+
100
+ Returns one :class:`LayerState` per layer in priority order (1..10). The
101
+ first layer with a non-``None`` value has ``status="winning"``; subsequent
102
+ non-``None`` layers have ``status="shadowed"``. Layers that never apply
103
+ (flat layers 5/7/9 for options outside ``_FLAT_OPTION_KEYS``, layer 2 for
104
+ command options, layer 6/7 when no active connection) have ``status="n/a"``.
105
+ Populated-eligible layers with no value are ``"not set"``.
106
+ """
107
+ from kd import config as kd_config # at call time: tests patch these names there
108
+
109
+ if local_config_path is None:
110
+ _lp = kd_config.find_local_config()
111
+ local_config_path = str(_lp) if _lp else None
112
+ if global_config_path is None:
113
+ global_config_path = str(kd_config.DEFAULT_CONFIG_PATH)
114
+
115
+ conn_name = config.default_connection_name
116
+ conn_origin = config.connection_origins.get(conn_name) if conn_name else None
117
+ conn_invalid = config.invalid_connections.get(conn_name) if conn_name else None
118
+ is_flat = opt in _FLAT_OPTION_KEYS
119
+
120
+ def _conn_key_origin(key: str) -> str | None:
121
+ if conn_origin is None:
122
+ return None
123
+ ko = conn_origin.keys.get(key)
124
+ return ko.path if ko is not None else conn_origin.path
125
+
126
+ def _conn_default_origin() -> str | None:
127
+ """Layer 6's file: the one that sets this command's option, per leaf.
128
+
129
+ A local key replaces the global one in the connection merge even
130
+ when it is empty, so the first file that has the key is the origin.
131
+ """
132
+ if conn_name is not None:
133
+ leaf = ["connections", conn_name, "defaults", command_key, opt]
134
+ for tier in kd_config.config_tier_values(config, leaf):
135
+ if tier.present:
136
+ return str(tier.path) if tier.path is not None else None
137
+ return _conn_key_origin("defaults")
138
+
139
+ # (layer, source, origin, value, applicable) in priority order.
140
+ raw: list[tuple[int, str, str | None, Any, bool]] = []
141
+
142
+ # Layer 1 — CLI flag
143
+ raw.append((1, f"cli.{opt}", None, cli_value, True))
144
+
145
+ # Layer 2 — env var (always n/a for command options)
146
+ raw.append((2, "env", None, None, False))
147
+
148
+ # Layer 3 — project command default (local-first then global, same layer)
149
+ proj_val: Any = None
150
+ proj_source = f"project.defaults.{command_key}"
151
+ proj_origin: str | None = None
152
+ if project:
153
+ v = _project_command_default(project, command_key, opt, config.local_projects)
154
+ if v is not None:
155
+ proj_val = v
156
+ proj_source = f"project({project}).local.{command_key}"
157
+ proj_origin = local_config_path
158
+ else:
159
+ v = _project_command_default(project, command_key, opt, config.global_projects)
160
+ if v is not None:
161
+ proj_val = v
162
+ proj_source = f"project({project}).global.{command_key}"
163
+ proj_origin = global_config_path
164
+ raw.append((3, proj_source, proj_origin, proj_val, project is not None))
165
+
166
+ # Layer 4 — local command default
167
+ raw.append(
168
+ (
169
+ 4,
170
+ f"local.defaults.{command_key}",
171
+ local_config_path,
172
+ config.local_defaults.get(command_key, {}).get(opt),
173
+ True,
174
+ )
175
+ )
176
+
177
+ # Layer 5 — local flat key (limit/output only)
178
+ raw.append(
179
+ (
180
+ 5,
181
+ "local.flat",
182
+ local_config_path,
183
+ config.local_flat.get(opt) if is_flat else None,
184
+ is_flat,
185
+ )
186
+ )
187
+
188
+ # Layer 6 — connection command default
189
+ raw.append(
190
+ (
191
+ 6,
192
+ f"connection.defaults.{command_key}",
193
+ _conn_default_origin(),
194
+ config.connection_defaults.get(command_key, {}).get(opt),
195
+ conn_name is not None,
196
+ )
197
+ )
198
+
199
+ # Layer 7 — connection flat key (limit/output only)
200
+ raw.append(
201
+ (
202
+ 7,
203
+ "connection.flat",
204
+ _conn_key_origin(opt),
205
+ config.connection_flat.get(opt) if is_flat else None,
206
+ is_flat and conn_name is not None,
207
+ )
208
+ )
209
+
210
+ # Layer 8 — global command default
211
+ raw.append(
212
+ (
213
+ 8,
214
+ f"global.defaults.{command_key}",
215
+ global_config_path,
216
+ config.global_defaults.get(command_key, {}).get(opt),
217
+ True,
218
+ )
219
+ )
220
+
221
+ # Layer 9 — global flat key (limit/output only)
222
+ raw.append(
223
+ (
224
+ 9,
225
+ "global.flat",
226
+ global_config_path,
227
+ config.global_flat.get(opt) if is_flat else None,
228
+ is_flat,
229
+ )
230
+ )
231
+
232
+ # Layer 10 — built-in default
233
+ raw.append((10, "built-in", None, builtin_default, builtin_default is not None))
234
+
235
+ # Stamp statuses: first non-None wins, later non-Nones are shadowed. An
236
+ # invalid active connection contributes nothing to layers 6 and 7, and
237
+ # says so instead of reading "not set" (KDCLI-321).
238
+ winner_found = False
239
+ out: list[LayerState] = []
240
+ for layer, source, origin, value, applicable in raw:
241
+ if conn_invalid is not None and layer in (6, 7) and applicable:
242
+ out.append(
243
+ LayerState(
244
+ layer=layer,
245
+ source=source,
246
+ origin=conn_invalid.path,
247
+ value=None,
248
+ status="invalid",
249
+ )
250
+ )
251
+ continue
252
+ if value is None:
253
+ status = "not set" if applicable else "n/a"
254
+ elif not winner_found:
255
+ status = "winning"
256
+ winner_found = True
257
+ else:
258
+ status = "shadowed"
259
+ out.append(
260
+ LayerState(layer=layer, source=source, origin=origin, value=value, status=status)
261
+ )
262
+ return out
263
+
264
+
265
+ def _resolve_single_option(
266
+ opt: str,
267
+ command_key: str,
268
+ project: str | None,
269
+ config: ResolvedConfig,
270
+ d: DebugLog,
271
+ ) -> Any:
272
+ """Walk levels 3–9 for a single option. Returns None if no config value found."""
273
+ for state in walk_option_layers(opt, command_key, project, config):
274
+ if state.status == "winning":
275
+ d.debug("config", f"option {opt}: {state.source} -> {state.value}")
276
+ return state.value
277
+ return None
278
+
279
+
280
+ def _project_command_default(
281
+ project: str,
282
+ command_key: str,
283
+ opt: str,
284
+ projects_raw: dict[str, Any],
285
+ ) -> Any:
286
+ """Look up a project command default from a raw projects dict."""
287
+ proj_data = projects_raw.get(project)
288
+ if not isinstance(proj_data, dict):
289
+ return None
290
+ defaults = proj_data.get("defaults")
291
+ if not isinstance(defaults, dict):
292
+ return None
293
+ cmd_defaults = defaults.get(command_key)
294
+ if not isinstance(cmd_defaults, dict):
295
+ return None
296
+ return cmd_defaults.get(opt)
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Shadow detection — post-write check against the 10-layer chain
301
+ # ---------------------------------------------------------------------------
302
+
303
+
304
+ @dataclass(frozen=True)
305
+ class ShadowWarning:
306
+ """Result of :func:`detect_shadow` — a written value is shadowed.
307
+
308
+ ``written`` names the layer and source the write landed at; ``winning``
309
+ names the higher-priority layer whose value continues to resolve.
310
+ ``option`` is ``"<command_key>.<option>"``. ``hint_path`` suggests a
311
+ dotted path that would actually override the winning layer, or None
312
+ when no such path exists below the winning tier.
313
+ """
314
+
315
+ written: dict[str, Any]
316
+ winning: dict[str, Any]
317
+ option: str
318
+ hint_path: str | None
319
+
320
+
321
+ def detect_shadow(
322
+ path: str,
323
+ tier: str,
324
+ rc: ResolvedConfig,
325
+ *,
326
+ default_lookup: Callable[[str, str], Any] | None = None,
327
+ ) -> ShadowWarning | None:
328
+ """Return a :class:`ShadowWarning` when *path* (just written in *tier*)
329
+ is shadowed by a higher-priority layer in *rc*, else ``None``.
330
+
331
+ Returns ``None`` when the path is not part of the command-option chain,
332
+ when the write landed at the winning layer, or when the post-write walker
333
+ reports no higher-priority layer with a value. Paths that don't map to a
334
+ specific ``<command_key>.<option>`` target — flat top-level writes and
335
+ connection-level flat writes — are cross-command by nature and are not
336
+ flagged here.
337
+
338
+ *rc* must be a freshly-resolved config (post-write); callers are
339
+ responsible for reloading. *default_lookup*, when provided, is called
340
+ as ``default_lookup(command_key, option_name)`` to obtain the layer-10
341
+ built-in default (typically the caller reaches into the CLI option
342
+ registry); ``None`` or a lookup returning ``None`` means layer 10 stays
343
+ unpopulated, which is fine for shadow comparison.
344
+ """
345
+ classified = _classify_set_path(path, tier, rc.default_connection_name)
346
+ if classified is None:
347
+ return None
348
+ written_layer, command_key, option_name = classified
349
+
350
+ builtin_default = default_lookup(command_key, option_name) if default_lookup else None
351
+
352
+ states = walk_option_layers(
353
+ option_name,
354
+ command_key,
355
+ rc.default_project,
356
+ rc,
357
+ builtin_default=builtin_default,
358
+ )
359
+ winner = next((s for s in states if s.status == "winning"), None)
360
+ if winner is None or winner.layer >= written_layer:
361
+ return None # write is active (equal) or nothing set (impossible post-write)
362
+
363
+ written = next((s for s in states if s.layer == written_layer), None)
364
+ hint_path = _shadow_hint_path(winner, command_key, option_name, rc)
365
+ return ShadowWarning(
366
+ written={
367
+ "layer": written_layer,
368
+ "value": written.value if written else None,
369
+ "source": written.source if written else "",
370
+ },
371
+ winning={
372
+ "layer": winner.layer,
373
+ "value": winner.value,
374
+ "source": winner.source,
375
+ "origin": winner.origin,
376
+ },
377
+ option=f"{command_key}.{option_name}",
378
+ hint_path=hint_path,
379
+ )
380
+
381
+
382
+ def _classify_set_path(
383
+ path: str, tier: str, active_connection: str | None
384
+ ) -> tuple[int, str, str] | None:
385
+ """Map a written config path to a chain layer + (command_key, option)."""
386
+ segs = path.split(".")
387
+ # defaults.<cmd>.<opt> — layer 4 (local) / 8 (global)
388
+ if len(segs) == 3 and segs[0] == "defaults":
389
+ layer = 4 if tier == "local" else 8
390
+ return layer, segs[1], segs[2]
391
+ # connections.<name>.defaults.<cmd>.<opt> — layer 6 when <name> is active
392
+ if (
393
+ len(segs) == 5
394
+ and segs[0] == "connections"
395
+ and segs[2] == "defaults"
396
+ and active_connection is not None
397
+ and segs[1] == active_connection
398
+ ):
399
+ return 6, segs[3], segs[4]
400
+ return None
401
+
402
+
403
+ def _shadow_hint_path(
404
+ winner: LayerState, command_key: str, option_name: str, rc: ResolvedConfig
405
+ ) -> str | None:
406
+ """Suggest a path that would actually override the winning layer, if any.
407
+
408
+ For a winning connection-flat value, suggest
409
+ ``connections.<name>.defaults.<cmd>.<opt>`` which sits at layer 6 —
410
+ one step above layer 7 and scoped per-command.
411
+ """
412
+ if winner.layer == 7 and rc.default_connection_name is not None:
413
+ return f"connections.{rc.default_connection_name}.defaults.{command_key}.{option_name}"
414
+ return None
@@ -85,6 +85,7 @@ def _run_token_cmd(cmd: str) -> str:
85
85
  capture_output=True,
86
86
  text=True,
87
87
  timeout=10,
88
+ check=False,
88
89
  )
89
90
  except subprocess.TimeoutExpired:
90
91
  raise AuthError(f"token_cmd timed out after 10s: {cmd}") from None