kd-cli 2.0.4__py3-none-any.whl

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 (64) hide show
  1. kd/__init__.py +45 -0
  2. kd/api/__init__.py +15 -0
  3. kd/api/articles.py +232 -0
  4. kd/api/http.py +234 -0
  5. kd/api/hub.py +69 -0
  6. kd/api/saved_queries.py +76 -0
  7. kd/api/tags.py +107 -0
  8. kd/api/youtrack.py +414 -0
  9. kd/article_marker.py +348 -0
  10. kd/auth.py +96 -0
  11. kd/cli/__init__.py +5 -0
  12. kd/cli/core/__init__.py +25 -0
  13. kd/cli/core/command_base.py +133 -0
  14. kd/cli/core/command_parser.py +246 -0
  15. kd/cli/core/namespaces.py +175 -0
  16. kd/cli/core/option_registry.py +265 -0
  17. kd/cli/input.py +42 -0
  18. kd/cli/main.py +612 -0
  19. kd/cli/namespaces/__init__.py +38 -0
  20. kd/cli/namespaces/alias/__init__.py +87 -0
  21. kd/cli/namespaces/article/__init__.py +843 -0
  22. kd/cli/namespaces/init.py +42 -0
  23. kd/cli/namespaces/issue/__init__.py +959 -0
  24. kd/cli/namespaces/primer/__init__.py +337 -0
  25. kd/cli/namespaces/project/__init__.py +243 -0
  26. kd/cli/namespaces/query/__init__.py +465 -0
  27. kd/cli/namespaces/search/__init__.py +82 -0
  28. kd/cli/namespaces/system/__init__.py +528 -0
  29. kd/cli/namespaces/tag/__init__.py +337 -0
  30. kd/cli/namespaces/time/__init__.py +307 -0
  31. kd/cli/namespaces/user/__init__.py +99 -0
  32. kd/cli/output.py +287 -0
  33. kd/cli/tree.py +65 -0
  34. kd/client.py +1953 -0
  35. kd/config.py +853 -0
  36. kd/connection.py +156 -0
  37. kd/debug.py +107 -0
  38. kd/exceptions.py +189 -0
  39. kd/history.py +169 -0
  40. kd/models/__init__.py +75 -0
  41. kd/models/article.py +64 -0
  42. kd/models/assist.py +47 -0
  43. kd/models/attachment.py +20 -0
  44. kd/models/comment.py +25 -0
  45. kd/models/common.py +39 -0
  46. kd/models/issue.py +67 -0
  47. kd/models/project.py +60 -0
  48. kd/models/saved_query.py +54 -0
  49. kd/models/search.py +20 -0
  50. kd/models/tag.py +30 -0
  51. kd/models/user.py +42 -0
  52. kd/models/work_item.py +59 -0
  53. kd/primer.py +231 -0
  54. kd/query_parser.py +181 -0
  55. kd/resources/__init__.py +1 -0
  56. kd/resources/command-synonyms.yaml +19 -0
  57. kd/resources/config-template.yaml +71 -0
  58. kd/resources/error-messages.yaml +101 -0
  59. kd_cli-2.0.4.dist-info/METADATA +149 -0
  60. kd_cli-2.0.4.dist-info/RECORD +64 -0
  61. kd_cli-2.0.4.dist-info/WHEEL +5 -0
  62. kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
  63. kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
  64. kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/article_marker.py ADDED
