kd-cli 2.1.4__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.4/kd_cli.egg-info → kd_cli-2.1.6}/PKG-INFO +1 -1
  2. {kd_cli-2.1.4 → 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.4 → kd_cli-2.1.6}/kd/cli/core/command_base.py +0 -2
  5. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/option_registry.py +1 -1
  6. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/main.py +4 -10
  7. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/primer/__init__.py +50 -9
  8. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/system/__init__.py +1 -1
  9. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/output.py +23 -6
  10. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/config.py +22 -6
  11. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/connection.py +14 -1
  12. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/exceptions.py +12 -5
  13. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/saved_query.py +1 -1
  14. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/CHANGELOG.md +24 -0
  15. {kd_cli-2.1.4 → kd_cli-2.1.6/kd_cli.egg-info}/PKG-INFO +1 -1
  16. {kd_cli-2.1.4 → kd_cli-2.1.6}/pyproject.toml +1 -1
  17. kd_cli-2.1.4/kd/auth.py +0 -96
  18. {kd_cli-2.1.4 → kd_cli-2.1.6}/LICENSE +0 -0
  19. {kd_cli-2.1.4 → kd_cli-2.1.6}/MANIFEST.in +0 -0
  20. {kd_cli-2.1.4 → kd_cli-2.1.6}/README.md +0 -0
  21. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/__init__.py +0 -0
  22. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/__init__.py +0 -0
  23. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/articles.py +0 -0
  24. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/http.py +0 -0
  25. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/hub.py +0 -0
  26. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/tags.py +0 -0
  27. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/youtrack.py +0 -0
  28. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/article_marker.py +0 -0
  29. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/__init__.py +0 -0
  30. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/__init__.py +0 -0
  31. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/command_parser.py +0 -0
  32. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/namespaces.py +0 -0
  33. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/input.py +0 -0
  34. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/__init__.py +0 -0
  35. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/alias/__init__.py +0 -0
  36. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/article/__init__.py +0 -0
  37. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/init.py +0 -0
  38. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/issue/__init__.py +0 -0
  39. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/project/__init__.py +0 -0
  40. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/query/__init__.py +0 -0
  41. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/release_notes.py +0 -0
  42. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/search/__init__.py +0 -0
  43. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/tag/__init__.py +0 -0
  44. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/time/__init__.py +0 -0
  45. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/user/__init__.py +0 -0
  46. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/tree.py +0 -0
  47. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/client.py +0 -0
  48. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/config_schema.py +0 -0
  49. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/debug.py +0 -0
  50. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/history.py +0 -0
  51. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/__init__.py +0 -0
  52. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/article.py +0 -0
  53. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/assist.py +0 -0
  54. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/attachment.py +0 -0
  55. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/comment.py +0 -0
  56. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/common.py +0 -0
  57. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/issue.py +0 -0
  58. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/project.py +0 -0
  59. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/search.py +0 -0
  60. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/tag.py +0 -0
  61. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/user.py +0 -0
  62. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/work_item.py +0 -0
  63. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/primer.py +0 -0
  64. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/query_parser.py +0 -0
  65. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/release_notes.py +0 -0
  66. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/__init__.py +0 -0
  67. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/command-synonyms.yaml +0 -0
  68. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/config-template.yaml +0 -0
  69. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/error-messages.yaml +0 -0
  70. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/SOURCES.txt +0 -0
  71. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/dependency_links.txt +0 -0
  72. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/entry_points.txt +0 -0
  73. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/requires.txt +0 -0
  74. {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/top_level.txt +0 -0
  75. {kd_cli-2.1.4 → 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.4
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)
@@ -114,8 +114,6 @@ class Command(ABC):
114
114
  if opt.aliases:
115
115
  alias_str = ", ".join(f"--{a}" for a in opt.aliases)
116
116
  flag += f" (alias: {alias_str})"
117
- if opt.choices:
118
- flag += f" ({', '.join(opt.choices)})"
119
117
  if opt.help_text:
120
118
  flag += f" {opt.help_text}"
121
119
  lines.append(flag)
@@ -253,7 +253,7 @@ OUTPUT_OPTIONS: list[OptionDeclaration] = [
253
253
  layer=OptionLayer.OUTPUT,
254
254
  type=str,
255
255
  choices=["table", "json", "yaml", "md"],
256
- help_text="Output format (table, json, yaml, md)",
256
+ help_text="Output format: table|json|yaml|md (default: table)",
257
257
  ),
258
258
  OptionDeclaration(
259
259
  name="fields",
@@ -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(
@@ -503,8 +504,6 @@ def _show_reference(
503
504
  flag += f"/-{decl.short_name}"
504
505
  if decl.type is not bool:
505
506
  flag += f" {decl.name.upper()}"
506
- if decl.choices:
507
- flag += f" ({'/'.join(decl.choices)})"
508
507
  parts.append(flag)
509
508
  print(f"Global options: {', '.join(parts)}")
510
509
  print()
@@ -539,8 +538,6 @@ def _print_namespace_reference(ns: Namespace, depth: int = 0) -> None:
539
538
  flag = f"--{opt.name}"
540
539
  if opt.short_name:
541
540
  flag += f"/-{opt.short_name}"
542
- if opt.choices:
543
- flag += f" ({', '.join(opt.choices)})"
544
541
  line = f" {flag}"
545
542
  if opt.help_text:
546
543
  line += f" {opt.help_text}"
@@ -621,8 +618,5 @@ def _show_discovery(namespace: Namespace, *, file: Any = None) -> None:
621
618
  flag = f"--{decl.name}"
622
619
  if decl.short_name:
623
620
  flag += f", -{decl.short_name}"
624
- extra = ""
625
- if decl.choices:
626
- extra = f" ({', '.join(decl.choices)})"
627
- print(f" {flag:<16}{decl.help_text}{extra}", file=file)
621
+ print(f" {flag:<16}{decl.help_text}", file=file)
628
622
  print(file=file)
@@ -10,7 +10,12 @@ from typing import Any
10
10
 
11
11
  from kd.cli.core.command_base import Command, CommandResult
12
12
  from kd.cli.core.namespaces import Namespace
13
- from kd.cli.core.option_registry import OptionDeclaration, OptionLayer, ResolvedOptions
13
+ from kd.cli.core.option_registry import (
14
+ OptionDeclaration,
15
+ OptionLayer,
16
+ ResolvedOptions,
17
+ project_option,
18
+ )
14
19
  from kd.cli.input import resolve_text_input
15
20
  from kd.exceptions import ValidationError
16
21
  from kd.primer import (
@@ -25,6 +30,26 @@ from kd.primer import (
25
30
  )
26
31
 
27
32
 
33
+ def _assert_workspace_project(command: Command, options: ResolvedOptions) -> None:
34
+ """Enforce the ``--project`` assertion on workspace-scoped primer commands.
35
+
36
+ The primer lives in ``.kd/primer.yaml`` and has no project dimension.
37
+ ``--project`` is accepted as an assertion of which workspace's primer the
38
+ caller thinks it's editing: matches ``default_project`` → silent no-op;
39
+ mismatches → raise so an agent running from the wrong workspace fails loud.
40
+ """
41
+ asserted = options.command.get("project")
42
+ if not asserted:
43
+ return
44
+ current = command.config.default_project
45
+ if current and asserted != current:
46
+ raise ValidationError(
47
+ f"Primer is workspace-scoped. --project={asserted} does not match "
48
+ f"this workspace's default_project={current}. To edit {asserted}'s "
49
+ f"primer, run from that workspace."
50
+ )
51
+
52
+
28
53
  # ---------------------------------------------------------------------------
29
54
  # /primer show (root default)
30
55
  # ---------------------------------------------------------------------------
@@ -46,19 +71,16 @@ class PrimerShowCommand(Command):
46
71
  ],
47
72
  see_also=["/primer/note list", "/primer/article list"],
48
73
  )
74
+ self.display_hint = "primer_show"
49
75
 
50
76
  def execute(
51
77
  self, args: list[str], options: ResolvedOptions, client: Any = None
52
78
  ) -> CommandResult:
53
79
  data = load_primer()
54
- if not data.notes and not data.articles:
55
- return None
56
- result: dict[str, Any] = {}
57
- if data.notes:
58
- result["notes"] = [{"#": i + 1, "note": n} for i, n in enumerate(data.notes)]
59
- if data.articles:
60
- result["articles"] = [{"id": a.id, "summary": a.summary or ""} for a in data.articles]
61
- return result
80
+ return {
81
+ "notes": [{"#": i + 1, "note": n} for i, n in enumerate(data.notes)],
82
+ "articles": [{"id": a.id, "summary": a.summary or ""} for a in data.articles],
83
+ }
62
84
 
63
85
 
64
86
  # ---------------------------------------------------------------------------
@@ -93,6 +115,7 @@ class PrimerNoteAddCommand(Command):
93
115
  super().__init__(
94
116
  "add",
95
117
  "Add a primer note",
118
+ requires_config=True,
96
119
  usage="kd /primer/note add TEXT",
97
120
  long_description="Appends a note to the project primer. TEXT can be inline, "
98
121
  "@file to read from a file, or - to read from stdin.",
@@ -103,9 +126,13 @@ class PrimerNoteAddCommand(Command):
103
126
  see_also=["/primer/note list", "/primer/note remove"],
104
127
  )
105
128
 
129
+ def _register_options(self) -> None:
130
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
131
+
106
132
  def execute(
107
133
  self, args: list[str], options: ResolvedOptions, client: Any = None
108
134
  ) -> CommandResult:
135
+ _assert_workspace_project(self, options)
109
136
  if not args:
110
137
  raise ValidationError("Usage: kd /primer/note add TEXT")
111
138
  text = resolve_text_input(" ".join(args))
@@ -122,6 +149,7 @@ class PrimerNoteRemoveCommand(Command):
122
149
  super().__init__(
123
150
  "remove",
124
151
  "Remove a primer note by index or all notes",
152
+ requires_config=True,
125
153
  usage="kd /primer/note remove INDEX | --all",
126
154
  long_description="Removes the note at the given 1-based index, or all notes "
127
155
  "if --all is passed. Use '/primer/note list' to see current indices.",
@@ -142,10 +170,12 @@ class PrimerNoteRemoveCommand(Command):
142
170
  help_text="Remove all notes",
143
171
  )
144
172
  )
173
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
145
174
 
146
175
  def execute(
147
176
  self, args: list[str], options: ResolvedOptions, client: Any = None
148
177
  ) -> CommandResult:
178
+ _assert_workspace_project(self, options)
149
179
  if options.command.get("all"):
150
180
  count = clear_notes()
151
181
  return {"ok": True, "action": "clear", "removed": count}
@@ -166,6 +196,7 @@ class PrimerNoteUpdateCommand(Command):
166
196
  super().__init__(
167
197
  "update",
168
198
  "Replace a primer note by index",
199
+ requires_config=True,
169
200
  usage="kd /primer/note update INDEX TEXT",
170
201
  long_description="Replaces the note at the given 1-based index with new text. "
171
202
  "TEXT can be inline, @file to read from a file, or - to read from stdin. "
@@ -177,9 +208,13 @@ class PrimerNoteUpdateCommand(Command):
177
208
  see_also=["/primer/note list", "/primer/note remove"],
178
209
  )
179
210
 
211
+ def _register_options(self) -> None:
212
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
213
+
180
214
  def execute(
181
215
  self, args: list[str], options: ResolvedOptions, client: Any = None
182
216
  ) -> CommandResult:
217
+ _assert_workspace_project(self, options)
183
218
  if not args:
184
219
  raise ValidationError("Usage: kd /primer/note update INDEX TEXT")
185
220
  try:
@@ -227,6 +262,7 @@ class PrimerArticleAddCommand(Command):
227
262
  super().__init__(
228
263
  "add",
229
264
  "Pin an article to the primer",
265
+ requires_config=True,
230
266
  usage="kd /primer/article add ARTICLE_ID [--summary S]",
231
267
  long_description="Adds a YouTrack article reference to the project primer. "
232
268
  "Use --summary to store a description alongside the ID.",
@@ -246,10 +282,12 @@ class PrimerArticleAddCommand(Command):
246
282
  help_text="Description to store with the article reference",
247
283
  )
248
284
  )
285
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
249
286
 
250
287
  def execute(
251
288
  self, args: list[str], options: ResolvedOptions, client: Any = None
252
289
  ) -> CommandResult:
290
+ _assert_workspace_project(self, options)
253
291
  if not args:
254
292
  raise ValidationError("Usage: kd /primer/article add ARTICLE_ID")
255
293
  article_id = args[0]
@@ -268,6 +306,7 @@ class PrimerArticleRemoveCommand(Command):
268
306
  super().__init__(
269
307
  "remove",
270
308
  "Unpin an article or all articles",
309
+ requires_config=True,
271
310
  usage="kd /primer/article remove ARTICLE_ID | --all",
272
311
  long_description="Removes the article with the given ID from the primer, "
273
312
  "or all pinned articles if --all is passed.",
@@ -288,10 +327,12 @@ class PrimerArticleRemoveCommand(Command):
288
327
  help_text="Remove all pinned articles",
289
328
  )
290
329
  )
