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.
- kd/__init__.py +45 -0
- kd/api/__init__.py +15 -0
- kd/api/articles.py +232 -0
- kd/api/http.py +234 -0
- kd/api/hub.py +69 -0
- kd/api/saved_queries.py +76 -0
- kd/api/tags.py +107 -0
- kd/api/youtrack.py +414 -0
- kd/article_marker.py +348 -0
- kd/auth.py +96 -0
- kd/cli/__init__.py +5 -0
- kd/cli/core/__init__.py +25 -0
- kd/cli/core/command_base.py +133 -0
- kd/cli/core/command_parser.py +246 -0
- kd/cli/core/namespaces.py +175 -0
- kd/cli/core/option_registry.py +265 -0
- kd/cli/input.py +42 -0
- kd/cli/main.py +612 -0
- kd/cli/namespaces/__init__.py +38 -0
- kd/cli/namespaces/alias/__init__.py +87 -0
- kd/cli/namespaces/article/__init__.py +843 -0
- kd/cli/namespaces/init.py +42 -0
- kd/cli/namespaces/issue/__init__.py +959 -0
- kd/cli/namespaces/primer/__init__.py +337 -0
- kd/cli/namespaces/project/__init__.py +243 -0
- kd/cli/namespaces/query/__init__.py +465 -0
- kd/cli/namespaces/search/__init__.py +82 -0
- kd/cli/namespaces/system/__init__.py +528 -0
- kd/cli/namespaces/tag/__init__.py +337 -0
- kd/cli/namespaces/time/__init__.py +307 -0
- kd/cli/namespaces/user/__init__.py +99 -0
- kd/cli/output.py +287 -0
- kd/cli/tree.py +65 -0
- kd/client.py +1953 -0
- kd/config.py +853 -0
- kd/connection.py +156 -0
- kd/debug.py +107 -0
- kd/exceptions.py +189 -0
- kd/history.py +169 -0
- kd/models/__init__.py +75 -0
- kd/models/article.py +64 -0
- kd/models/assist.py +47 -0
- kd/models/attachment.py +20 -0
- kd/models/comment.py +25 -0
- kd/models/common.py +39 -0
- kd/models/issue.py +67 -0
- kd/models/project.py +60 -0
- kd/models/saved_query.py +54 -0
- kd/models/search.py +20 -0
- kd/models/tag.py +30 -0
- kd/models/user.py +42 -0
- kd/models/work_item.py +59 -0
- kd/primer.py +231 -0
- kd/query_parser.py +181 -0
- kd/resources/__init__.py +1 -0
- kd/resources/command-synonyms.yaml +19 -0
- kd/resources/config-template.yaml +71 -0
- kd/resources/error-messages.yaml +101 -0
- kd_cli-2.0.4.dist-info/METADATA +149 -0
- kd_cli-2.0.4.dist-info/RECORD +64 -0
- kd_cli-2.0.4.dist-info/WHEEL +5 -0
- kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
- kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
- kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/cli/input.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared text input resolution.
|
|
2
|
+
|
|
3
|
+
Provides a single function for resolving text values that may reference
|
|
4
|
+
a file (``@path``), stdin (``-`` or ``@-``). Used by any CLI command
|
|
5
|
+
that accepts free-text input such as descriptions or comments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from kd.exceptions import ValidationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_text_input(value: str | None) -> str | None:
|
|
17
|
+
"""Resolve a text value that may reference a file or stdin.
|
|
18
|
+
|
|
19
|
+
- ``None`` or empty string: returned as-is
|
|
20
|
+
- ``@path``: reads file at *path* and returns its contents
|
|
21
|
+
- ``@-``: reads all of stdin (raises on empty input)
|
|
22
|
+
- ``-``: reads all of stdin and returns it
|
|
23
|
+
- anything else: returned as-is (plain text)
|
|
24
|
+
"""
|
|
25
|
+
if not value:
|
|
26
|
+
return value
|
|
27
|
+
if value == "-":
|
|
28
|
+
return sys.stdin.read()
|
|
29
|
+
if value == "@-":
|
|
30
|
+
text = sys.stdin.read()
|
|
31
|
+
if not text:
|
|
32
|
+
raise ValidationError("No input received from stdin")
|
|
33
|
+
return text
|
|
34
|
+
if value.startswith("@"):
|
|
35
|
+
file_path = Path(value[1:])
|
|
36
|
+
if not file_path.is_file():
|
|
37
|
+
raise ValidationError(f"File not found: {file_path}")
|
|
38
|
+
text = file_path.read_text(encoding="utf-8")
|
|
39
|
+
if not text:
|
|
40
|
+
raise ValidationError(f"File is empty: {file_path}")
|
|
41
|
+
return text
|
|
42
|
+
return value
|
kd/cli/main.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""CLI entry point.
|
|
2
|
+
|
|
3
|
+
Initializes the namespace tree, parses arguments, dispatches
|
|
4
|
+
to the matching command, and handles output formatting.
|
|
5
|
+
|
|
6
|
+
Entry point registered in pyproject.toml as:
|
|
7
|
+
kd = "kd.cli.main:main"
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from kd import __version__
|
|
19
|
+
from kd.cli.core.command_parser import (
|
|
20
|
+
CommandParser,
|
|
21
|
+
ParseError,
|
|
22
|
+
UnresolvedCommand,
|
|
23
|
+
)
|
|
24
|
+
from kd.cli.core.namespaces import Namespace, NamespaceRegistry
|
|
25
|
+
from kd.cli.core.option_registry import OUTPUT_OPTIONS, OptionDeclaration, OptionLayer
|
|
26
|
+
from kd.cli.namespaces import register_all_namespaces
|
|
27
|
+
from kd.cli.output import OutputFormatter
|
|
28
|
+
from kd.client import YouTrackClient
|
|
29
|
+
from kd.config import ResolvedConfig, resolve_command_defaults, resolve_config
|
|
30
|
+
from kd.connection import ConnectionContext, resolve_connection
|
|
31
|
+
from kd.debug import DebugLevel, DebugLog, init_debug_log
|
|
32
|
+
from kd.exceptions import APIError, KdError
|
|
33
|
+
|
|
34
|
+
# Global options — single source of truth for extraction, help, and reference.
|
|
35
|
+
GLOBAL_OPTIONS: list[OptionDeclaration] = [
|
|
36
|
+
OptionDeclaration(
|
|
37
|
+
name="help",
|
|
38
|
+
short_name="h",
|
|
39
|
+
layer=OptionLayer.GLOBAL,
|
|
40
|
+
type=bool,
|
|
41
|
+
help_text="Show help for a command or namespace",
|
|
42
|
+
),
|
|
43
|
+
OptionDeclaration(
|
|
44
|
+
name="version",
|
|
45
|
+
short_name="V",
|
|
46
|
+
layer=OptionLayer.GLOBAL,
|
|
47
|
+
type=bool,
|
|
48
|
+
help_text="Show version",
|
|
49
|
+
),
|
|
50
|
+
OptionDeclaration(
|
|
51
|
+
name="reference",
|
|
52
|
+
short_name="R",
|
|
53
|
+
layer=OptionLayer.GLOBAL,
|
|
54
|
+
type=bool,
|
|
55
|
+
help_text="Print full command reference (LLM-friendly)",
|
|
56
|
+
),
|
|
57
|
+
OptionDeclaration(
|
|
58
|
+
name="debug",
|
|
59
|
+
short_name="D",
|
|
60
|
+
layer=OptionLayer.GLOBAL,
|
|
61
|
+
type=bool,
|
|
62
|
+
help_text="Enable debug output (-D info, -DD debug, -DDD trace)",
|
|
63
|
+
),
|
|
64
|
+
OptionDeclaration(
|
|
65
|
+
name="connection",
|
|
66
|
+
short_name="c",
|
|
67
|
+
layer=OptionLayer.GLOBAL,
|
|
68
|
+
type=str,
|
|
69
|
+
help_text="Select a configured connection by name (env: KD_CONNECTION)",
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _config_flags_from_globals(global_opts: dict[str, Any]) -> dict[str, str] | None:
|
|
75
|
+
"""Build the ``flags`` dict for :func:`resolve_config` from extracted globals.
|
|
76
|
+
|
|
77
|
+
Returns ``None`` when no relevant globals are set, so call sites can use
|
|
78
|
+
``resolve_config(flags=_config_flags_from_globals(global_opts))`` and pass
|
|
79
|
+
``None`` naturally.
|
|
80
|
+
"""
|
|
81
|
+
flags: dict[str, str] = {}
|
|
82
|
+
conn = global_opts.get("connection")
|
|
83
|
+
if conn:
|
|
84
|
+
flags["default_connection"] = conn
|
|
85
|
+
return flags or None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Subcommands in the /alias namespace — not eligible for alias expansion.
|
|
89
|
+
_ALIAS_SUBCOMMANDS = {"list", "show"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def expand_alias(args: list[str], config: ResolvedConfig) -> list[str]:
|
|
93
|
+
"""Expand an alias invocation to the full command string.
|
|
94
|
+
|
|
95
|
+
If ``args`` starts with ``["alias", NAME]`` where NAME is a defined alias,
|
|
96
|
+
replace with the expanded command tokens plus any trailing override flags.
|
|
97
|
+
Returns *args* unchanged if no expansion applies.
|
|
98
|
+
"""
|
|
99
|
+
if len(args) >= 2 and args[0] == "alias" and args[1] in config.aliases:
|
|
100
|
+
expanded = shlex.split(config.aliases[args[1]])
|
|
101
|
+
return expanded + args[2:]
|
|
102
|
+
return args
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def expand_toplevel_alias(
|
|
106
|
+
name: str,
|
|
107
|
+
trailing: list[str],
|
|
108
|
+
config: ResolvedConfig,
|
|
109
|
+
) -> list[str] | None:
|
|
110
|
+
"""Expand a top-level alias to full command tokens, or *None*.
|
|
111
|
+
|
|
112
|
+
Unlike :func:`expand_alias` (which handles the explicit ``alias <name>``
|
|
113
|
+
form), this function is used as a fallback when the dispatcher encounters
|
|
114
|
+
an unknown command at root level.
|
|
115
|
+
"""
|
|
116
|
+
if name in config.aliases:
|
|
117
|
+
return shlex.split(config.aliases[name]) + trailing
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _print_error(error: KdError) -> None:
|
|
122
|
+
"""Print error with optional suggestion to stderr."""
|
|
123
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
124
|
+
if isinstance(error, APIError) and error.suggestion:
|
|
125
|
+
print(f"Hint: {error.suggestion}", file=sys.stderr)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: list[str] | None = None) -> None:
|
|
129
|
+
"""CLI entry point."""
|
|
130
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
131
|
+
|
|
132
|
+
# Extract global options before namespace parsing
|
|
133
|
+
global_opts, remaining = _extract_globals(args)
|
|
134
|
+
config_flags = _config_flags_from_globals(global_opts)
|
|
135
|
+
|
|
136
|
+
# Initialize debug logging from -D count (0 = silent)
|
|
137
|
+
debug_raw = global_opts.get("debug", 0)
|
|
138
|
+
debug_level = DebugLevel(min(debug_raw, 3)) if debug_raw else DebugLevel.SILENT
|
|
139
|
+
debug_log = init_debug_log(debug_level)
|
|
140
|
+
|
|
141
|
+
if global_opts.get("version"):
|
|
142
|
+
print(f"kd {__version__}")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Alias expansion: detect `alias <name>` and expand before parsing.
|
|
146
|
+
# Config is loaded early only when needed; stored for reuse later.
|
|
147
|
+
resolved: ResolvedConfig | None = None
|
|
148
|
+
if len(remaining) >= 2 and remaining[0] == "alias" and remaining[1] not in _ALIAS_SUBCOMMANDS:
|
|
149
|
+
resolved = resolve_config(flags=config_flags, debug_log=debug_log)
|
|
150
|
+
remaining = expand_alias(remaining, resolved)
|
|
151
|
+
|
|
152
|
+
# Build registry and parser
|
|
153
|
+
registry = NamespaceRegistry()
|
|
154
|
+
register_all_namespaces(registry)
|
|
155
|
+
|
|
156
|
+
if global_opts.get("reference"):
|
|
157
|
+
if resolved is None:
|
|
158
|
+
resolved = resolve_config(flags=config_flags, debug_log=debug_log)
|
|
159
|
+
_show_reference(registry, config=resolved)
|
|
160
|
+
return
|
|
161
|
+
parser = CommandParser(registry)
|
|
162
|
+
formatter = OutputFormatter()
|
|
163
|
+
|
|
164
|
+
# Phase 1: resolve command and tokenize options (no validation).
|
|
165
|
+
# A ParseError on an unknown root token may still be resolved by
|
|
166
|
+
# alias expansion, so we defer the error in that case.
|
|
167
|
+
parse_error: ParseError | None = None
|
|
168
|
+
try:
|
|
169
|
+
unresolved = parser.parse_command(remaining)
|
|
170
|
+
except ParseError as e:
|
|
171
|
+
# If the first token could be a top-level alias, defer the error
|
|
172
|
+
# and let the alias fallback below handle it.
|
|
173
|
+
if remaining and not remaining[0].startswith("-"):
|
|
174
|
+
parse_error = e
|
|
175
|
+
# Build a minimal UnresolvedCommand so the fallback logic can inspect it.
|
|
176
|
+
unresolved = UnresolvedCommand(
|
|
177
|
+
namespace=registry.root,
|
|
178
|
+
command_name=remaining[0],
|
|
179
|
+
command=None,
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
# No command found — show discovery or help
|
|
186
|
+
if unresolved.command is None:
|
|
187
|
+
if not parse_error and global_opts.get("help"):
|
|
188
|
+
_show_discovery(unresolved.namespace)
|
|
189
|
+
return
|
|
190
|
+
if not unresolved.command_name:
|
|
191
|
+
_show_discovery(unresolved.namespace)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# Top-level alias fallback: when an unknown command is typed at root,
|
|
195
|
+
# check whether it matches a configured alias and expand it.
|
|
196
|
+
if unresolved.namespace is registry.root:
|
|
197
|
+
if resolved is None:
|
|
198
|
+
resolved = resolve_config(flags=config_flags, debug_log=debug_log)
|
|
199
|
+
expanded = expand_toplevel_alias(unresolved.command_name, remaining[1:], resolved)
|
|
200
|
+
if expanded is not None:
|
|
201
|
+
try:
|
|
202
|
+
unresolved = parser.parse_command(expanded)
|
|
203
|
+
except ParseError as e:
|
|
204
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
parse_error = None
|
|
207
|
+
# Fall through to normal execution below.
|
|
208
|
+
elif parse_error:
|
|
209
|
+
print(f"Error: {parse_error}", file=sys.stderr)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
else:
|
|
212
|
+
print(f"Unknown command: {unresolved.command_name}", file=sys.stderr)
|
|
213
|
+
_show_suggestions(unresolved.namespace, unresolved.command_name)
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
elif parse_error:
|
|
216
|
+
print(f"Error: {parse_error}", file=sys.stderr)
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
else:
|
|
219
|
+
print(f"Unknown command: {unresolved.command_name}", file=sys.stderr)
|
|
220
|
+
_show_suggestions(unresolved.namespace, unresolved.command_name)
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
assert unresolved.command is not None # alias expansion guarantees a resolved command
|
|
224
|
+
|
|
225
|
+
# --help for a specific command — before option validation or config resolution
|
|
226
|
+
if global_opts.get("help"):
|
|
227
|
+
print(unresolved.command.get_help_text())
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Resolve config from flags + env + local + global (reuse if loaded for alias expansion)
|
|
231
|
+
if resolved is None:
|
|
232
|
+
resolved = resolve_config(flags=config_flags, debug_log=debug_log)
|
|
233
|
+
|
|
234
|
+
# Debug config key: if config says debug=true and no -D on CLI, enable level 1
|
|
235
|
+
if resolved.debug and not global_opts.get("debug"):
|
|
236
|
+
debug_log = init_debug_log(DebugLevel.INFO)
|
|
237
|
+
debug_log.info("config", "debug enabled via config")
|
|
238
|
+
|
|
239
|
+
# Inject config defaults into raw options BEFORE validation
|
|
240
|
+
_apply_config_defaults(unresolved, resolved, debug_log)
|
|
241
|
+
|
|
242
|
+
# Phase 2: validate and resolve options (after config defaults filled in)
|
|
243
|
+
try:
|
|
244
|
+
parsed = parser.resolve_options(unresolved)
|
|
245
|
+
except ParseError as e:
|
|
246
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
|
|
249
|
+
assert parsed.command is not None # guaranteed by unresolved.command assertion above
|
|
250
|
+
|
|
251
|
+
# Create client if needed — resolve connection first
|
|
252
|
+
client: YouTrackClient | None = None
|
|
253
|
+
connection: ConnectionContext | None = None
|
|
254
|
+
if parsed.command.requires_client:
|
|
255
|
+
try:
|
|
256
|
+
connection = resolve_connection(
|
|
257
|
+
connections=resolved.connections,
|
|
258
|
+
default_connection=resolved.default_connection_name,
|
|
259
|
+
debug_log=debug_log,
|
|
260
|
+
)
|
|
261
|
+
client = YouTrackClient(
|
|
262
|
+
connection=connection,
|
|
263
|
+
config=resolved,
|
|
264
|
+
debug_log=debug_log,
|
|
265
|
+
)
|
|
266
|
+
except KdError as e:
|
|
267
|
+
_print_error(e)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
# Execute command
|
|
271
|
+
if parsed.command.requires_config:
|
|
272
|
+
parsed.command.set_config(resolved)
|
|
273
|
+
try:
|
|
274
|
+
result = parsed.command.execute(parsed.args, parsed.options, client)
|
|
275
|
+
except KdError as e:
|
|
276
|
+
_print_error(e)
|
|
277
|
+
sys.exit(1)
|
|
278
|
+
finally:
|
|
279
|
+
if client:
|
|
280
|
+
client.close()
|
|
281
|
+
|
|
282
|
+
# Format and print output
|
|
283
|
+
if result is not None:
|
|
284
|
+
fmt = parsed.options.output.get("output")
|
|
285
|
+
fields = parsed.options.output.get("fields")
|
|
286
|
+
display_hint = getattr(parsed.command, "display_hint", None)
|
|
287
|
+
output = formatter.format(result, fmt=fmt, fields=fields, display_hint=display_hint)
|
|
288
|
+
if output:
|
|
289
|
+
# String results (e.g. /article pull) own their trailing newline.
|
|
290
|
+
# Everything else goes through print() which appends one newline.
|
|
291
|
+
if isinstance(result, str):
|
|
292
|
+
sys.stdout.write(output)
|
|
293
|
+
if not output.endswith("\n"):
|
|
294
|
+
sys.stdout.write("\n")
|
|
295
|
+
else:
|
|
296
|
+
print(output)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _extract_globals(args: list[str]) -> tuple[dict[str, Any], list[str]]:
|
|
300
|
+
"""Extract global options from args. Returns (globals_dict, remaining_args).
|
|
301
|
+
|
|
302
|
+
Driven by GLOBAL_OPTIONS declarations — adding a new global option
|
|
303
|
+
only requires adding an OptionDeclaration to the list.
|
|
304
|
+
"""
|
|
305
|
+
# Build lookup tables from declarations
|
|
306
|
+
flags: dict[str, str] = {} # --name / -short → name (bool options)
|
|
307
|
+
value_opts: dict[str, str] = {} # --name / -short → name (str options)
|
|
308
|
+
for decl in GLOBAL_OPTIONS:
|
|
309
|
+
if decl.type is bool:
|
|
310
|
+
flags[f"--{decl.name}"] = decl.name
|
|
311
|
+
if decl.short_name:
|
|
312
|
+
flags[f"-{decl.short_name}"] = decl.name
|
|
313
|
+
else:
|
|
314
|
+
value_opts[f"--{decl.name}"] = decl.name
|
|
315
|
+
if decl.short_name:
|
|
316
|
+
value_opts[f"-{decl.short_name}"] = decl.name
|
|
317
|
+
|
|
318
|
+
# Pre-pass: count -D occurrences (stacking). -DD = 2, -DDD = 3,
|
|
319
|
+
# --debug = 1. Remove matched tokens so the main loop never sees them.
|
|
320
|
+
_D_PATTERN = re.compile(r"^-D{1,3}$")
|
|
321
|
+
debug_count = 0
|
|
322
|
+
filtered: list[str] = []
|
|
323
|
+
for token in args:
|
|
324
|
+
if _D_PATTERN.match(token):
|
|
325
|
+
debug_count += len(token) - 1 # "-DD" → 2
|
|
326
|
+
elif token == "--debug":
|
|
327
|
+
debug_count += 1
|
|
328
|
+
else:
|
|
329
|
+
filtered.append(token)
|
|
330
|
+
# Remove debug from the generic flag map — already handled above.
|
|
331
|
+
flags.pop("--debug", None)
|
|
332
|
+
flags.pop("-D", None)
|
|
333
|
+
|
|
334
|
+
global_opts: dict[str, Any] = {}
|
|
335
|
+
if debug_count:
|
|
336
|
+
global_opts["debug"] = min(debug_count, 3)
|
|
337
|
+
|
|
338
|
+
remaining: list[str] = []
|
|
339
|
+
|
|
340
|
+
i = 0
|
|
341
|
+
while i < len(filtered):
|
|
342
|
+
token = filtered[i]
|
|
343
|
+
|
|
344
|
+
if token in flags:
|
|
345
|
+
global_opts[flags[token]] = True
|
|
346
|
+
i += 1
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
if token in value_opts:
|
|
350
|
+
name = value_opts[token]
|
|
351
|
+
if i + 1 < len(filtered):
|
|
352
|
+
global_opts[name] = filtered[i + 1]
|
|
353
|
+
i += 2
|
|
354
|
+
else:
|
|
355
|
+
print(f"Error: {token} requires a value", file=sys.stderr)
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Handle --name=value for value options
|
|
360
|
+
matched_equals = False
|
|
361
|
+
for prefix, name in value_opts.items():
|
|
362
|
+
if prefix.startswith("--") and token.startswith(f"{prefix}="):
|
|
363
|
+
global_opts[name] = token.split("=", 1)[1]
|
|
364
|
+
matched_equals = True
|
|
365
|
+
break
|
|
366
|
+
if matched_equals:
|
|
367
|
+
i += 1
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
remaining.append(token)
|
|
371
|
+
i += 1
|
|
372
|
+
|
|
373
|
+
return global_opts, remaining
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Config default injection
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
_ISSUE_ID_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)-\d+$")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _command_key(namespace: Namespace, command_name: str) -> str:
|
|
384
|
+
"""Derive the config command key from namespace path and command name.
|
|
385
|
+
|
|
386
|
+
Examples: ``issue_list``, ``issue_comment_add``, ``system_config_list``.
|
|
387
|
+
"""
|
|
388
|
+
if namespace.path:
|
|
389
|
+
return namespace.path.replace("/", "_") + "_" + command_name
|
|
390
|
+
return command_name
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _determine_project(
|
|
394
|
+
args: list[str], raw_options: dict[str, Any], config: ResolvedConfig
|
|
395
|
+
) -> str | None:
|
|
396
|
+
"""Determine which project a command targets.
|
|
397
|
+
|
|
398
|
+
Priority: explicit ``--project`` flag → ``default_project`` from
|
|
399
|
+
config → inferred from first issue-ID-shaped arg.
|
|
400
|
+
"""
|
|
401
|
+
# 1. Explicit --project flag
|
|
402
|
+
project = raw_options.get("project")
|
|
403
|
+
if project:
|
|
404
|
+
return str(project)
|
|
405
|
+
|
|
406
|
+
# 2. default_project from config
|
|
407
|
+
if config.default_project:
|
|
408
|
+
return config.default_project
|
|
409
|
+
|
|
410
|
+
# 3. Infer from first issue-ID-shaped arg (e.g. "FB-123" → "FB")
|
|
411
|
+
for arg in args:
|
|
412
|
+
m = _ISSUE_ID_RE.match(arg)
|
|
413
|
+
if m:
|
|
414
|
+
return m.group(1).upper()
|
|
415
|
+
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _apply_config_defaults(
|
|
420
|
+
unresolved: UnresolvedCommand,
|
|
421
|
+
config: ResolvedConfig,
|
|
422
|
+
debug_log: DebugLog | None,
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Inject config defaults into raw options before validation.
|
|
425
|
+
|
|
426
|
+
Mutates ``unresolved.raw_options`` so that phase-2 validation
|
|
427
|
+
sees config-provided values for options not set by the CLI.
|
|
428
|
+
"""
|
|
429
|
+
if unresolved.command is None:
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
cmd_key = _command_key(unresolved.namespace, unresolved.command_name)
|
|
433
|
+
project = _determine_project(unresolved.args, unresolved.raw_options, config)
|
|
434
|
+
|
|
435
|
+
# Gather all declarations (command + output options)
|
|
436
|
+
all_decls = unresolved.command.get_options() + OUTPUT_OPTIONS
|
|
437
|
+
|
|
438
|
+
defaults = resolve_command_defaults(
|
|
439
|
+
cmd_key, project, unresolved.raw_options, config, all_decls, debug_log=debug_log
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Inject default_project for commands that declare a project option.
|
|
443
|
+
# Priority: --project flag > command defaults > default_project > none.
|
|
444
|
+
if (
|
|
445
|
+
"project" not in unresolved.raw_options
|
|
446
|
+
and "project" not in defaults
|
|
447
|
+
and config.default_project
|
|
448
|
+
and any(d.name == "project" for d in all_decls)
|
|
449
|
+
):
|
|
450
|
+
defaults["project"] = config.default_project
|
|
451
|
+
if debug_log:
|
|
452
|
+
debug_log.debug(
|
|
453
|
+
"config",
|
|
454
|
+
f"injecting default_project={config.default_project} for {cmd_key}",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if not defaults:
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
if debug_log:
|
|
461
|
+
debug_log.debug("config", f"injecting config defaults for {cmd_key}: {defaults}")
|
|
462
|
+
|
|
463
|
+
# Merge defaults into raw_options — CLI flags already present are not overwritten
|
|
464
|
+
for key, value in defaults.items():
|
|
465
|
+
if key not in unresolved.raw_options:
|
|
466
|
+
unresolved.raw_options[key] = value
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _show_reference(
|
|
470
|
+
registry: NamespaceRegistry,
|
|
471
|
+
*,
|
|
472
|
+
config: ResolvedConfig | None = None,
|
|
473
|
+
) -> None:
|
|
474
|
+
"""Print a condensed reference of all commands, options, and examples.
|
|
475
|
+
|
|
476
|
+
Generated from command metadata — always in sync with the code.
|
|
477
|
+
Designed for LLM consumption: one output, full coverage.
|
|
478
|
+
"""
|
|
479
|
+
print(f"kd {__version__} — YouTrack CLI and SDK")
|
|
480
|
+
print()
|
|
481
|
+
|
|
482
|
+
# Global options line — generated from declarations
|
|
483
|
+
parts: list[str] = []
|
|
484
|
+
for decl in GLOBAL_OPTIONS + OUTPUT_OPTIONS:
|
|
485
|
+
flag = f"--{decl.name}"
|
|
486
|
+
if decl.short_name:
|
|
487
|
+
flag += f"/-{decl.short_name}"
|
|
488
|
+
if decl.type is not bool:
|
|
489
|
+
flag += f" {decl.name.upper()}"
|
|
490
|
+
if decl.choices:
|
|
491
|
+
flag += f" ({'/'.join(decl.choices)})"
|
|
492
|
+
parts.append(flag)
|
|
493
|
+
print(f"Global options: {', '.join(parts)}")
|
|
494
|
+
print()
|
|
495
|
+
_print_namespace_reference(registry.root)
|
|
496
|
+
|
|
497
|
+
# Aliases — show user-defined shortcuts when any are configured
|
|
498
|
+
if config and config.aliases:
|
|
499
|
+
print()
|
|
500
|
+
print("Aliases:")
|
|
501
|
+
for name, command in sorted(config.aliases.items()):
|
|
502
|
+
print(f" kd {name:<20}{command}")
|
|
503
|
+
print(
|
|
504
|
+
" Aliases are invocable as top-level commands. Flags after the name override the expansion."
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _print_namespace_reference(ns: Namespace, depth: int = 0) -> None:
|
|
509
|
+
"""Recursively print reference for a namespace and its children."""
|
|
510
|
+
for cmd_name, cmd in sorted(ns.commands.items()):
|
|
511
|
+
# Build the full command path using canonical slash notation
|
|
512
|
+
prefix = f"kd /{ns.path} " if ns.path else "kd "
|
|
513
|
+
usage = cmd.usage or f"{prefix}{cmd_name}"
|
|
514
|
+
|
|
515
|
+
# Command line
|
|
516
|
+
print(f" {usage}")
|
|
517
|
+
print(f" {cmd.description}")
|
|
518
|
+
if cmd.long_description:
|
|
519
|
+
print(f" {cmd.long_description}")
|
|
520
|
+
|
|
521
|
+
# Options
|
|
522
|
+
for opt in cmd.get_options():
|
|
523
|
+
flag = f"--{opt.name}"
|
|
524
|
+
if opt.short_name:
|
|
525
|
+
flag += f"/-{opt.short_name}"
|
|
526
|
+
if opt.choices:
|
|
527
|
+
flag += f" ({', '.join(opt.choices)})"
|
|
528
|
+
line = f" {flag}"
|
|
529
|
+
if opt.help_text:
|
|
530
|
+
line += f" {opt.help_text}"
|
|
531
|
+
print(line)
|
|
532
|
+
|
|
533
|
+
print()
|
|
534
|
+
|
|
535
|
+
# Recurse into sub-namespaces
|
|
536
|
+
for _, sub_ns in sorted(ns.sub_namespaces.items()):
|
|
537
|
+
if sub_ns.commands or sub_ns.sub_namespaces:
|
|
538
|
+
_print_namespace_reference(sub_ns, depth + 1)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _show_suggestions(namespace: Namespace, typo: str) -> None:
|
|
542
|
+
"""Show fuzzy suggestions for an unknown command or namespace name."""
|
|
543
|
+
# Collect all candidate names: commands + synonyms + sub-namespaces
|
|
544
|
+
candidates = (
|
|
545
|
+
list(namespace.commands.keys())
|
|
546
|
+
+ list(namespace.synonyms.keys())
|
|
547
|
+
+ list(namespace.sub_namespaces.keys())
|
|
548
|
+
)
|
|
549
|
+
matches = difflib.get_close_matches(typo, candidates, n=3, cutoff=0.6)
|
|
550
|
+
|
|
551
|
+
if matches:
|
|
552
|
+
print(file=sys.stderr)
|
|
553
|
+
print("Did you mean?", file=sys.stderr)
|
|
554
|
+
for name in matches:
|
|
555
|
+
if name in namespace.synonyms:
|
|
556
|
+
canonical = namespace.synonyms[name]
|
|
557
|
+
desc = namespace.commands[canonical].description
|
|
558
|
+
print(f" {name:<16}{desc} (synonym for {canonical})", file=sys.stderr)
|
|
559
|
+
elif name in namespace.commands:
|
|
560
|
+
desc = namespace.commands[name].description
|
|
561
|
+
print(f" {name:<16}{desc}", file=sys.stderr)
|
|
562
|
+
else:
|
|
563
|
+
desc = namespace.sub_namespaces[name].description
|
|
564
|
+
print(f" {name:<16}{desc}", file=sys.stderr)
|
|
565
|
+
|
|
566
|
+
print(file=sys.stderr)
|
|
567
|
+
_show_discovery(namespace, file=sys.stderr)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _show_discovery(namespace: Namespace, *, file: Any = None) -> None:
|
|
571
|
+
"""Display available commands and sub-namespaces."""
|
|
572
|
+
if file is None:
|
|
573
|
+
file = sys.stdout
|
|
574
|
+
|
|
575
|
+
if namespace.path:
|
|
576
|
+
print(f"kd /{namespace.path}", file=file)
|
|
577
|
+
else:
|
|
578
|
+
print("kd — YouTrack CLI and SDK", file=file)
|
|
579
|
+
print(file=file)
|
|
580
|
+
|
|
581
|
+
if namespace.sub_namespaces:
|
|
582
|
+
print("Namespaces:", file=file)
|
|
583
|
+
for name, ns in sorted(namespace.sub_namespaces.items()):
|
|
584
|
+
print(f" {name:<16}{ns.description}", file=file)
|
|
585
|
+
print(file=file)
|
|
586
|
+
|
|
587
|
+
if namespace.commands:
|
|
588
|
+
print("Commands:", file=file)
|
|
589
|
+
for name, cmd in sorted(namespace.commands.items()):
|
|
590
|
+
print(f" {name:<16}{cmd.description}", file=file)
|
|
591
|
+
print(file=file)
|
|
592
|
+
|
|
593
|
+
# Show global options at root level
|
|
594
|
+
if not namespace.path:
|
|
595
|
+
print("Global options:", file=file)
|
|
596
|
+
for decl in GLOBAL_OPTIONS:
|
|
597
|
+
flag = f"--{decl.name}"
|
|
598
|
+
if decl.short_name:
|
|
599
|
+
flag += f", -{decl.short_name}"
|
|
600
|
+
print(f" {flag:<16}{decl.help_text}", file=file)
|
|
601
|
+
print(file=file)
|
|
602
|
+
|
|
603
|
+
print("Output options:", file=file)
|
|
604
|
+
for decl in OUTPUT_OPTIONS:
|
|
605
|
+
flag = f"--{decl.name}"
|
|
606
|
+
if decl.short_name:
|
|
607
|
+
flag += f", -{decl.short_name}"
|
|
608
|
+
extra = ""
|
|
609
|
+
if decl.choices:
|
|
610
|
+
extra = f" ({', '.join(decl.choices)})"
|
|
611
|
+
print(f" {flag:<16}{decl.help_text}{extra}", file=file)
|
|
612
|
+
print(file=file)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Namespace command implementations.
|
|
2
|
+
|
|
3
|
+
Each sub-package exports a create_*_namespace() factory function
|
|
4
|
+
that returns a configured Namespace with registered commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from kd.cli.core.namespaces import NamespaceRegistry, apply_synonyms, load_synonyms
|
|
8
|
+
from kd.cli.namespaces.alias import create_alias_namespace
|
|
9
|
+
from kd.cli.namespaces.article import create_article_namespace
|
|
10
|
+
from kd.cli.namespaces.init import InitCommand
|
|
11
|
+
from kd.cli.namespaces.issue import create_issue_namespace
|
|
12
|
+
from kd.cli.namespaces.primer import create_primer_namespace
|
|
13
|
+
from kd.cli.namespaces.project import create_project_namespace
|
|
14
|
+
from kd.cli.namespaces.query import create_query_namespace
|
|
15
|
+
from kd.cli.namespaces.search import SearchCommand
|
|
16
|
+
from kd.cli.namespaces.system import create_system_namespace
|
|
17
|
+
from kd.cli.namespaces.tag import create_tag_namespace
|
|
18
|
+
from kd.cli.namespaces.time import create_time_namespace
|
|
19
|
+
from kd.cli.namespaces.user import create_user_namespace
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_all_namespaces(registry: NamespaceRegistry) -> None:
|
|
23
|
+
"""Register all namespace trees in the registry."""
|
|
24
|
+
registry.root.register_command("init", InitCommand())
|
|
25
|
+
registry.root.register_command("search", SearchCommand())
|
|
26
|
+
registry.register_namespace(create_alias_namespace())
|
|
27
|
+
registry.register_namespace(create_article_namespace())
|
|
28
|
+
registry.register_namespace(create_system_namespace())
|
|
29
|
+
registry.register_namespace(create_project_namespace())
|
|
30
|
+
registry.register_namespace(create_issue_namespace())
|
|
31
|
+
registry.register_namespace(create_primer_namespace())
|
|
32
|
+
registry.register_namespace(create_query_namespace())
|
|
33
|
+
registry.register_namespace(create_tag_namespace())
|
|
34
|
+
registry.register_namespace(create_user_namespace())
|
|
35
|
+
registry.register_namespace(create_time_namespace())
|
|
36
|
+
|
|
37
|
+
# Apply built-in command synonyms (YTCTL-8)
|
|
38
|
+
apply_synonyms(registry.root, load_synonyms())
|