@@ -0,0 +1,348 @@
1
+ """kd-meta marker parser and renderer.
2
+
3
+ The marker block is the upsert key for ``/article push``. It lives as
4
+ an HTML comment at the top of an article's content and in the local
5
+ source file. See ``docs/proposals/19-article-push-proposal.md`` for
6
+ the full rationale.
7
+
8
+ Grammar (informal):
9
+
10
+ <!-- kd-meta
11
+ key: value
12
+ ...
13
+ -->
14
+ <optional blank line>
15
+ <body>
16
+
17
+ Rules (pinned down by ``tests/test_article_marker.py``):
18
+
19
+ - Open and close lines must be exact (``<!-- kd-meta`` / ``-->``).
20
+ - Keys match ``[a-z][a-z0-9-]*``; uppercase and underscores are
21
+ rejected to keep the grammar ASCII-boring.
22
+ - Unknown keys are preserved verbatim on round-trip — the load-bearing
23
+ property that lets future kd versions add keys without breaking
24
+ older versions' round-trips.
25
+ - Known keys are emitted first in the canonical order ``slug``,
26
+ ``upstream-id``, ``upstream-updated``. Unknown keys follow in their
27
+ original insertion order.
28
+ - Line endings and trailing newlines are preserved so that a
29
+ ``parse → render`` cycle is a fixed point.
30
+
31
+ Legacy marker synonym (transitional, proposal 28 / M003 §S4):
32
+
33
+ The parser accepts any ``<!-- kd-meta`` or ``<!-- ytctl-meta``
34
+ open line and treats them identically. The renderer always writes
35
+ ``<!-- kd-meta``, so every successful push rewrites a legacy
36
+ header to native; upstream state converges one article at a time.
37
+ The legacy set is pinned to ``_LEGACY_SYNONYMS`` and to a single
38
+ test assertion so removal is a one-file, one-PR edit once the
39
+ upstream population of ``ytctl-meta`` is empty. No auto-disable,
40
+ no timer — a deliberate future milestone.
41
+
42
+ Stdlib only; no third-party deps. The push workflow (in
43
+ ``kd.client.ArticleOperations.push``) and its tests are the only
44
+ importers.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import re
50
+ from collections import OrderedDict
51
+ from dataclasses import dataclass, field
52
+
53
+ from kd.exceptions import ValidationError
54
+
55
+ __all__ = [
56
+ "ArticleMarker",
57
+ "MarkerError",
58
+ "ParsedFile",
59
+ "parse_content",
60
+ "render_content",
61
+ "validate_slug",
62
+ ]
63
+
64
+
65
+ _MARKER_OPEN = "<!-- kd-meta"
66
+ _MARKER_CLOSE = "-->"
67
+
68
+ # Transitional read-only synonyms for the marker open line. The parser
69
+ # accepts these identically to ``_MARKER_OPEN``; the renderer only ever
70
+ # emits ``_MARKER_OPEN``, so every successful push rewrites an upstream
71
+ # body to the native marker.
72
+ #
73
+ # Removability contract (proposal 28 §Article Marker / M003 §S4):
74
+ # sunset this table by deleting the single entry below and the
75
+ # ``TestLegacySynonym.test_synonym_set_pinned`` assertion once the
76
+ # upstream population of ``ytctl-meta`` markers is empty. Do not add
77
+ # more synonyms; every extra entry is more work for the removal
78
+ # milestone.
79
+ _LEGACY_SYNONYMS: tuple[str, ...] = ("<!-- ytctl-meta",)
80
+
81
+ # Every marker-open line the parser recognizes (native + legacy).
82
+ _RECOGNIZED_OPENS: frozenset[str] = frozenset((_MARKER_OPEN, *_LEGACY_SYNONYMS))
83
+
84
+ # Keys are marker identifiers (e.g. "slug", "upstream-id"). Lowercase,
85
+ # ASCII, hyphen-separated. Underscores are intentionally excluded so
86
+ # the grammar stays HTML-comment-safe and greppable.
87
+ _KEY_RE = re.compile(r"^[a-z][a-z0-9-]*$")
88
+
89
+ # `key: value` line inside the marker block. Value is captured raw and
90
+ # stripped separately.
91
+ _LINE_RE = re.compile(r"^([a-z][a-z0-9-]*)\s*:\s*(.*)$")
92
+
93
+ # Slug charset: file-path shape, case-preserved. Rejects whitespace,
94
+ # colons (would break the marker grammar), and newlines.
95
+ _SLUG_RE = re.compile(r"^[A-Za-z0-9._][A-Za-z0-9._/\-]*$")
96
+
97
+ # Canonical emission order for keys kd itself writes. Anything else
98
+ # is an "unknown key" and appended in original order.
99
+ _KNOWN_KEYS: tuple[str, ...] = ("slug", "upstream-id", "upstream-updated")
100
+
101
+
102
+ class MarkerError(ValidationError):
103
+ """Raised when a marker block fails to parse or a slug is invalid."""
104
+
105
+
106
+ @dataclass
107
+ class ArticleMarker:
108
+ """Parsed kd-meta marker block.
109
+
110
+ ``data`` is an insertion-ordered dict of every key/value pair found
111
+ in the block, including unknown keys. The three known keys expose
112
+ typed properties for convenience; everything else is accessed via
113
+ ``data``.
114
+ """
115
+
116
+ data: "OrderedDict[str, str]" = field(default_factory=OrderedDict)
117
+
118
+ def get(self, key: str) -> str | None:
119
+ return self.data.get(key)
120
+
121
+ def set(self, key: str, value: str) -> None:
122
+ """Set a key; validates the key charset."""
123
+ if not _KEY_RE.match(key):
124
+ raise MarkerError(f"invalid key: {key!r}")
125
+ self.data[key] = value
126
+
127
+ @property
128
+ def slug(self) -> str | None:
129
+ return self.data.get("slug")
130
+
131
+ @property
132
+ def upstream_id(self) -> str | None:
133
+ return self.data.get("upstream-id")
134
+
135
+ @property
136
+ def upstream_updated(self) -> str | None:
137
+ return self.data.get("upstream-updated")
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class ParsedFile:
142
+ """Result of parsing a local file's content into marker + body.
143
+
144
+ Preserves enough metadata to re-emit the file byte-identically:
145
+
146
+ - ``marker`` is ``None`` when the input has no marker at the top.
147
+ - ``body`` is the content after the marker (and its optional blank
148
+ separator line), with line endings normalized to ``"\\n"``.
149
+ ``render_content`` restores the original line ending style.
150
+ - ``newline`` is ``"\\n"`` or ``"\\r\\n"`` detected from the input.
151
+ - ``trailing_newline`` is ``True`` iff the original input ended
152
+ with a line terminator.
153
+ - ``body_separator`` is ``True`` iff there was a blank line between
154
+ the marker close and the first body line. The renderer emits one
155
+ iff this flag is set. Synthesized markers default to ``True``
156
+ (canonical form; also what the YouTrack web UI normalizes into).
157
+ """
158
+
159
+ marker: ArticleMarker | None
160
+ body: str
161
+ newline: str
162
+ trailing_newline: bool
163
+ body_separator: bool = True
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Parser
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def _detect_newline(text: str) -> str:
172
+ """Return ``"\\r\\n"`` if the first line terminator is CRLF, else ``"\\n"``."""
173
+ idx = text.find("\n")
174
+ if idx > 0 and text[idx - 1] == "\r":
175
+ return "\r\n"
176
+ return "\n"
177
+
178
+
179
+ def _split_lines(text: str, newline: str) -> list[str]:
180
+ """Split on the given line terminator, preserving empty trailing lines.
181
+
182
+ Normalizes to ``\\n``-joined form internally; callers never see the
183
+ raw terminator. A trailing terminator produces an extra empty
184
+ element which the caller handles via ``trailing_newline``.
185
+ """
186
+ if newline == "\r\n":
187
+ text = text.replace("\r\n", "\n")
188
+ return text.split("\n")
189
+
190
+
191
+ def parse_content(text: str) -> ParsedFile:
192
+ """Parse *text* into a ``ParsedFile`` with optional marker + body.
193
+
194
+ Raises ``MarkerError`` if a marker block is opened but malformed.
195
+ A file with no marker at the top returns ``marker=None``; the body
196
+ equals the input verbatim (modulo newline normalization).
197
+ """
198
+ newline = _detect_newline(text)
199
+ trailing_newline = text.endswith(("\n", "\r\n"))
200
+ lines = _split_lines(text, newline)
201
+ # A trailing newline leaves an extra empty element at the end; drop
202
+ # it and record the fact so render() can restore it.
203
+ if trailing_newline and lines and lines[-1] == "":
204
+ lines = lines[:-1]
205
+
206
+ # Scan past any leading blank lines (tolerated before the marker
207
+ # open). If the first non-blank line is not the marker open, the
208
+ # file is marker-less and we return the entire text as body.
209
+ i = 0
210
+ while i < len(lines) and lines[i] == "":
211
+ i += 1
212
+ if i >= len(lines) or lines[i] not in _RECOGNIZED_OPENS:
213
+ return ParsedFile(
214
+ marker=None,
215
+ body=_join_body(lines, trailing_newline),
216
+ newline=newline,
217
+ trailing_newline=trailing_newline,
218
+ body_separator=False,
219
+ )
220
+
221
+ # Find the matching close line before per-line parsing. If there is
222
+ # no close at all, report "not closed" rather than misdiagnosing a
223
+ # body line as a malformed key:value.
224
+ close_idx: int | None = None
225
+ for k in range(i + 1, len(lines)):
226
+ if lines[k] == _MARKER_CLOSE:
227
+ close_idx = k
228
+ break
229
+ if close_idx is None:
230
+ raise MarkerError("marker block not closed (expected '-->' on its own line)")
231
+
232
+ # Refuse a second marker anywhere later in the file — native or
233
+ # legacy. Prevents a stray ``<!-- ytctl-meta`` further down from
234
+ # being silently accepted as body content on a file that also
235
+ # carries a native header at the top.
236
+ for k in range(close_idx + 1, len(lines)):
237
+ if lines[k] in _RECOGNIZED_OPENS:
238
+ raise MarkerError("file contains more than one kd-meta block")
239
+
240
+ # Parse key:value lines between open and close.
241
+ marker = ArticleMarker()
242
+ for j in range(i + 1, close_idx):
243
+ line = lines[j]
244
+ if line == "":
245
+ continue # blank lines inside the marker are tolerated
246
+ m = _LINE_RE.match(line)
247
+ if m is None:
248
+ # Distinguish "invalid key charset" from "no colon at all"
249
+ # to give a more actionable error message.
250
+ if ":" not in line:
251
+ raise MarkerError(f"malformed key:value line: {line!r}")
252
+ raw_key = line.split(":", 1)[0].strip()
253
+ raise MarkerError(f"invalid key: {raw_key!r}")
254
+ key, value = m.group(1), m.group(2).strip()
255
+ marker.data[key] = value
256
+
257
+ # Consume one optional blank separator line after the close.
258
+ body_start = close_idx + 1
259
+ body_separator = False
260
+ if body_start < len(lines) and lines[body_start] == "":
261
+ body_separator = True
262
+ body_start += 1
263
+
264
+ body_lines = lines[body_start:]
265
+ body = _join_body(body_lines, trailing_newline)
266
+ return ParsedFile(
267
+ marker=marker,
268
+ body=body,
269
+ newline=newline,
270
+ trailing_newline=trailing_newline,
271
+ body_separator=body_separator,
272
+ )
273
+
274
+
275
+ def _join_body(lines: list[str], trailing_newline: bool) -> str:
276
+ """Join body lines with ``\\n``, restoring the final terminator if any."""
277
+ if not lines:
278
+ return "\n" if trailing_newline else ""
279
+ body = "\n".join(lines)
280
+ if trailing_newline:
281
+ body += "\n"
282
+ return body
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Renderer
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ def render_content(parsed: ParsedFile) -> str:
291
+ """Render a ``ParsedFile`` back to text, preserving byte-identity.
292
+
293
+ - Known keys first (``slug``, ``upstream-id``, ``upstream-updated``),
294
+ then unknown keys in their original insertion order.
295
+ - Blank line separator between ``-->`` and the first body line
296
+ (canonical form; also the shape the YouTrack web UI normalizes
297
+ into).
298
+ - Line endings are ``parsed.newline``; trailing newline follows
299
+ ``parsed.trailing_newline``.
300
+ """
301
+ if parsed.marker is None:
302
+ # Marker-less files pass through verbatim. ``body`` already
303
+ # includes any trailing newline; line endings are ``\n``.
304
+ return _retarget_newlines(parsed.body, parsed.newline)
305
+
306
+ marker = parsed.marker
307
+ lines: list[str] = [_MARKER_OPEN]
308
+ # Known keys first, in canonical order, if present.
309
+ for key in _KNOWN_KEYS:
310
+ if key in marker.data:
311
+ lines.append(f"{key}: {marker.data[key]}")
312
+ # Unknown keys, in their original insertion order.
313
+ for key, value in marker.data.items():
314
+ if key in _KNOWN_KEYS:
315
+ continue
316
+ lines.append(f"{key}: {value}")
317
+ lines.append(_MARKER_CLOSE)
318
+ if parsed.body_separator:
319
+ lines.append("")
320
+
321
+ header = "\n".join(lines) + "\n"
322
+ body = parsed.body
323
+ combined = header + body
324
+ return _retarget_newlines(combined, parsed.newline)
325
+
326
+
327
+ def _retarget_newlines(text: str, newline: str) -> str:
328
+ """Replace ``\\n`` with *newline* if *newline* is CRLF; else no-op."""
329
+ if newline == "\r\n":
330
+ return text.replace("\n", "\r\n")
331
+ return text
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Slug validation
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ def validate_slug(slug: str) -> None:
340
+ """Raise ``MarkerError`` if *slug* does not match the push slug charset.
341
+
342
+ Allowed: ``[A-Za-z0-9._]`` as first char, then ``[A-Za-z0-9._/-]*``.
343
+ Rejects whitespace, colons, newlines, and a leading ``/``.
344
+ """
345
+ if not slug:
346
+ raise MarkerError("slug cannot be empty")
347
+ if not _SLUG_RE.match(slug):
348
+ raise MarkerError(f"invalid slug {slug!r}: must match [A-Za-z0-9._][A-Za-z0-9._/-]*")
kd/auth.py ADDED
@@ -0,0 +1,96 @@
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
kd/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """CLI layer — thin wrapper over the SDK.
2
+
3
+ Parses arguments via namespace-based command routing,
4
+ calls SDK methods, formats output.
5
+ """
@@ -0,0 +1,25 @@
1
+ """CLI framework core — namespace system, command base, option registry."""
2
+
3
+ from kd.cli.core.command_base import Command, CommandResult
4
+ from kd.cli.core.namespaces import Namespace, NamespaceRegistry
5
+ from kd.cli.core.option_registry import (
6
+ OUTPUT_OPTIONS,
7
+ OptionDeclaration,
8
+ OptionError,
9
+ OptionLayer,
10
+ OptionRegistry,
11
+ ResolvedOptions,
12
+ )
13
+
14
+ __all__ = [
15
+ "OUTPUT_OPTIONS",
16
+ "Command",
17
+ "CommandResult",
18
+ "Namespace",
19
+ "NamespaceRegistry",
20
+ "OptionDeclaration",
21
+ "OptionError",
22
+ "OptionLayer",
23
+ "OptionRegistry",
24
+ "ResolvedOptions",
25
+ ]
@@ -0,0 +1,133 @@
1
+ """Command base class.
2
+
3
+ All CLI commands inherit from Command and implement execute().
4
+ Commands receive positional args, resolved options, and optionally
5
+ a YouTrackClient. They return structured data for output formatting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from kd.cli.core.option_registry import OptionDeclaration, ResolvedOptions
14
+
15
+ if TYPE_CHECKING:
16
+ from kd.config import ResolvedConfig
17
+
18
+ # Commands return structured data — the output layer formats it.
19
+ # list[dict] -> table rows; dict -> key-value pairs; str -> passthrough; None -> no output
20
+ CommandResult = list[dict[str, Any]] | dict[str, Any] | str | None
21
+
22
+
23
+ class Command(ABC):
24
+ """Base class for all CLI commands."""
25
+
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ description: str,
30
+ *,
31
+ requires_client: bool = False,
32
+ requires_config: bool = False,
33
+ usage: str = "",
34
+ long_description: str = "",
35
+ examples: list[str] | None = None,
36
+ see_also: list[str] | None = None,
37
+ ) -> None:
38
+ self.name = name
39
+ self.description = description
40
+ self.requires_client = requires_client
41
+ self.requires_config = requires_config
42
+ self.usage = usage
43
+ self.long_description = long_description
44
+ self.examples: list[str] = examples or []
45
+ self.see_also: list[str] = see_also or []
46
+ self.display_hint: str | None = None
47
+ self._options: list[OptionDeclaration] = []
48
+ self._resolved_config: ResolvedConfig | None = None
49
+ self._register_options()
50
+
51
+ def set_config(self, config: ResolvedConfig) -> None:
52
+ """Inject the resolved config. Called by the dispatcher when
53
+ ``requires_config`` is True, before ``execute()`` runs.
54
+ """
55
+ self._resolved_config = config
56
+
57
+ @property
58
+ def config(self) -> ResolvedConfig:
59
+ """The resolved config for this invocation.
60
+
61
+ Only valid when the command declares ``requires_config=True``; the
62
+ dispatcher calls :meth:`set_config` before :meth:`execute` in that
63
+ case. Raises :class:`RuntimeError` otherwise.
64
+ """
65
+ if self._resolved_config is None:
66
+ raise RuntimeError(f"{self.name}: config not set. Did you forget requires_config=True?")
67
+ return self._resolved_config
68
+
69
+ def _register_options(self) -> None:
70
+ """Register command-specific options. Override in subclasses."""
71
+
72
+ @abstractmethod
73
+ def execute(
74
+ self,
75
+ args: list[str],
76
+ options: ResolvedOptions,
77
+ client: Any = None,
78
+ ) -> CommandResult:
79
+ """Execute the command. Return structured data for output formatting.
80
+
81
+ Args:
82
+ args: Positional arguments after namespace+command resolution.
83
+ options: Resolved command and output options.
84
+ client: YouTrackClient instance (None if requires_client is False).
85
+ """
86
+
87
+ def register_option(self, option: OptionDeclaration) -> None:
88
+ """Add an option declaration to this command."""
89
+ self._options.append(option)
90
+
91
+ def get_options(self) -> list[OptionDeclaration]:
92
+ """Return all option declarations for this command."""
93
+ return list(self._options)
94
+
95
+ def get_help_text(self) -> str:
96
+ """Generate help text from command metadata and options."""
97
+ lines = [self.description]
98
+
99
+ if self.usage:
100
+ lines.append("")
101
+ lines.append(f"Usage: {self.usage}")
102
+
103
+ if self.long_description:
104
+ lines.append("")
105
+ lines.append(self.long_description)
106
+
107
+ if self._options:
108
+ lines.append("")
109
+ lines.append("Options:")
110
+ for opt in self._options:
111
+ flag = f" --{opt.name}"
112
+ if opt.short_name:
113
+ flag += f", -{opt.short_name}"
114
+ if opt.aliases:
115
+ alias_str = ", ".join(f"--{a}" for a in opt.aliases)
116
+ flag += f" (alias: {alias_str})"
117
+ if opt.choices:
118
+ flag += f" ({', '.join(opt.choices)})"
119
+ if opt.help_text:
120
+ flag += f" {opt.help_text}"
121
+ lines.append(flag)
122
+
123
+ if self.examples:
124
+ lines.append("")
125
+ lines.append("Examples:")
126
+ for ex in self.examples:
127
+ lines.append(f" {ex}")
128
+
129
+ if self.see_also:
130
+ lines.append("")
131
+ lines.append(f"See also: {', '.join(self.see_also)}")
132
+
133
+ return "\n".join(lines)