330
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
291
331
 
292
332
  def execute(
293
333
  self, args: list[str], options: ResolvedOptions, client: Any = None
294
334
  ) -> CommandResult:
335
+ _assert_workspace_project(self, options)
295
336
  if options.command.get("all"):
296
337
  count = clear_articles()
297
338
  return {"ok": True, "action": "clear", "removed": count}
@@ -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"],
@@ -1,13 +1,14 @@
1
1
  """Output formatting.
2
2
 
3
3
  Renders command results in multiple formats:
4
- - table: human-readable tabular output (default for TTY)
4
+ - table: human-readable tabular output (unconditional default)
5
5
  - json: JSON output for piping to jq
6
6
  - yaml: YAML output
7
7
  - md: Markdown table
8
8
 
9
- Auto-detects TTY vs pipe: uses table format for interactive terminals,
10
- falls back to json when piped.
9
+ Default is ``table`` regardless of stdout context; TTY state does not
10
+ influence format (ADR-006 — TTY detection stays config-first). Consumers
11
+ that want JSON or YAML pass ``-o json`` / ``-o yaml`` explicitly.
11
12
 
12
13
  Value normalization runs before serialization: epoch-ms timestamps become
13
14
  ISO 8601, enums become their ``.value``, and nested collections render
@@ -17,7 +18,6 @@ cleanly in every format.
17
18
  from __future__ import annotations
18
19
 
19
20
  import json
20
- import sys
21
21
  from datetime import datetime, timezone
22
22
  from typing import Any
23
23
 
@@ -75,7 +75,8 @@ class OutputFormatter:
75
75
 
76
76
  Args:
77
77
  data: Structured data from a command.
78
- fmt: Output format. Auto-detected if None (TTY->table, pipe->json).
78
+ fmt: Output format. Defaults to ``"table"`` when None; TTY state
79
+ does not influence the default (ADR-006).
79
80
  fields: Comma-separated field names to include.
80
81
  display_hint: Rendering hint from the command (e.g. ``"tree"``).
81
82
  """
