kd-cli 2.10.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 (93) hide show
  1. {kd_cli-2.10.0/kd_cli.egg-info → kd_cli-2.11.1}/PKG-INFO +18 -14
  2. {kd_cli-2.10.0 → kd_cli-2.11.1}/README.md +17 -13
  3. kd_cli-2.11.1/kd/_option_chain.py +414 -0
  4. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/http.py +97 -18
  5. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/youtrack.py +3 -3
  6. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/auth.py +3 -23
  7. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cache.py +329 -379
  8. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/command_base.py +4 -1
  9. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/namespaces.py +9 -0
  10. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/input.py +31 -21
  11. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/main.py +400 -140
  12. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/article/__init__.py +38 -35
  13. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/cache/__init__.py +33 -24
  14. kd_cli-2.11.1/kd/cli/namespaces/init.py +135 -0
  15. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/issue/__init__.py +33 -92
  16. kd_cli-2.11.1/kd/cli/namespaces/primer/__init__.py +979 -0
  17. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/project/__init__.py +1 -1
  18. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/query/__init__.py +7 -18
  19. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/system/__init__.py +308 -228
  20. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/tag/__init__.py +17 -33
  21. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/output.py +17 -5
  22. kd_cli-2.11.1/kd/cli/rows.py +78 -0
  23. kd_cli-2.11.1/kd/cli/tree.py +24 -0
  24. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/client.py +79 -17
  25. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/config.py +648 -497
  26. kd_cli-2.11.1/kd/config_read.py +332 -0
  27. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/config_schema.py +1 -1
  28. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/connection.py +110 -51
  29. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/debug.py +1 -1
  30. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/exceptions.py +57 -19
  31. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/history.py +3 -6
  32. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/locking.py +14 -11
  33. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/primer.py +197 -141
  34. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/CHANGELOG.md +248 -237
  35. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/command-synonyms.yaml +1 -1
  36. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/error-messages.yaml +124 -13
  37. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/operating-guide.md +120 -38
  38. kd_cli-2.11.1/kd/resources/primers/hitl.yaml +27 -0
  39. {kd_cli-2.10.0 → kd_cli-2.11.1/kd_cli.egg-info}/PKG-INFO +18 -14
  40. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/SOURCES.txt +2 -2
  41. {kd_cli-2.10.0 → kd_cli-2.11.1}/pyproject.toml +7 -1
  42. kd_cli-2.10.0/kd/cli/namespaces/init.py +0 -62
  43. kd_cli-2.10.0/kd/cli/namespaces/primer/__init__.py +0 -1049
  44. kd_cli-2.10.0/kd/cli/tree.py +0 -65
  45. kd_cli-2.10.0/kd/config_read.py +0 -194
  46. kd_cli-2.10.0/kd/resources/primers/hitl.yaml +0 -41
  47. kd_cli-2.10.0/kd/resources/sdlc-doctrine.md +0 -602
  48. kd_cli-2.10.0/kd/sdlc.py +0 -21
  49. {kd_cli-2.10.0 → kd_cli-2.11.1}/LICENSE +0 -0
  50. {kd_cli-2.10.0 → kd_cli-2.11.1}/MANIFEST.in +0 -0
  51. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/__init__.py +0 -0
  52. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/__init__.py +0 -0
  53. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/articles.py +0 -0
  54. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/hub.py +0 -0
  55. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/saved_queries.py +0 -0
  56. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/api/tags.py +0 -0
  57. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/article_marker.py +0 -0
  58. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/__init__.py +0 -0
  59. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/__init__.py +0 -0
  60. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/command_parser.py +0 -0
  61. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/confirm.py +0 -0
  62. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/core/option_registry.py +0 -0
  63. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/__init__.py +0 -0
  64. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/alias/__init__.py +0 -0
  65. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/release_notes.py +0 -0
  66. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/search/__init__.py +0 -0
  67. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/time/__init__.py +0 -0
  68. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/cli/namespaces/user/__init__.py +0 -0
  69. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/__init__.py +0 -0
  70. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/article.py +0 -0
  71. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/assist.py +0 -0
  72. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/attachment.py +0 -0
  73. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/comment.py +0 -0
  74. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/common.py +0 -0
  75. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/issue.py +0 -0
  76. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/project.py +0 -0
  77. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/saved_query.py +0 -0
  78. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/search.py +0 -0
  79. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/tag.py +0 -0
  80. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/tag_entities.py +0 -0
  81. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/user.py +0 -0
  82. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/models/work_item.py +0 -0
  83. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/operating_guide.py +0 -0
  84. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/query_parser.py +0 -0
  85. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/release_notes.py +0 -0
  86. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/__init__.py +0 -0
  87. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/config-template.yaml +0 -0
  88. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd/resources/primers/__init__.py +0 -0
  89. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/dependency_links.txt +0 -0
  90. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/entry_points.txt +0 -0
  91. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/requires.txt +0 -0
  92. {kd_cli-2.10.0 → kd_cli-2.11.1}/kd_cli.egg-info/top_level.txt +0 -0
  93. {kd_cli-2.10.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.10.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
@@ -59,7 +59,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
59
59
  It exists to solve a problem that has gotten worse as LLMs have become central to real work:
60
60
 
61
61
  - **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
62
- - **LLM-portable.** One primer works for any model. Switch from Claude to GPT-5 to the next generation — the primer travels with the project, not with a vendor.
62
+ - **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
63
63
  - **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
64
64
  - **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
65
65
 
@@ -83,20 +83,24 @@ uv tool install kd-cli
83
83
 
84
84
  The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
85
85
 
86
- Release notes for the installed version: `kd /release-notes`. The canonical changelog lives at [`kd/resources/CHANGELOG.md`](kd/resources/CHANGELOG.md).
86
+ Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
87
87
 
88
88
  ## Configure
89
89
 
90
90
  ```bash
91
- # Scaffold a project-local config in the current directory.
92
- kd init
91
+ # Global config with your YouTrack URL (recommended on first install).
92
+ kd init --global https://your-instance.youtrack.cloud
93
93
 
94
- # Or set via environment (overrides workspace + global config).
95
- export KD_TOKEN=your_token
96
- export KD_CONNECTION=main
94
+ # The scaffolded connection reads its token from KD_TOKEN (its token_env).
95
+ export KD_TOKEN=perm:your-token-here
96
+
97
+ # Check the connection.
98
+ kd /system info
97
99
  ```
98
100
 
99
- Global config lives at `~/.config/kd/config.yaml`; workspace config lives at `.kd/config.yaml`.
101
+ Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
102
+
103
+ Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
100
104
 
101
105
  ## Usage
102
106
 
@@ -116,13 +120,13 @@ kd /issue show FB-1
116
120
  kd /time log FB-1 45 --type Development --description "Router reconfiguration"
117
121
 
118
122
  # Articles
119
- kd /article list --project CMCSA
120
- kd /article push guide.md --project CMCSA --title "Setup Guide" --tag "type:guide"
123
+ kd /article list --project FB
124
+ kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
121
125
  kd /article pull FB-A-42 > setup.md
122
126
 
123
127
  # Tags
124
128
  kd /tag list
125
- kd /tag articles type:case --project CMCSA
129
+ kd /tag articles type:case --project FB
126
130
 
127
131
  # Saved queries
128
132
  kd /query list
@@ -156,7 +160,7 @@ client = YouTrackClient(
156
160
  projects = client.projects.list()
157
161
  issue = client.issues.create(project="FB", summary="Fix router config")
158
162
  client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
159
- articles = client.tags.articles(["type:case"], project="CMCSA")
163
+ articles = client.tags.articles(["type:case"], project="FB")
160
164
  results = client.search.search("router", types=["issue", "article"])
161
165
  ```
162
166
 
@@ -171,4 +175,4 @@ uv run mypy
171
175
  ```
172
176
 
173
177
  ---
174
- Updated: 2026-04-14
178
+ Updated: 2026-11-01
@@ -19,7 +19,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
19
19
  It exists to solve a problem that has gotten worse as LLMs have become central to real work:
20
20
 
21
21
  - **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
22
- - **LLM-portable.** One primer works for any model. Switch from Claude to GPT-5 to the next generation — the primer travels with the project, not with a vendor.
22
+ - **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
23
23
  - **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
24
24
  - **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
25
25
 
@@ -43,20 +43,24 @@ uv tool install kd-cli
43
43
 
44
44
  The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
45
45
 
46
- Release notes for the installed version: `kd /release-notes`. The canonical changelog lives at [`kd/resources/CHANGELOG.md`](kd/resources/CHANGELOG.md).
46
+ Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
47
47
 
48
48
  ## Configure
49
49
 
50
50
  ```bash
51
- # Scaffold a project-local config in the current directory.
52
- kd init
51
+ # Global config with your YouTrack URL (recommended on first install).
52
+ kd init --global https://your-instance.youtrack.cloud
53
53
 
54
- # Or set via environment (overrides workspace + global config).
55
- export KD_TOKEN=your_token
56
- export KD_CONNECTION=main
54
+ # The scaffolded connection reads its token from KD_TOKEN (its token_env).
55
+ export KD_TOKEN=perm:your-token-here
56
+
57
+ # Check the connection.
58
+ kd /system info
57
59
  ```
58
60
 
59
- Global config lives at `~/.config/kd/config.yaml`; workspace config lives at `.kd/config.yaml`.
61
+ Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
62
+
63
+ Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
60
64
 
61
65
  ## Usage
62
66
 
@@ -76,13 +80,13 @@ kd /issue show FB-1
76
80
  kd /time log FB-1 45 --type Development --description "Router reconfiguration"
77
81
 
78
82
  # Articles
79
- kd /article list --project CMCSA
80
- kd /article push guide.md --project CMCSA --title "Setup Guide" --tag "type:guide"
83
+ kd /article list --project FB
84
+ kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
81
85
  kd /article pull FB-A-42 > setup.md
82
86
 
83
87
  # Tags
84
88
  kd /tag list
85
- kd /tag articles type:case --project CMCSA
89
+ kd /tag articles type:case --project FB
86
90
 
87
91
  # Saved queries
88
92
  kd /query list
@@ -116,7 +120,7 @@ client = YouTrackClient(
116
120
  projects = client.projects.list()
117
121
  issue = client.issues.create(project="FB", summary="Fix router config")
118
122
  client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
119
- articles = client.tags.articles(["type:case"], project="CMCSA")
123
+ articles = client.tags.articles(["type:case"], project="FB")
120
124
  results = client.search.search("router", types=["issue", "article"])
121
125
  ```
122
126
 
@@ -131,4 +135,4 @@ uv run mypy
131
135
  ```
132
136
 
133
137
  ---
134
- Updated: 2026-04-14
138
+ Updated: 2026-11-01
@@ -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