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/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())