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.
- {kd_cli-2.1.4/kd_cli.egg-info → kd_cli-2.1.6}/PKG-INFO +1 -1
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/saved_queries.py +1 -2
- kd_cli-2.1.6/kd/auth.py +173 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/command_base.py +0 -2
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/option_registry.py +1 -1
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/main.py +4 -10
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/primer/__init__.py +50 -9
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/system/__init__.py +1 -1
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/output.py +23 -6
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/config.py +22 -6
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/connection.py +14 -1
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/exceptions.py +12 -5
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/saved_query.py +1 -1
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/CHANGELOG.md +24 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.1.4 → kd_cli-2.1.6}/pyproject.toml +1 -1
- kd_cli-2.1.4/kd/auth.py +0 -96
- {kd_cli-2.1.4 → kd_cli-2.1.6}/LICENSE +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/MANIFEST.in +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/README.md +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/articles.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/http.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/hub.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/tags.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/api/youtrack.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/article_marker.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/input.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/cli/tree.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/client.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/config_schema.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/debug.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/history.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/article.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/assist.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/attachment.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/comment.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/common.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/issue.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/project.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/search.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/tag.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/user.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/models/work_item.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/primer.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/query_parser.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/release_notes.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/__init__.py +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.1.4 → kd_cli-2.1.6}/setup.cfg +0 -0
|
@@ -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
|
|
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",
|
kd_cli-2.1.6/kd/auth.py
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
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.
|
|
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"
|
|
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(
|
|
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
|
-
|
|
894
|
+
loc_parts = item.get("loc", ())
|
|
895
|
+
loc = ".".join(str(p) for p in loc_parts)
|
|
885
896
|
msg = item.get("msg", "")
|
|
886
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
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
|
|
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
|