@@ -87,12 +88,28 @@ class OutputFormatter:
87
88
  normalized: list[dict[str, Any]] | dict[str, Any] = self._normalize(data)
88
89
 
89
90
  if fmt is None:
90
- fmt = "table" if sys.stdout.isatty() else "json"
91
+ fmt = "table"
91
92
 
92
93
  # Tree display: table/md render as pretty tree, json/yaml serialize structure.
93
94
  if display_hint == "tree" and isinstance(normalized, dict) and fmt in ("table", "md"):
94
95
  return self._render_tree_display(normalized)
95
96
 
97
+ # Primer show: table/md formats drop empty sections so a notes-only
98
+ # primer renders without an empty "articles" row. When both sections
99
+ # are empty, show a populate hint. json/yaml pass through unchanged.
100
+ if (
101
+ display_hint == "primer_show"
102
+ and fmt in ("table", "md")
103
+ and isinstance(normalized, dict)
104
+ ):
105
+ if not normalized.get("notes") and not normalized.get("articles"):
106
+ return (
107
+ "Primer is empty.\n"
108
+ ' Populate with: kd /primer/note add "<text>"\n'
109
+ " kd /primer/article add <ARTICLE_ID>"
110
+ )
111
+ normalized = {k: v for k, v in normalized.items() if v}
112
+
96
113
  field_list = [f.strip() for f in fields.split(",")] if fields else None
