kd-cli 2.9.0__tar.gz → 2.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {kd_cli-2.9.0/kd_cli.egg-info → kd_cli-2.10.0}/PKG-INFO +2 -2
  2. {kd_cli-2.9.0 → kd_cli-2.10.0}/README.md +1 -1
  3. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/api/http.py +18 -0
  4. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/article_marker.py +23 -9
  5. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/core/command_base.py +18 -0
  6. kd_cli-2.10.0/kd/cli/core/command_parser.py +348 -0
  7. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/core/option_registry.py +24 -8
  8. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/article/__init__.py +25 -9
  9. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/init.py +1 -0
  10. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/issue/__init__.py +66 -24
  11. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/primer/__init__.py +57 -9
  12. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/project/__init__.py +11 -11
  13. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/query/__init__.py +14 -18
  14. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/system/__init__.py +5 -2
  15. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/tag/__init__.py +7 -3
  16. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/time/__init__.py +2 -2
  17. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/user/__init__.py +5 -6
  18. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/output.py +56 -19
  19. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/client.py +85 -8
  20. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/CHANGELOG.md +177 -0
  21. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/error-messages.yaml +61 -0
  22. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/operating-guide.md +60 -7
  23. {kd_cli-2.9.0 → kd_cli-2.10.0/kd_cli.egg-info}/PKG-INFO +2 -2
  24. {kd_cli-2.9.0 → kd_cli-2.10.0}/pyproject.toml +2 -1
  25. kd_cli-2.9.0/kd/cli/core/command_parser.py +0 -246
  26. {kd_cli-2.9.0 → kd_cli-2.10.0}/LICENSE +0 -0
  27. {kd_cli-2.9.0 → kd_cli-2.10.0}/MANIFEST.in +0 -0
  28. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/__init__.py +0 -0
  29. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/api/__init__.py +0 -0
  30. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/api/articles.py +0 -0
  31. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/api/hub.py +0 -0
  32. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/api/saved_queries.py +0 -0
  33. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/api/tags.py +0 -0
  34. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/api/youtrack.py +0 -0
  35. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/auth.py +0 -0
  36. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cache.py +0 -0
  37. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/__init__.py +0 -0
  38. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/core/__init__.py +0 -0
  39. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/core/confirm.py +0 -0
  40. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/core/namespaces.py +0 -0
  41. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/input.py +0 -0
  42. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/main.py +0 -0
  43. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/__init__.py +0 -0
  44. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  45. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/cache/__init__.py +0 -0
  46. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/release_notes.py +0 -0
  47. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/namespaces/search/__init__.py +0 -0
  48. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/cli/tree.py +0 -0
  49. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/config.py +0 -0
  50. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/config_read.py +0 -0
  51. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/config_schema.py +0 -0
  52. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/connection.py +0 -0
  53. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/debug.py +0 -0
  54. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/exceptions.py +0 -0
  55. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/history.py +0 -0
  56. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/locking.py +0 -0
  57. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/__init__.py +0 -0
  58. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/article.py +0 -0
  59. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/assist.py +0 -0
  60. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/attachment.py +0 -0
  61. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/comment.py +0 -0
  62. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/common.py +0 -0
  63. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/issue.py +0 -0
  64. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/project.py +0 -0
  65. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/saved_query.py +0 -0
  66. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/search.py +0 -0
  67. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/tag.py +0 -0
  68. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/tag_entities.py +0 -0
  69. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/user.py +0 -0
  70. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/models/work_item.py +0 -0
  71. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/operating_guide.py +0 -0
  72. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/primer.py +0 -0
  73. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/query_parser.py +0 -0
  74. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/release_notes.py +0 -0
  75. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/__init__.py +0 -0
  76. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/command-synonyms.yaml +0 -0
  77. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/config-template.yaml +0 -0
  78. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/primers/__init__.py +0 -0
  79. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/primers/hitl.yaml +0 -0
  80. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/resources/sdlc-doctrine.md +0 -0
  81. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd/sdlc.py +0 -0
  82. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd_cli.egg-info/SOURCES.txt +0 -0
  83. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  84. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd_cli.egg-info/entry_points.txt +0 -0
  85. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd_cli.egg-info/requires.txt +0 -0
  86. {kd_cli-2.9.0 → kd_cli-2.10.0}/kd_cli.egg-info/top_level.txt +0 -0
  87. {kd_cli-2.9.0 → kd_cli-2.10.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.9.0
3
+ Version: 2.10.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -167,7 +167,7 @@ pytest tests/ -v --tb=short
167
167
 
168
168
  ruff format kd/ tests/
169
169
  ruff check --fix kd/ tests/
170
- mypy kd/
170
+ uv run mypy
171
171
  ```
172
172
 
173
173
  ---
@@ -127,7 +127,7 @@ pytest tests/ -v --tb=short
127
127
 
128
128
  ruff format kd/ tests/
129
129
  ruff check --fix kd/ tests/
130
- mypy kd/
130
+ uv run mypy
131
131
  ```
132
132
 
133
133
  ---
@@ -23,6 +23,7 @@ from kd.exceptions import (
23
23
  ConflictError,
24
24
  NotFoundError,
25
25
  PermissionDeniedError,
26
+ ValidationError,
26
27
  enrich_error,
27
28
  )
28
29
 
@@ -30,6 +31,22 @@ _RETRYABLE_STATUSES = {429, 502, 503}
30
31
  _MAX_RETRIES = 3
31
32
 
32
33
 
34
+ def _refuse_control_characters(path: str) -> None:
35
+ """Refuse a path that no URL can carry, before it is sent.
36
+
37
+ The refused set is exactly what httpx rejects as non-printable ASCII:
38
+ C0 controls and DEL. The user values kd puts in a path are entity ids,
39
+ one per segment, so the error names the segment that carries the
40
+ character, escaped onto one line.
41
+ """
42
+ for segment in path.split("/"):
43
+ if any(c < " " or c == "\x7f" for c in segment):
44
+ raise ValidationError(
45
+ f"Invalid id {segment!r}: it contains a control character "
46
+ "(line break, tab or similar). Pass one id per argument."
47
+ )
48
+
49
+
33
50
  class HttpClient:
34
51
  """Low-level HTTP client with auth, retry, and error mapping."""
35
52
 
@@ -116,6 +133,7 @@ class HttpClient:
116
133
 
117
134
  def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
118
135
  """Execute request with retry and error mapping."""
136
+ _refuse_control_characters(path)
119
137
  d = self._debug
120
138
 
121
139
  # Log request details before first attempt
@@ -18,6 +18,9 @@ Grammar (informal):
18
18
  Rules (pinned down by ``tests/test_article_marker.py``):
19
19
 
20
20
  - Open and close lines must be exact (``<!-- kd-meta`` / ``-->``).
21
+ - Only a marker that opens on the first non-blank line counts; a
22
+ marker-open line after body content is body. A second marker block
23
+ directly below the top one (only blank lines between) is an error.
21
24
  - Keys match ``[a-z][a-z0-9-]*``; uppercase and underscores are
22
25
  rejected to keep the grammar ASCII-boring.
23
26
  - Unknown keys are preserved verbatim on round-trip — the load-bearing
@@ -49,8 +52,9 @@ Legacy marker synonym (transitional, proposal 28 / M003 §S4):
49
52
  upstream population of ``ytctl-meta`` is empty. No auto-disable,
50
53
  no timer — a deliberate future milestone.
51
54
 
52
- Stdlib only; no third-party deps. The push workflow (in
53
- ``kd.client.ArticleOperations.push``) and its tests are the only
55
+ Stdlib only; no third-party deps. The push workflow
56
+ (``kd.client.ArticleOperations.push``), ``/article update``
57
+ (``kd.client.ArticleOperations.update``) and their tests are the only
54
58
  importers.
55
59
  """
56
60
 
@@ -248,13 +252,17 @@ def parse_content(text: str) -> ParsedFile:
248
252
  if close_idx is None:
249
253
  raise MarkerError("marker block not closed (expected '-->' on its own line)")
250
254
 
251
- # Refuse a second marker anywhere later in the file — native or
252
- # legacy. Prevents a stray ``<!-- ytctl-meta`` further down from
253
- # being silently accepted as body content on a file that also
254
- # carries a native header at the top.
255
- for k in range(close_idx + 1, len(lines)):
256
- if lines[k] in _RECOGNIZED_OPENS:
257
- raise MarkerError("file contains more than one kd-meta block")
255
+ # A second marker block with only blank lines above it would become
256
+ # the identity once the top block is stripped (``/article update
257
+ # --content``) or edited away, so it is refused. A marker-open line
258
+ # after body content stays body. This check only refuses, so it
259
+ # compares leniently (whitespace, stray CR, BOM): an editor or
260
+ # encoding round trip can turn a near-exact open into an exact one.
261
+ k = close_idx + 1
262
+ while k < len(lines) and _loose(lines[k]) == "":
263
+ k += 1
264
+ if k < len(lines) and _loose(lines[k]) in _RECOGNIZED_OPENS:
265
+ raise MarkerError("a second kd-meta block directly follows the top marker; keep one")
258
266
 
259
267
  # Parse key:value lines between open and close.
260
268
  marker = ArticleMarker()
@@ -291,6 +299,12 @@ def parse_content(text: str) -> ParsedFile:
291
299
  )
292
300
 
293
301
 
302
+ def _loose(line: str) -> str:
303
+ """Normalize a line for the stacked-block refusal only: drop BOMs and
304
+ surrounding whitespace. Never used to detect the top marker."""
305
+ return line.replace("\ufeff", "").strip()
306
+
307
+
294
308
  def _join_body(lines: list[str], trailing_newline: bool) -> str:
295
309
  """Join body lines with ``\\n``, restoring the final terminator if any."""
296
310
  if not lines:
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
11
11
  from typing import TYPE_CHECKING, Any
12
12
 
13
13
  from kd.cli.core.option_registry import OptionDeclaration, ResolvedOptions
14
+ from kd.exceptions import ValidationError
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from kd.config import ResolvedConfig
@@ -20,6 +21,18 @@ if TYPE_CHECKING:
20
21
  CommandResult = list[dict[str, Any]] | dict[str, Any] | str | None
21
22
 
22
23
 
24
+ def reject_extra_args(args: list[str], allowed: int, usage: str) -> None:
25
+ """Refuse positionals beyond the first *allowed* ones.
26
+
27
+ For commands whose positional count is fixed and whose effect is
28
+ outward or irreversible: a word left over by the parser (for example
29
+ ``off`` after a boolean option) must not be dropped silently. The
30
+ error names the first extra word and the command's usage line.
31
+ """
32
+ if len(args) > allowed:
33
+ raise ValidationError(f"Unexpected argument '{args[allowed]}'. Usage: {usage}")
34
+
35
+
23
36
  class Command(ABC):
24
37
  """Base class for all CLI commands."""
25
38
 
@@ -31,6 +44,7 @@ class Command(ABC):
31
44
  requires_client: bool = False,
32
45
  requires_config: bool = False,
33
46
  usage: str = "",
47
+ joins_text: bool = False,
34
48
  long_description: str = "",
35
49
  examples: list[str] | None = None,
36
50
  see_also: list[str] | None = None,
@@ -40,6 +54,10 @@ class Command(ABC):
40
54
  self.requires_client = requires_client
41
55
  self.requires_config = requires_config
42
56
  self.usage = usage
57
+ # The command joins its arguments into one text (a summary, comment,
58
+ # note, name or query). The parser then refuses a bare switch that is
59
+ # directly followed by a word when an argument comes before it.
60
+ self.joins_text = joins_text
43
61
  self.long_description = long_description
44
62
  self.examples: list[str] = examples or []
45
63
  self.see_also: list[str] = see_also or []
@@ -0,0 +1,348 @@
1
+ """Two-phase command parser.
2
+
3
+ Phase 1 (parse_command): Resolve namespace + command from positional
4
+ args and tokenize options into a raw dict. No validation.
5
+
6
+ Phase 2 (resolve_options): Validate types, choices, and required
7
+ options, producing the final ResolvedOptions.
8
+
9
+ The convenience method ``parse()`` runs both phases in one call.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from kd.cli.core.command_base import Command
18
+ from kd.cli.core.namespaces import Namespace, NamespaceRegistry
19
+ from kd.cli.core.option_registry import (
20
+ OUTPUT_OPTIONS,
21
+ OptionError,
22
+ OptionRegistry,
23
+ ResolvedOptions,
24
+ is_spaced_bool_word,
25
+ )
26
+
27
+
28
+ class ParseError(Exception):
29
+ """Raised when command parsing fails."""
30
+
31
+
32
+ @dataclass
33
+ class UnresolvedCommand:
34
+ """Phase-1 result: command identified, options tokenized but not validated."""
35
+
36
+ namespace: Namespace
37
+ command_name: str
38
+ command: Command | None # None if command not found
39
+ args: list[str] = field(default_factory=list)
40
+ raw_options: dict[str, Any] = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class ParsedCommand:
45
+ """Result of parsing a command line."""
46
+
47
+ namespace: Namespace
48
+ command_name: str
49
+ command: Command | None # None if command not found
50
+ args: list[str] = field(default_factory=list)
51
+ options: ResolvedOptions = field(default_factory=ResolvedOptions)
52
+ raw_options: dict[str, Any] = field(default_factory=dict)
53
+
54
+
55
+ def _is_option_token(token: str) -> bool:
56
+ """``-x`` / ``--name`` style token; a lone ``-`` is a value (stdin convention)."""
57
+ return token.startswith("-") and len(token) > 1
58
+
59
+
60
+ class CommandParser:
61
+ """Parses argv into a resolved command with options."""
62
+
63
+ def __init__(self, registry: NamespaceRegistry) -> None:
64
+ self._registry = registry
65
+
66
+ def parse_command(self, argv: list[str]) -> UnresolvedCommand:
67
+ """Phase 1: resolve command and tokenize options. No validation.
68
+
69
+ 1. Split argv knowing only the output options, and resolve the
70
+ namespace + command from the positionals
71
+ 2. Split argv again with that command's option registry, so each
72
+ option takes exactly the tokens it declares and positionals keep
73
+ the order they were typed in
74
+ 3. Parse the option tokens into a raw dict
75
+
76
+ The command's options are not known until its name is read. The two
77
+ splits resolve different commands only when a switch of the command
78
+ sits before the command name and the first split let it take a word
79
+ that is not ``true``/``false``; that form is refused with a message
80
+ naming the form that works. A switch directly before a namespace or
81
+ command word swallows that word in the first split too, so the
82
+ command is never found and the usual unknown-command path reports it.
83
+
84
+ On a command that joins its arguments into one text
85
+ (``Command.joins_text``), a bare switch directly followed by a word
86
+ is refused when any argument, the id included, comes before the
87
+ switch: the word would otherwise become text or the target. ``--``,
88
+ an option between the switch and the word, the ``=`` form and a
89
+ ``true``/``false`` the switch takes all break the adjacency.
90
+ """
91
+ first_positionals, first_tokens, _ = self._split_args(
92
+ argv, self._build_option_registry(None)
93
+ )
94
+ namespace, command_name, _ = self._registry.resolve(first_positionals)
95
+ command = namespace.get_command(command_name) if command_name else None
96
+
97
+ option_reg = self._build_option_registry(command)
98
+ positionals, option_tokens, after_switch = self._split_args(argv, option_reg)
99
+ second_namespace, second_name, remaining = self._registry.resolve(positionals)
100
+ if second_namespace is not namespace or second_name != command_name:
101
+ raise ParseError(
102
+ self._misplaced_switch_message(
103
+ first_tokens, option_tokens, namespace, command_name, option_reg
104
+ )
105
+ )
106
+ if command is not None and command.joins_text:
107
+ self._reject_switch_between_args(
108
+ positionals, len(positionals) - len(remaining), after_switch, command, option_reg
109
+ )
110
+
111
+ raw_options = self._parse_option_tokens(option_tokens, option_reg)
112
+
113
+ return UnresolvedCommand(
114
+ namespace=namespace,
115
+ command_name=command_name,
116
+ command=command,
117
+ args=remaining,
118
+ raw_options=raw_options,
119
+ )
120
+
121
+ def resolve_options(self, unresolved: UnresolvedCommand) -> ParsedCommand:
122
+ """Phase 2: validate and resolve options.
123
+
124
+ Builds the option registry, validates types/choices/required,
125
+ and returns a fully resolved ``ParsedCommand``.
126
+ """
127
+ option_reg = self._build_option_registry(unresolved.command)
128
+ try:
129
+ resolved = option_reg.resolve(unresolved.raw_options)
130
+ except OptionError as e:
131
+ raise ParseError(str(e)) from e
132
+
133
+ return ParsedCommand(
134
+ namespace=unresolved.namespace,
135
+ command_name=unresolved.command_name,
136
+ command=unresolved.command,
137
+ args=unresolved.args,
138
+ options=resolved,
139
+ raw_options=unresolved.raw_options,
140
+ )
141
+
142
+ def parse(self, argv: list[str]) -> ParsedCommand:
143
+ """Full parse: resolve command + validate options in one call.
144
+
145
+ Convenience wrapper equivalent to ``parse_command()`` then
146
+ ``resolve_options()``.
147
+ """
148
+ return self.resolve_options(self.parse_command(argv))
149
+
150
+ def _split_args(
151
+ self, argv: list[str], registry: OptionRegistry
152
+ ) -> tuple[list[str], list[str], list[tuple[int, str]]]:
153
+ """Split argv into positional args, option tokens, and switch-adjacent words.
154
+
155
+ Supports GNU-style mixed placement: options can appear anywhere
156
+ in the command line (before, between, or after positionals).
157
+
158
+ Slash-path expansion: the first positional token starting with
159
+ ``/`` is expanded into namespace segments (``/a/b`` → ``a``, ``b``).
160
+ Once any positional has been emitted, later ``/``-prefixed tokens
161
+ are treated as literal arguments (file paths, etc.).
162
+ A bare ``/`` expands to nothing (root namespace).
163
+
164
+ The ``--`` separator ends option parsing: everything after it
165
+ is positional.
166
+
167
+ Which token an option takes comes from *registry*: a boolean
168
+ option takes the next token only when it is ``true`` or ``false``
169
+ (any case); any other option, known or not, takes the next token
170
+ unless it is ``--`` or another option. A lone ``-`` is a value
171
+ (stdin convention). An unknown option is reported later by
172
+ ``_parse_option_tokens`` or phase 2.
173
+
174
+ The third value lists each positional that directly follows a bare
175
+ switch (a boolean option without ``=`` that took no token), as its
176
+ index in the positionals and the switch token. A word after ``--``
177
+ or after another option is never listed.
178
+ """
179
+ positionals: list[str] = []
180
+ option_tokens: list[str] = []
181
+ after_switch: list[tuple[int, str]] = []
182
+
183
+ i = 0
184
+ while i < len(argv):
185
+ token = argv[i]
186
+
187
+ # -- separator: everything after is positional
188
+ if token == "--":
189
+ positionals.extend(argv[i + 1 :])
190
+ break
191
+
192
+ if _is_option_token(token):
193
+ option_tokens.append(token)
194
+ if (
195
+ "=" not in token
196
+ and i + 1 < len(argv)
197
+ and self._takes_next(token, argv[i + 1], registry)
198
+ ):
199
+ option_tokens.append(argv[i + 1])
200
+ i += 2
201
+ continue
202
+ # A bare switch directly followed by a word (not an option, ``--`` included).
203
+ if (
204
+ "=" not in token
205
+ and i + 1 < len(argv)
206
+ and not _is_option_token(argv[i + 1])
207
+ and registry.is_boolean_flag(token.lstrip("-"))
208
+ ):
209
+ after_switch.append((len(positionals), token))
210
+ i += 1
211
+ else:
212
+ # Positional token — expand slash path only for the first one
213
+ if not positionals and token.startswith("/"):
214
+ segments = [s for s in token.split("/") if s]
215
+ positionals.extend(segments)
216
+ else:
217
+ positionals.append(token)
218
+ i += 1
219
+
220
+ return positionals, option_tokens, after_switch
221
+
222
+ @staticmethod
223
+ def _reject_switch_between_args(
224
+ positionals: list[str],
225
+ offset: int,
226
+ after_switch: list[tuple[int, str]],
227
+ command: Command,
228
+ registry: OptionRegistry,
229
+ ) -> None:
230
+ """Refuse a word right after a bare switch when an argument precedes it.
231
+
232
+ *offset* is the number of namespace and command tokens at the front
233
+ of *positionals*, so ``index - offset`` is the word's place in the
234
+ command's arguments; anything past the first has an argument before
235
+ the switch.
236
+ """
237
+ for index, switch in after_switch:
238
+ if index - offset > 0:
239
+ name = switch.lstrip("-")
240
+ canonical = registry.resolve_name(name) or name
241
+ raise ParseError(
242
+ f"Unexpected argument '{positionals[index]}' after --{canonical}: "
243
+ f"a switch takes no word after it. Put --{canonical} after the "
244
+ f"last argument, or write --{canonical}=false. Usage: {command.usage}"
245
+ )
246
+
247
+ @staticmethod
248
+ def _takes_next(token: str, next_token: str, registry: OptionRegistry) -> bool:
249
+ """Whether option *token* takes *next_token* as its value."""
250
+ if registry.is_boolean_flag(token.lstrip("-")):
251
+ return is_spaced_bool_word(next_token)
252
+ return next_token != "--" and not _is_option_token(next_token)
253
+
254
+ def _build_option_registry(self, command: Command | None) -> OptionRegistry:
255
+ """Build option registry from command declarations + standard OUTPUT options."""
256
+ registry = OptionRegistry()
257
+ registry.register_all(OUTPUT_OPTIONS)
258
+ if command:
259
+ registry.register_all(command.get_options())
260
+ return registry
261
+
262
+ def _parse_option_tokens(self, tokens: list[str], registry: OptionRegistry) -> dict[str, Any]:
263
+ """Parse option tokens into a raw name->value dict.
264
+
265
+ Handles: --key value, --key=value, -k value, -k=value, and boolean
266
+ options bare or followed by their value. ``_split_args`` only places
267
+ a non-option token right after the option that takes it, so a
268
+ following non-option token is always that option's value.
269
+ """
270
+ raw: dict[str, Any] = {}
271
+ i = 0
272
+
273
+ while i < len(tokens):
274
+ token = tokens[i]
275
+
276
+ if "=" in token:
277
+ # --key=value or -k=value
278
+ name_part, value = token.split("=", 1)
279
+ name = name_part.lstrip("-")
280
+ canonical = registry.resolve_name(name) or name
281
+ self._set_option(raw, canonical, value, registry)
282
+ i += 1
283
+ continue
284
+
285
+ name = token.lstrip("-")
286
+ canonical = registry.resolve_name(name) or name
287
+ has_value = i + 1 < len(tokens) and not _is_option_token(tokens[i + 1])
288
+
289
+ if registry.is_boolean_flag(name):
290
+ # Bare boolean option means true; a value was paired by _split_args.
291
+ if has_value:
292
+ self._set_option(raw, canonical, tokens[i + 1], registry)
293
+ i += 2
294
+ else:
295
+ raw[canonical] = True
296
+ i += 1
297
+ continue
298
+
299
+ if has_value:
300
+ self._set_option(raw, canonical, tokens[i + 1], registry)
301
+ i += 2
302
+ elif registry.get_declaration(name) is not None:
303
+ raise ParseError(f"Option --{canonical} requires a value")
304
+ else:
305
+ raise ParseError(f"Unknown option: --{name}")
306
+
307
+ return raw
308
+
309
+ @staticmethod
310
+ def _misplaced_switch_message(
311
+ first_tokens: list[str],
312
+ second_tokens: list[str],
313
+ namespace: Namespace,
314
+ command_name: str,
315
+ registry: OptionRegistry,
316
+ ) -> str:
317
+ """Name the switch written before its command name, and the working form.
318
+
319
+ The two splits list the same option tokens up to the word the first
320
+ split let an option take and the second did not; that option is the
321
+ switch. Both lists come from ``_split_args``, which places a taken
322
+ word right after its option.
323
+ """
324
+ index = next(
325
+ (
326
+ i
327
+ for i in range(len(first_tokens))
328
+ if i >= len(second_tokens) or first_tokens[i] != second_tokens[i]
329
+ ),
330
+ len(first_tokens),
331
+ )
332
+ switch = first_tokens[index - 1] if index > 0 else ""
333
+ assert _is_option_token(switch), "splits diverged without an option taking a word"
334
+ name = switch.lstrip("-")
335
+ canonical = registry.resolve_name(name) or name
336
+ display = f"/{namespace.path} {command_name}" if namespace.path else command_name
337
+ return (
338
+ f"Option --{canonical} belongs to {display}; put it after the "
339
+ f"command name: kd {display} ... --{canonical}"
340
+ )
341
+
342
+ @staticmethod
343
+ def _set_option(raw: dict[str, Any], name: str, value: str, registry: OptionRegistry) -> None:
344
+ """Set an option value, appending to a list for repeatable options."""
345
+ if registry.is_list_option(name):
346
+ raw.setdefault(name, []).append(value)
347
+ else:
348
+ raw[name] = value
@@ -15,6 +15,19 @@ from dataclasses import dataclass, field
15
15
  from enum import Enum
16
16
  from typing import Any
17
17
 
18
+ # Words a boolean option accepts as its value. The ``=`` form
19
+ # (``--flag=no``) reads all six. The space form (``--flag false``) reads
20
+ # only the two documented literals: there the next token may be a
21
+ # positional instead (an INDEX of ``1``, prose that starts with "no").
22
+ _BOOL_TRUE_WORDS = ("true", "1", "yes")
23
+ _BOOL_FALSE_WORDS = ("false", "0", "no")
24
+ _SPACED_BOOL_WORDS = ("true", "false")
25
+
26
+
27
+ def is_spaced_bool_word(token: str) -> bool:
28
+ """Whether a boolean option takes *token*, the next argv token, as its value."""
29
+ return token.lower() in _SPACED_BOOL_WORDS
30
+
18
31
 
19
32
  class OptionLayer(Enum):
20
33
  """Option layers — separates options by concern."""
@@ -92,7 +105,7 @@ class OptionRegistry:
92
105
  return self._short_names.get(name) or self._aliases.get(name)
93
106
 
94
107
  def is_boolean_flag(self, name: str) -> bool:
95
- """Check if an option is a boolean flag (no value expected)."""
108
+ """Check if an option is boolean: true when bare, or set by a following true/false."""
96
109
  decl = self.get_declaration(name)
97
110
  return decl is not None and decl.type is bool
98
111
 
@@ -144,9 +157,9 @@ class OptionRegistry:
144
157
  if isinstance(value, bool):
145
158
  return value
146
159
  if isinstance(value, str):
147
- if value.lower() in ("true", "1", "yes"):
160
+ if value.lower() in _BOOL_TRUE_WORDS:
148
161
  return True
149
- if value.lower() in ("false", "0", "no"):
162
+ if value.lower() in _BOOL_FALSE_WORDS:
150
163
  return False
151
164
  raise OptionError(f"Invalid boolean value for --{decl.name}: {value}")
152
165
 
@@ -249,9 +262,11 @@ def state_option(
249
262
  # Standard OUTPUT options available to all commands.
250
263
  #
251
264
  # ``--quiet`` is registered globally so any command's parser accepts it,
252
- # but it only affects mutation commands that opt into the write-output
253
- # contract (``display_hint="mutation"``). On reads and structured output
254
- # modes (``-o yaml`` / ``-o json``), ``--quiet`` is silently no-op.
265
+ # but it only affects writes: a write on the write-output contract
266
+ # (``display_hint="mutation"``) prints only its ``ok:`` line, and a write
267
+ # outside it (``display_hint="write"``) has none, so it prints nothing. On
268
+ # reads and structured output modes (``-o yaml`` / ``-o json``),
269
+ # ``--quiet`` is silently no-op.
255
270
  OUTPUT_OPTIONS: list[OptionDeclaration] = [
256
271
  OptionDeclaration(
257
272
  name="output",
@@ -274,8 +289,9 @@ OUTPUT_OPTIONS: list[OptionDeclaration] = [
274
289
  type=bool,
275
290
  default=False,
276
291
  help_text=(
277
- "Suppress detail block on mutation commands; print only the "
278
- "single-line ack. No-op on reads and structured modes (-o yaml/json)."
292
+ "Print only the ok: line of a write; a write without one (the /primer "
293
+ "writes, kd init, /system/config set, /system restore, /tag add) prints "
294
+ "nothing. No-op on reads and structured modes (-o yaml/json)."
279
295
  ),
280
296
  ),
281
297
  ]