kd-cli 2.1.5__tar.gz → 2.1.7__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 (76) hide show
  1. {kd_cli-2.1.5/kd_cli.egg-info → kd_cli-2.1.7}/PKG-INFO +1 -1
  2. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/saved_queries.py +1 -2
  3. kd_cli-2.1.7/kd/auth.py +173 -0
  4. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/main.py +3 -2
  5. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/system/__init__.py +23 -327
  6. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/config.py +178 -6
  7. kd_cli-2.1.7/kd/config_read.py +194 -0
  8. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/connection.py +14 -1
  9. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/exceptions.py +12 -5
  10. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/saved_query.py +1 -1
  11. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/CHANGELOG.md +19 -0
  12. {kd_cli-2.1.5 → kd_cli-2.1.7/kd_cli.egg-info}/PKG-INFO +1 -1
  13. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/SOURCES.txt +1 -0
  14. {kd_cli-2.1.5 → kd_cli-2.1.7}/pyproject.toml +1 -1
  15. kd_cli-2.1.5/kd/auth.py +0 -96
  16. {kd_cli-2.1.5 → kd_cli-2.1.7}/LICENSE +0 -0
  17. {kd_cli-2.1.5 → kd_cli-2.1.7}/MANIFEST.in +0 -0
  18. {kd_cli-2.1.5 → kd_cli-2.1.7}/README.md +0 -0
  19. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/__init__.py +0 -0
  20. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/__init__.py +0 -0
  21. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/articles.py +0 -0
  22. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/http.py +0 -0
  23. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/hub.py +0 -0
  24. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/tags.py +0 -0
  25. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/api/youtrack.py +0 -0
  26. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/article_marker.py +0 -0
  27. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/__init__.py +0 -0
  28. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/__init__.py +0 -0
  29. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/command_base.py +0 -0
  30. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/command_parser.py +0 -0
  31. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/namespaces.py +0 -0
  32. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/core/option_registry.py +0 -0
  33. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/input.py +0 -0
  34. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/__init__.py +0 -0
  35. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/alias/__init__.py +0 -0
  36. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/article/__init__.py +0 -0
  37. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/init.py +0 -0
  38. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/issue/__init__.py +0 -0
  39. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/primer/__init__.py +0 -0
  40. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/project/__init__.py +0 -0
  41. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/query/__init__.py +0 -0
  42. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/release_notes.py +0 -0
  43. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/search/__init__.py +0 -0
  44. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/tag/__init__.py +0 -0
  45. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/time/__init__.py +0 -0
  46. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/namespaces/user/__init__.py +0 -0
  47. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/output.py +0 -0
  48. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/cli/tree.py +0 -0
  49. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/client.py +0 -0
  50. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/config_schema.py +0 -0
  51. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/debug.py +0 -0
  52. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/history.py +0 -0
  53. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/__init__.py +0 -0
  54. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/article.py +0 -0
  55. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/assist.py +0 -0
  56. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/attachment.py +0 -0
  57. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/comment.py +0 -0
  58. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/common.py +0 -0
  59. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/issue.py +0 -0
  60. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/project.py +0 -0
  61. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/search.py +0 -0
  62. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/tag.py +0 -0
  63. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/user.py +0 -0
  64. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/models/work_item.py +0 -0
  65. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/primer.py +0 -0
  66. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/query_parser.py +0 -0
  67. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/release_notes.py +0 -0
  68. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/__init__.py +0 -0
  69. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/command-synonyms.yaml +0 -0
  70. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/config-template.yaml +0 -0
  71. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd/resources/error-messages.yaml +0 -0
  72. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/dependency_links.txt +0 -0
  73. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/entry_points.txt +0 -0
  74. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/requires.txt +0 -0
  75. {kd_cli-2.1.5 → kd_cli-2.1.7}/kd_cli.egg-info/top_level.txt +0 -0
  76. {kd_cli-2.1.5 → kd_cli-2.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.1.5
3
+ Version: 2.1.7
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -65,8 +65,7 @@ class SavedQueriesAPI:
65
65
  """List saved queries owned by a specific user.
66
66
 
67
67
  The literal string ``"me"`` is accepted as ``user_id`` and resolves
68
- to the authenticated user server-side (verified against
69
- ``vod.youtrack.cloud``).
68
+ to the authenticated user server-side.
70
69
  """
71
70
  data = self._http.get(
72
71
  f"/users/{user_id}/savedQueries",
@@ -0,0 +1,173 @@
1
+ """Connection-aware token resolution.
2
+
3
+ Each connection resolves its token from one of two sources:
4
+
5
+ 1. ``token_cmd`` — shell command that prints the token to stdout.
6
+ 2. ``token_env`` — environment variable name containing the token.
7
+
8
+ Priority: ``token_cmd`` > ``token_env``. Secrets never live in config
9
+ files — only references to external sources.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+ from typing import TYPE_CHECKING
17
+
18
+ from kd.exceptions import AuthError
19
+
20
+ if TYPE_CHECKING:
21
+ from kd.config import ConnectionOrigin
22
+ from kd.connection import ConnectionConfig
23
+ from kd.debug import DebugLog
24
+
25
+
26
+ def resolve_connection_token(
27
+ conn: ConnectionConfig,
28
+ *,
29
+ name: str | None = None,
30
+ origin: ConnectionOrigin | None = None,
31
+ debug_log: DebugLog | None = None,
32
+ ) -> str:
33
+ """Resolve the API token for a connection.
34
+
35
+ Parameters
36
+ ----------
37
+ conn:
38
+ The connection config whose auth sources to try.
39
+ name:
40
+ Optional connection name, used in error messages to name the
41
+ failing connection.
42
+ origin:
43
+ Optional per-key origin map, used to cite the exact config file
44
+ that declared the (now-broken) auth key.
45
+ debug_log:
46
+ Optional debug logger.
47
+
48
+ Returns
49
+ -------
50
+ str
51
+ The resolved token.
52
+
53
+ Raises
54
+ ------
55
+ AuthError
56
+ If no token can be resolved from any source. When *name* and
57
+ *origin* are supplied, the error carries a multi-line
58
+ ``.suggestion`` with connection-specific guidance and a
59
+ ``Config: <path>`` pointer.
60
+ """
61
+ if conn.token_cmd:
62
+ if debug_log:
63
+ debug_log.info("auth", f"running token_cmd: {conn.token_cmd}")
64
+ return _run_token_cmd(conn.token_cmd)
65
+
66
+ if conn.token_env:
67
+ if debug_log:
68
+ debug_log.info("auth", f"reading token from env var: {conn.token_env}")
69
+ value = os.environ.get(conn.token_env)
70
+ if value:
71
+ return value
72
+ raise _missing_token_error(conn, name, origin)
73
+
74
+ raise _no_auth_source_error(conn, name, origin)
75
+
76
+
77
+ def _run_token_cmd(cmd: str) -> str:
78
+ """Execute a token command and return its stdout.
79
+
80
+ Raises :class:`AuthError` on non-zero exit or empty output.
81
+ """
82
+ try:
83
+ proc = subprocess.run(
84
+ cmd,
85
+ shell=True, # noqa: S602 — user's own config, same trust as git credential.helper
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=10,
89
+ )
90
+ except subprocess.TimeoutExpired:
91
+ raise AuthError(f"token_cmd timed out after 10s: {cmd}") from None
92
+
93
+ if proc.returncode != 0:
94
+ stderr = proc.stderr.strip()
95
+ detail = f": {stderr}" if stderr else ""
96
+ raise AuthError(f"token_cmd failed (exit {proc.returncode}){detail}: {cmd}")
97
+
98
+ token = proc.stdout.strip()
99
+ if not token:
100
+ raise AuthError(f"token_cmd returned empty output: {cmd}")
101
+
102
+ return token
103
+
104
+
105
+ def _missing_token_error(
106
+ conn: ConnectionConfig,
107
+ name: str | None,
108
+ origin: ConnectionOrigin | None,
109
+ ) -> AuthError:
110
+ """Build an ``AuthError`` for a ``token_env`` that points at an unset var."""
111
+ env_name = conn.token_env or "<token_env>"
112
+ where = _describe_connection(conn, name)
113
+ message = f"{where} needs a token — environment variable '{env_name}' is not set."
114
+ suggestion = _build_suggestion(
115
+ lines=[
116
+ f"Either export {env_name} or configure `token_cmd:` on the connection:",
117
+ f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
118
+ ],
119
+ origin=origin,
120
+ key="token_env",
121
+ )
122
+ return AuthError(message, suggestion=suggestion)
123
+
124
+
125
+ def _no_auth_source_error(
126
+ conn: ConnectionConfig,
127
+ name: str | None,
128
+ origin: ConnectionOrigin | None,
129
+ ) -> AuthError:
130
+ """Build an ``AuthError`` for a connection with no ``token_cmd`` or ``token_env``."""
131
+ where = _describe_connection(conn, name)
132
+ message = f"{where} has no auth source configured."
133
+ suggestion = _build_suggestion(
134
+ lines=[
135
+ "Set one of `token_cmd:` or `token_env:` on the connection:",
136
+ f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
137
+ f" connections.{name or '<name>'}.token_env: KD_{(name or 'NAME').upper()}_TOKEN",
138
+ ],
139
+ origin=origin,
140
+ key=None,
141
+ )
142
+ return AuthError(message, suggestion=suggestion)
143
+
144
+
145
+ def _describe_connection(conn: ConnectionConfig, name: str | None) -> str:
146
+ """Render the ``Connection '<name>' (<url>)`` prefix, degraded when no name."""
147
+ if name:
148
+ return f"Connection '{name}' ({conn.url})"
149
+ return f"Connection ({conn.url})"
150
+
151
+
152
+ def _build_suggestion(
153
+ *,
154
+ lines: list[str],
155
+ origin: ConnectionOrigin | None,
156
+ key: str | None,
157
+ ) -> str:
158
+ """Assemble a multi-line suggestion, appending ``Config: <path>`` when known.
159
+
160
+ When *origin* is provided, prefer the per-key origin for *key* (which
161
+ points at the exact file declaring that key); fall back to the
162
+ connection's primary path. Omit the line entirely when no origin is
163
+ known so tests and ad-hoc callers don't see a dangling ``Config:``.
164
+ """
165
+ path: str | None = None
166
+ if origin is not None:
167
+ if key and key in origin.keys:
168
+ path = origin.keys[key].path
169
+ else:
170
+ path = origin.path
171
+ if path:
172
+ lines = [*lines, f"Config: {path}"]
173
+ return "\n".join(lines)
@@ -29,7 +29,7 @@ from kd.client import YouTrackClient
29
29
  from kd.config import ResolvedConfig, resolve_command_defaults, resolve_config
30
30
  from kd.connection import ConnectionContext, resolve_connection
31
31
  from kd.debug import DebugLevel, DebugLog, init_debug_log
32
- from kd.exceptions import APIError, ConfigError, KdError
32
+ from kd.exceptions import KdError
33
33
 
34
34
  # Global options — single source of truth for extraction, help, and reference.
35
35
  GLOBAL_OPTIONS: list[OptionDeclaration] = [
@@ -121,7 +121,7 @@ def expand_toplevel_alias(
121
121
  def _print_error(error: KdError) -> None:
122
122
  """Print error with optional suggestion to stderr."""
123
123
  print(f"Error: {error}", file=sys.stderr)
124
- if isinstance(error, (APIError, ConfigError)) and error.suggestion:
124
+ if error.suggestion:
125
125
  print(f"Hint: {error.suggestion}", file=sys.stderr)
126
126
 
127
127
 
@@ -272,6 +272,7 @@ def main(argv: list[str] | None = None) -> None:
272
272
  connection = resolve_connection(
273
273
  connections=resolved.connections,
274
274
  default_connection=resolved.default_connection_name,
275
+ connection_origins=resolved.connection_origins,
275
276
  debug_log=debug_log,
276
277
  )
277
278
  client = YouTrackClient(
@@ -9,6 +9,7 @@ copy-on-write history for config and primer files.
9
9
  from __future__ import annotations
10
10
 
11
11
  import difflib
12
+ from dataclasses import asdict
12
13
  from pathlib import Path
13
14
  from typing import Any
14
15
 
@@ -18,14 +19,15 @@ from kd.cli.core.option_registry import OptionDeclaration, OptionLayer, Resolved
18
19
  from kd.config import (
19
20
  DEFAULT_CONFIG_PATH,
20
21
  FLAT_KEYS,
21
- LayerState,
22
22
  ResolvedConfig,
23
+ ShadowWarning,
24
+ detect_shadow,
23
25
  find_local_config,
24
- load_config,
25
26
  resolve_config,
26
27
  update_config_path,
27
28
  walk_option_layers,
28
29
  )
30
+ from kd.config_read import get_config_path
29
31
  from kd.config_schema import MISSING, ConfigKeyEntry, resolve_path, walk_schema
30
32
  from kd.exceptions import ConfigError, ValidationError
31
33
  from kd.history import (
@@ -273,7 +275,7 @@ class ConfigGetCommand(Command):
273
275
  "source": str(rv.source),
274
276
  "origin": rv.origin,
275
277
  }
276
- resolved = _nested_get(rc, path)
278
+ resolved = get_config_path(rc, path)
277
279
  if resolved is None:
278
280
  return {"path": path, "value": None}
279
281
  return resolved
@@ -296,7 +298,7 @@ class ConfigSetCommand(Command):
296
298
  ),
297
299
  examples=[
298
300
  "kd /system/config set default_connection corp",
299
- "kd /system/config set connections.vod.default_project L9FRM --local",
301
+ "kd /system/config set connections.support.default_project BCO --local",
300
302
  "kd /system/config set history.retention 50 --global",
301
303
  ],
302
304
  see_also=["/system/config get", "/system/config keys", "/system/config list"],
@@ -370,132 +372,43 @@ class ConfigSetCommand(Command):
370
372
  }
371
373
 
372
374
  if not options.command.get("no-shadow-warning", False):
373
- warning = _detect_shadow(resolved_path, tier, options)
375
+ rc = resolve_config()
376
+ warning = detect_shadow(resolved_path, tier, rc, default_lookup=_lookup_builtin_default)
374
377
  if warning is not None:
375
- result["warning"] = warning
378
+ result["warning"] = asdict(warning)
376
379
  _emit_shadow_warning(warning)
377
380
 
378
381
  return result
379
382
 
380
383
 
381
- def _detect_shadow(path: str, tier: str, options: ResolvedOptions) -> dict[str, Any] | None:
382
- """Return a warning payload if *path* (just written in *tier*) is shadowed.
383
-
384
- Returns ``None`` when the path is not part of the command-option chain,
385
- when the write landed at the winning layer, or when the post-write walker
386
- reports no higher-priority layer with a value. Skips paths that don't map
387
- to a specific ``<command_key>.<option>`` target — flat top-level writes and
388
- connection-level flat writes are cross-command by nature and are not
389
- flagged here.
390
- """
391
- rc = resolve_config()
392
- classified = _classify_set_path(path, tier, rc.default_connection_name)
393
- if classified is None:
394
- return None
395
- written_layer, command_key, option_name = classified
396
-
397
- declared = _lookup_option(command_key, option_name)
398
- builtin_default = declared.default if declared is not None else None
399
-
400
- states = walk_option_layers(
401
- option_name,
402
- command_key,
403
- _active_project(rc),
404
- rc,
405
- builtin_default=builtin_default,
406
- )
407
- winner = next((s for s in states if s.status == "winning"), None)
408
- if winner is None or winner.layer >= written_layer:
409
- return None # write is active (equal) or nothing set (impossible post-write)
410
-
411
- written = next((s for s in states if s.layer == written_layer), None)
412
- hint_path = _shadow_hint_path(winner, command_key, option_name, rc)
413
- return {
414
- "written": {
415
- "layer": written_layer,
416
- "value": written.value if written else None,
417
- "source": written.source if written else "",
418
- },
419
- "winning": {
420
- "layer": winner.layer,
421
- "value": winner.value,
422
- "source": winner.source,
423
- "origin": winner.origin,
424
- },
425
- "option": f"{command_key}.{option_name}",
426
- "hint_path": hint_path,
427
- }
428
-
429
-
430
- def _classify_set_path(
431
- path: str, tier: str, active_connection: str | None
432
- ) -> tuple[int, str, str] | None:
433
- """Map a written config path to a chain layer + (command_key, option)."""
434
- segs = path.split(".")
435
- # defaults.<cmd>.<opt> — layer 4 (local) / 8 (global)
436
- if len(segs) == 3 and segs[0] == "defaults":
437
- layer = 4 if tier == "local" else 8
438
- return layer, segs[1], segs[2]
439
- # connections.<name>.defaults.<cmd>.<opt> — layer 6 when <name> is active
440
- if (
441
- len(segs) == 5
442
- and segs[0] == "connections"
443
- and segs[2] == "defaults"
444
- and active_connection is not None
445
- and segs[1] == active_connection
446
- ):
447
- return 6, segs[3], segs[4]
448
- return None
449
-
450
-
451
- def _lookup_option(command_key: str, option_name: str) -> OptionDeclaration | None:
452
- """Best-effort OptionDeclaration lookup; None if the registry cannot answer."""
384
+ def _lookup_builtin_default(command_key: str, option_name: str) -> Any:
385
+ """Option-registry accessor passed to :func:`detect_shadow` for layer-10 hints."""
453
386
  try:
454
387
  options_map = _command_options_map()
455
388
  except Exception:
456
389
  return None
457
- return options_map.get(command_key, {}).get(option_name)
458
-
459
-
460
- def _active_project(rc: ResolvedConfig) -> str | None:
461
- """Project context for shadow detection — mirrors _determine_project's config path."""
462
- return rc.default_project
463
-
464
-
465
- def _shadow_hint_path(
466
- winner: LayerState, command_key: str, option_name: str, rc: ResolvedConfig
467
- ) -> str | None:
468
- """Suggest a path that would actually override the winning layer, if any.
469
-
470
- For a winning connection-flat value, suggest
471
- ``connections.<name>.defaults.<cmd>.<opt>`` which sits at layer 6 —
472
- one step above layer 7 and scoped per-command.
473
- """
474
- if winner.layer == 7 and rc.default_connection_name is not None:
475
- return f"connections.{rc.default_connection_name}.defaults.{command_key}.{option_name}"
476
- return None
390
+ decl = options_map.get(command_key, {}).get(option_name)
391
+ return decl.default if decl is not None else None
477
392
 
478
393
 
479
- def _emit_shadow_warning(warning: dict[str, Any]) -> None:
394
+ def _emit_shadow_warning(warning: ShadowWarning) -> None:
480
395
  """Render the shadow warning to stderr — non-fatal, user-readable."""
481
396
  import sys
482
397
 
483
- written = warning["written"]
484
- winning = warning["winning"]
485
- option = warning["option"]
398
+ written = warning.written
399
+ winning = warning.winning
486
400
  lines = [
487
401
  "",
488
402
  (
489
- f"Warning: wrote {option} = {written['value']!r} at layer {written['layer']} "
490
- f"({written['source']}), but layer {winning['layer']} ({winning['source']}) "
491
- f"= {winning['value']!r} shadows it."
403
+ f"Warning: wrote {warning.option} = {written['value']!r} at layer "
404
+ f"{written['layer']} ({written['source']}), but layer {winning['layer']} "
405
+ f"({winning['source']}) = {winning['value']!r} shadows it."
492
406
  ),
493
- f"Run 'kd /system/config which {option}' for the full chain.",
407
+ f"Run 'kd /system/config which {warning.option}' for the full chain.",
494
408
  ]
495
- hint = warning.get("hint_path")
496
- if hint:
409
+ if warning.hint_path:
497
410
  lines.append("Hint: to override at a higher-priority layer, set:")
498
- lines.append(f" kd /system/config set {hint} {written['value']!r}")
411
+ lines.append(f" kd /system/config set {warning.hint_path} {written['value']!r}")
499
412
  print("\n".join(lines), file=sys.stderr)
500
413
 
501
414
 
@@ -547,196 +460,6 @@ def _render_default(default: Any) -> str:
547
460
  return str(default)
548
461
 
549
462
 
550
- # ---------------------------------------------------------------------------
551
- # Nested config get
552
- # ---------------------------------------------------------------------------
553
-
554
-
555
- def _nested_get(rc: ResolvedConfig, path: str) -> dict[str, Any] | None:
556
- """Resolve a nested dotted path to ``{path, value, source, origin}``.
557
-
558
- Returns ``None`` when the path is legal but unset — caller wraps that
559
- into ``{path, value: None}``.
560
- """
561
- segments = path.split(".")
562
- root = segments[0]
563
- if root == "connections" and len(segments) >= 3:
564
- return _nested_get_connection(rc, segments, path)
565
- if root == "defaults":
566
- return _nested_get_from_section(
567
- path, segments[1:], [("local", rc.local_defaults), ("global", rc.global_defaults)]
568
- )
569
- if root == "projects":
570
- return _nested_get_project(rc, segments, path)
571
- if root == "aliases" and len(segments) == 2:
572
- return _nested_get_from_section(
573
- path, segments[1:], [("local", rc.local_aliases), ("global", rc.global_aliases)]
574
- )
575
- if root == "history" and len(segments) == 2:
576
- return _nested_get_history(path, segments[1])
577
- return None
578
-
579
-
580
- def _nested_get_connection(
581
- rc: ResolvedConfig, segments: list[str], path: str
582
- ) -> dict[str, Any] | None:
583
- name = segments[1]
584
- conn = rc.connections.get(name)
585
- if conn is None:
586
- return None
587
- # connections.<name>.<key>
588
- if len(segments) == 3:
589
- key = segments[2]
590
- value = getattr(conn, key, None)
591
- if value is None or (isinstance(value, (dict, list)) and not value):
592
- return None
593
- origin = rc.connection_origins.get(name)
594
- key_origin = origin.keys.get(key) if origin else None
595
- if key_origin is not None:
596
- return {
597
- "path": path,
598
- "value": _render_value(value),
599
- "source": key_origin.source,
600
- "origin": key_origin.path,
601
- }
602
- return {
603
- "path": path,
604
- "value": _render_value(value),
605
- "source": origin.source if origin else "",
606
- "origin": origin.path if origin else "",
607
- }
608
- # connections.<name>.compatibility.<field>
609
- if len(segments) == 4 and segments[2] == "compatibility":
610
- compat = conn.compatibility
611
- field_name = segments[3]
612
- value = getattr(compat, field_name, None)
613
- if value is None:
614
- return None
615
- origin = rc.connection_origins.get(name)
616
- key_origin = origin.keys.get("compatibility") if origin else None
617
- source = key_origin.source if key_origin else (origin.source if origin else "")
618
- origin_path = key_origin.path if key_origin else (origin.path if origin else "")
619
- return {
620
- "path": path,
621
- "value": _render_value(value),
622
- "source": source,
623
- "origin": origin_path,
624
- }
625
- # connections.<name>.defaults.<cmd>.<opt>
626
- if len(segments) == 5 and segments[2] == "defaults":
627
- cmd, opt = segments[3], segments[4]
628
- defaults = conn.defaults.get(cmd) if isinstance(conn.defaults, dict) else None
629
- if not isinstance(defaults, dict) or opt not in defaults:
630
- return None
631
- origin = rc.connection_origins.get(name)
632
- key_origin = origin.keys.get("defaults") if origin else None
633
- source = key_origin.source if key_origin else (origin.source if origin else "")
634
- origin_path = key_origin.path if key_origin else (origin.path if origin else "")
635
- return {
636
- "path": path,
637
- "value": _render_value(defaults[opt]),
638
- "source": source,
639
- "origin": origin_path,
640
- }
641
- return None
642
-
643
-
644
- def _nested_get_project(
645
- rc: ResolvedConfig, segments: list[str], path: str
646
- ) -> dict[str, Any] | None:
647
- if len(segments) < 4:
648
- return None
649
- key = segments[1]
650
- section = segments[2]
651
- for source_label, projects in (("local", rc.local_projects), ("global", rc.global_projects)):
652
- proj = projects.get(key)
653
- if not isinstance(proj, dict):
654
- continue
655
- if section == "field_aliases" and len(segments) == 4:
656
- aliases = proj.get("field_aliases")
657
- if isinstance(aliases, dict) and segments[3] in aliases:
658
- return {
659
- "path": path,
660
- "value": _render_value(aliases[segments[3]]),
661
- "source": source_label,
662
- "origin": "",
663
- }
664
- if section == "defaults" and len(segments) == 5:
665
- defaults = proj.get("defaults")
666
- if isinstance(defaults, dict):
667
- cmd = defaults.get(segments[3])
668
- if isinstance(cmd, dict) and segments[4] in cmd:
669
- return {
670
- "path": path,
671
- "value": _render_value(cmd[segments[4]]),
672
- "source": source_label,
673
- "origin": "",
674
- }
675
- return None
676
-
677
-
678
- def _nested_get_from_section(
679
- path: str,
680
- remaining: list[str],
681
- sources: list[tuple[str, dict[str, Any]]],
682
- ) -> dict[str, Any] | None:
683
- """Walk a flat or nested dict section across local/global sources."""
684
- for source_label, section in sources:
685
- value = _walk_dict(section, remaining)
686
- if value is not None:
687
- return {
688
- "path": path,
689
- "value": _render_value(value),
690
- "source": source_label,
691
- "origin": "",
692
- }
693
- return None
694
-
695
-
696
- def _walk_dict(data: Any, segments: list[str]) -> Any:
697
- current: Any = data
698
- for seg in segments:
699
- if not isinstance(current, dict) or seg not in current:
700
- return None
701
- current = current[seg]
702
- return current
703
-
704
-
705
- def _nested_get_history(path: str, key: str) -> dict[str, Any] | None:
706
- """``history`` is not exposed on ResolvedConfig — read both tiers directly."""
707
- for source_label, tier_path in (
708
- ("local", find_local_config()),
709
- ("global", DEFAULT_CONFIG_PATH),
710
- ):
711
- if tier_path is None or not tier_path.exists():
712
- continue
713
- cfg = load_config(tier_path)
714
- value = getattr(cfg.history, key, None)
715
- if value is None:
716
- continue
717
- # Only surface values actually written to the file, not schema defaults.
718
- import yaml as _yaml
719
-
720
- raw = _yaml.safe_load(tier_path.read_text()) or {}
721
- history_block = raw.get("history") if isinstance(raw.get("history"), dict) else {}
722
- if not isinstance(history_block, dict) or key not in history_block:
723
- continue
724
- return {
725
- "path": path,
726
- "value": _render_value(value),
727
- "source": source_label,
728
- "origin": str(tier_path),
729
- }
730
- return None
731
-
732
-
733
- def _render_value(value: Any) -> Any:
734
- """Render a resolved value for output — keep scalars, stringify everything else."""
735
- if isinstance(value, (str, int, float, bool)) or value is None:
736
- return value
737
- return str(value)
738
-
739
-
740
463
  class ConfigWhichCommand(Command):
741
464
  """Show which config files are active, or trace a single option's resolution."""
742
465
 
@@ -877,7 +600,7 @@ def _which_option_path(
877
600
  if connection_override not in rc.connections:
878
601
  known = ", ".join(sorted(rc.connections)) or "(none)"
879
602
  raise ConfigError(f"Unknown connection: {connection_override}. Known: {known}.")
880
- active_rc = _activate_connection(rc, connection_override)
603
+ active_rc = rc.with_active_connection(connection_override)
881
604
  active_project = project_override # None → no project layer
882
605
 
883
606
  states = walk_option_layers(
@@ -915,33 +638,6 @@ def _which_option_path(
915
638
  }
916
639
 
917
640
 
918
- def _activate_connection(rc: ResolvedConfig, name: str) -> ResolvedConfig:
919
- """Return a derived ResolvedConfig with *name* as the active connection."""
920
- conn = rc.connections[name]
921
- conn_defaults = conn.defaults
922
- conn_flat: dict[str, Any] = {}
923
- if conn.output is not None:
924
- conn_flat["output"] = conn.output
925
- if conn.limit is not None:
926
- conn_flat["limit"] = conn.limit
927
- return ResolvedConfig(
928
- rc.entries,
929
- local_defaults=rc.local_defaults,
930
- global_defaults=rc.global_defaults,
931
- local_projects=rc.local_projects,
932
- global_projects=rc.global_projects,
933
- local_flat=rc.local_flat,
934
- global_flat=rc.global_flat,
935
- local_aliases=rc.local_aliases,
936
- global_aliases=rc.global_aliases,
937
- connections=rc.connections,
938
- connection_origins=rc.connection_origins,
939
- default_connection_name=name,
940
- connection_defaults=conn_defaults,
941
- connection_flat=conn_flat,
942
- )
943
-
944
-
945
641
  def _command_options_map() -> dict[str, dict[str, OptionDeclaration]]:
946
642
  """Walk the registered namespace tree and return ``{command_key: {opt: decl}}``.
947
643