97
114
 
98
115
  match fmt:
@@ -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,30 @@ 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
+
21
+ ## [2.1.5] — 2026-04-17
22
+
23
+ Milestone 005 — CLI friction audit, session S1. Four first-time-user surface fixes on the `--help` / `--output` axis.
24
+
25
+ ### Fixed
26
+ - `--help`, `--reference`, and per-command help now render the `--output` choices list exactly once. The literal help text carries the list and the default (`Output format: table|json|yaml|md (default: table)`); four renderer sites that previously auto-appended `(table, json, yaml, md)` a second time stopped doing so (KDCLI-73).
27
+ - `kd` no longer flips the default output format to JSON when stdout is a pipe. `table` is the unconditional default across TTY, pipes, and subprocess contexts, per [ADR-006](../../docs/design/ADR-006-tty-detection-config-first.md). Consumers wanting JSON or YAML pass `-o json` / `-o yaml` explicitly (KDCLI-74).
28
+ - `kd /primer show` on an empty primer now prints a populate hint in `table` and `md` output, instead of rendering zero bytes. JSON and YAML continue to return the canonical empty container `{"notes": [], "articles": []}` (KDCLI-76).
29
+ - `kd /primer/note add`, `kd /primer/article add`, and their sibling `remove` / `update` commands accept `--project/-p` as a workspace assertion. A match against the active connection's `default_project` is a silent no-op; a mismatch raises a `ValidationError` naming both the asserted and resolved keys so an agent running from the wrong workspace fails loud instead of silently writing to the wrong primer (KDCLI-70).
30
+
7
31
  ## [2.1.4] — 2026-04-15
8
32
 
9
33
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.1.4
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.4"
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.4/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