kd-cli 2.1.5__tar.gz → 2.1.6__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 (75) hide show
  1. {kd_cli-2.1.5/kd_cli.egg-info → kd_cli-2.1.6}/PKG-INFO +1 -1
  2. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/saved_queries.py +1 -2
  3. kd_cli-2.1.6/kd/auth.py +173 -0
  4. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/main.py +3 -2
  5. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/system/__init__.py +1 -1
  6. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/config.py +22 -6
  7. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/connection.py +14 -1
  8. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/exceptions.py +12 -5
  9. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/saved_query.py +1 -1
  10. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/CHANGELOG.md +14 -0
  11. {kd_cli-2.1.5 → kd_cli-2.1.6/kd_cli.egg-info}/PKG-INFO +1 -1
  12. {kd_cli-2.1.5 → kd_cli-2.1.6}/pyproject.toml +1 -1
  13. kd_cli-2.1.5/kd/auth.py +0 -96
  14. {kd_cli-2.1.5 → kd_cli-2.1.6}/LICENSE +0 -0
  15. {kd_cli-2.1.5 → kd_cli-2.1.6}/MANIFEST.in +0 -0
  16. {kd_cli-2.1.5 → kd_cli-2.1.6}/README.md +0 -0
  17. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/__init__.py +0 -0
  18. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/__init__.py +0 -0
  19. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/articles.py +0 -0
  20. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/http.py +0 -0
  21. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/hub.py +0 -0
  22. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/tags.py +0 -0
  23. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/youtrack.py +0 -0
  24. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/article_marker.py +0 -0
  25. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/__init__.py +0 -0
  26. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/__init__.py +0 -0
  27. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/command_base.py +0 -0
  28. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/command_parser.py +0 -0
  29. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/namespaces.py +0 -0
  30. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/option_registry.py +0 -0
  31. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/input.py +0 -0
  32. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/__init__.py +0 -0
  33. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/alias/__init__.py +0 -0
  34. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/article/__init__.py +0 -0
  35. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/init.py +0 -0
  36. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/issue/__init__.py +0 -0
  37. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/primer/__init__.py +0 -0
  38. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/project/__init__.py +0 -0
  39. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/query/__init__.py +0 -0
  40. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/release_notes.py +0 -0
  41. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/search/__init__.py +0 -0
  42. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/tag/__init__.py +0 -0
  43. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/time/__init__.py +0 -0
  44. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/user/__init__.py +0 -0
  45. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/output.py +0 -0
  46. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/tree.py +0 -0
  47. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/client.py +0 -0
  48. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/config_schema.py +0 -0
  49. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/debug.py +0 -0
  50. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/history.py +0 -0
  51. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/__init__.py +0 -0
  52. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/article.py +0 -0
  53. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/assist.py +0 -0
  54. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/attachment.py +0 -0
  55. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/comment.py +0 -0
  56. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/common.py +0 -0
  57. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/issue.py +0 -0
  58. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/project.py +0 -0
  59. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/search.py +0 -0
  60. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/tag.py +0 -0
  61. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/user.py +0 -0
  62. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/work_item.py +0 -0
  63. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/primer.py +0 -0
  64. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/query_parser.py +0 -0
  65. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/release_notes.py +0 -0
  66. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/__init__.py +0 -0
  67. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/command-synonyms.yaml +0 -0
  68. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/config-template.yaml +0 -0
  69. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/error-messages.yaml +0 -0
  70. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/SOURCES.txt +0 -0
  71. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/dependency_links.txt +0 -0
  72. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/entry_points.txt +0 -0
  73. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/requires.txt +0 -0
  74. {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/top_level.txt +0 -0
  75. {kd_cli-2.1.5 → kd_cli-2.1.6}/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.6
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(
@@ -296,7 +296,7 @@ class ConfigSetCommand(Command):
296
296
  ),
297
297
  examples=[
298
298
  "kd /system/config set default_connection corp",
299
- "kd /system/config set connections.vod.default_project L9FRM --local",
299
+ "kd /system/config set connections.support.default_project BCO --local",
300
300
  "kd /system/config set history.retention 50 --global",
301
301
  ],
302
302
  see_also=["/system/config get", "/system/config keys", "/system/config list"],
@@ -452,7 +452,7 @@ def update_config_path(path: str, value: str, config_path: Path) -> tuple[str, A
452
452
  coerced = TypeAdapter(entry.annotation).validate_python(value)
453
453
  except ValidationError as e:
454
454
  details = _format_validation_errors(e)
455
- raise ConfigError(f"Invalid value for {path}: {details}") from e
455
+ raise ConfigError(f"Invalid value for {path} in {config_path}: {details}") from e
456
456
 
457
457
  # Load raw YAML, capture original text for rollback.
458
458
  raw = _load_raw_yaml(config_path)
@@ -481,7 +481,7 @@ def update_config_path(path: str, value: str, config_path: Path) -> tuple[str, A
481
481
  ) from e
482
482
  details = _format_validation_errors(e)
483
483
  raise ConfigError(
484
- f"Config would be invalid after setting {path}: {details}",
484
+ f"Config would be invalid after setting {path} in {config_path}: {details}",
485
485
  ) from e
486
486
 
487
487
  return path, coerced
@@ -864,7 +864,7 @@ def _resolve_connections(
864
864
  where = f" in {primary_path}"
865
865
  if isinstance(e, ValidationError):
866
866
  d.trace("config", f"connection '{name}' validation error: {e!r}")
867
- details = _format_validation_errors(e)
867
+ details = _format_validation_errors(e, key_origins)
868
868
  else:
869
869
  details = str(e)
870
870
  message = f"Invalid connection '{name}'{where}: {details}"
@@ -873,17 +873,33 @@ def _resolve_connections(
873
873
  return result, origins
874
874
 
875
875
 
876
- def _format_validation_errors(err: ValidationError) -> str:
876
+ def _format_validation_errors(
877
+ err: ValidationError,
878
+ key_origins: dict[str, ConnectionKeyOrigin] | None = None,
879
+ ) -> str:
877
880
  """Render a Pydantic ``ValidationError`` as a user-facing summary.
878
881
 
879
882
  Drops the ``input_value=...`` dump and the ``errors.pydantic.dev`` URL,
880
883
  keeping only ``<dotted-loc>: <msg>`` parts joined by ``; ``.
884
+
885
+ When *key_origins* is supplied, each locus is annotated with the config
886
+ file that contributed its top-level key, e.g.
887
+ ``default_project: Input should be a string (from /path/.kd/config.yaml)``.
888
+ Origin tracking is per-top-level-key because deep merges on
889
+ ``defaults`` and ``compatibility`` replace the whole sub-dict from
890
+ whichever tier wrote last.
881
891
  """
882
892
  parts: list[str] = []
883
893
  for item in err.errors():
884
- loc = ".".join(str(p) for p in item.get("loc", ()))
894
+ loc_parts = item.get("loc", ())
895
+ loc = ".".join(str(p) for p in loc_parts)
885
896
  msg = item.get("msg", "")
886
- parts.append(f"{loc}: {msg}" if loc else msg)
897
+ origin_suffix = ""
898
+ if key_origins and loc_parts:
899
+ top_key = str(loc_parts[0])
900
+ if top_key in key_origins:
901
+ origin_suffix = f" (from {key_origins[top_key].path})"
902
+ parts.append(f"{loc}: {msg}{origin_suffix}" if loc else msg)
887
903
  return "; ".join(parts) if parts else str(err)
888
904
 
889
905
 
@@ -19,6 +19,9 @@ from kd.auth import resolve_connection_token
19
19
  from kd.exceptions import ConfigError
20
20
 
21
21
  if TYPE_CHECKING:
22
+ from collections.abc import Mapping
23
+
24
+ from kd.config import ConnectionOrigin
22
25
  from kd.debug import DebugLog
23
26
 
24
27
 
@@ -132,6 +135,7 @@ def resolve_connection(
132
135
  connections: dict[str, ConnectionConfig],
133
136
  default_connection: str | None,
134
137
  *,
138
+ connection_origins: Mapping[str, ConnectionOrigin] | None = None,
135
139
  debug_log: DebugLog | None = None,
136
140
  ) -> ConnectionContext:
137
141
  """Resolve the active connection into a :class:`ConnectionContext`.
@@ -142,6 +146,9 @@ def resolve_connection(
142
146
  All parsed ``ConnectionConfig`` objects keyed by name.
143
147
  default_connection:
144
148
  Name of the connection to resolve (from ``default_connection`` config key).
149
+ connection_origins:
150
+ Per-connection provenance from :class:`ResolvedConfig`, used to
151
+ cite the originating config file in auth-error messages.
145
152
  debug_log:
146
153
  Optional debug logger.
147
154
  """
@@ -173,7 +180,13 @@ def resolve_connection(
173
180
  if debug_log:
174
181
  debug_log.info("connection", f"resolving connection '{default_connection}'")
175
182
 
176
- token = resolve_connection_token(conn, debug_log=debug_log)
183
+ origin = connection_origins.get(default_connection) if connection_origins else None
184
+ token = resolve_connection_token(
185
+ conn,
186
+ name=default_connection,
187
+ origin=origin,
188
+ debug_log=debug_log,
189
+ )
177
190
 
178
191
  if debug_log:
179
192
  debug_log.info("connection", f"url={conn.url}")
@@ -17,7 +17,16 @@ import yaml
17
17
 
18
18
 
19
19
  class KdError(Exception):
20
- """Base exception for all kd errors."""
20
+ """Base exception for all kd errors.
21
+
22
+ Carries an optional :attr:`suggestion` that ``_print_error`` renders
23
+ as a ``Hint:`` line. Subclasses may pass it through their own
24
+ constructors or rely on this default.
25
+ """
26
+
27
+ def __init__(self, message: str, *, suggestion: str | None = None) -> None:
28
+ self.suggestion = suggestion
29
+ super().__init__(message)
21
30
 
22
31
 
23
32
  class AuthError(KdError):
@@ -28,8 +37,7 @@ class ConfigError(KdError):
28
37
  """Config file missing, malformed, or incomplete."""
29
38
 
30
39
  def __init__(self, message: str, suggestion: str | None = None) -> None:
31
- self.suggestion = suggestion
32
- super().__init__(message)
40
+ super().__init__(message, suggestion=suggestion)
33
41
 
34
42
 
35
43
  class APIError(KdError):
@@ -44,8 +52,7 @@ class APIError(KdError):
44
52
  ) -> None:
45
53
  self.status_code = status_code
46
54
  self.response_body = response_body
47
- self.suggestion = suggestion
48
- super().__init__(message)
55
+ super().__init__(message, suggestion=suggestion)
49
56
 
50
57
 
51
58
  class NotFoundError(APIError):
@@ -36,7 +36,7 @@ class SavedQueryUpdate(BaseModel):
36
36
 
37
37
  All fields optional. Dump with ``exclude_none=True`` so the POST body only
38
38
  carries the fields the caller actually wants to change; YouTrack preserves
39
- omitted fields (live-verified on ``vod.youtrack.cloud`` 2026-04-05).
39
+ omitted fields.
40
40
  """
41
41
 
42
42
  model_config = ConfigDict(populate_by_name=True)
@@ -4,6 +4,20 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.6] — 2026-04-17
8
+
9
+ Milestone 005 — CLI friction audit, session S2. Two raise sites enriched so users land on the right file and the right fix.
10
+
11
+ ### Fixed
12
+ - Missing-token errors now name the connection and URL, surface a copy-pasteable `token_cmd:` example, and cite the config file that declared `token_env:`. Previously the user saw only the env-var name — in a multi-connection setup, guesswork about which connection failed and which file to edit (KDCLI-72).
13
+ - Config merge-validation errors cite the contributing file for each offending locus. A bad local override now names the local file per key; a bad global-only entry names the global file. `kd /system/config set` leaf-coercion errors name the target config path. Origin data was already tracked via `ConnectionOrigin` for `config list`; this release threads it into the user-visible error string too (KDCLI-69).
14
+
15
+ ### Changed
16
+ - The `.suggestion` attribute now lives on the `KdError` base instead of being declared per-subclass. `_print_error` renders `Hint:` for any `KdError` with a suggestion — `AuthError` and `ValidationError` now carry suggestions alongside `APIError` and `ConfigError`. Internal plumbing; no CLI surface change.
17
+
18
+ ### Documentation
19
+ - Replace `vod` with `support` in help-text examples, code docstrings, current-behavior design docs, specs, how-tos, and test fixtures. Historical records (session docs, CHANGELOG, shipped milestone close-outs) keep their `vod` references intact so the audit trail stays honest (KDCLI-30).
20
+
7
21
  ## [2.1.5] — 2026-04-17
8
22
 
9
23
  Milestone 005 — CLI friction audit, session S1. Four first-time-user surface fixes on the `--help` / `--output` axis.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.1.5
3
+ Version: 2.1.6
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.1.5"
7
+ version = "2.1.6"
8
8
  description = "Knowledge Dispatcher for issue trackers and knowledge bases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
kd_cli-2.1.5/kd/auth.py DELETED
@@ -1,96 +0,0 @@
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.connection import ConnectionConfig
22
- from kd.debug import DebugLog
23
-
24
-
25
- def resolve_connection_token(
26
- conn: ConnectionConfig,
27
- *,
28
- debug_log: DebugLog | None = None,
29
- ) -> str:
30
- """Resolve the API token for a connection.
31
-
32
- Parameters
33
- ----------
34
- conn:
35
- The connection config whose auth sources to try.
36
- debug_log:
37
- Optional debug logger.
38
-
39
- Returns
40
- -------
41
- str
42
- The resolved token.
43
-
44
- Raises
45
- ------
46
- AuthError
47
- If no token can be resolved from any source.
48
- """
49
- if conn.token_cmd:
50
- if debug_log:
51
- debug_log.info("auth", f"running token_cmd: {conn.token_cmd}")
52
- return _run_token_cmd(conn.token_cmd)
53
-
54
- if conn.token_env:
55
- if debug_log:
56
- debug_log.info("auth", f"reading token from env var: {conn.token_env}")
57
- value = os.environ.get(conn.token_env)
58
- if value:
59
- return value
60
- raise AuthError(
61
- f"Environment variable '{conn.token_env}' is not set. "
62
- f"Export it or use 'token_cmd' instead."
63
- )
64
-
65
- raise AuthError(
66
- "Connection has no auth source configured. "
67
- "Set 'token_cmd' or 'token_env' in the connection config."
68
- )
69
-
70
-
71
- def _run_token_cmd(cmd: str) -> str:
72
- """Execute a token command and return its stdout.
73
-
74
- Raises :class:`AuthError` on non-zero exit or empty output.
75
- """
76
- try:
77
- proc = subprocess.run(
78
- cmd,
79
- shell=True, # noqa: S602 — user's own config, same trust as git credential.helper
80
- capture_output=True,
81
- text=True,
82
- timeout=10,
83
- )
84
- except subprocess.TimeoutExpired:
85
- raise AuthError(f"token_cmd timed out after 10s: {cmd}") from None
86
-
87
- if proc.returncode != 0:
88
- stderr = proc.stderr.strip()
89
- detail = f": {stderr}" if stderr else ""
90
- raise AuthError(f"token_cmd failed (exit {proc.returncode}){detail}: {cmd}")
91
-
92
- token = proc.stdout.strip()
93
- if not token:
94
- raise AuthError(f"token_cmd returned empty output: {cmd}")
95
-
96
- return token
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes