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
@@ -0,0 +1,246 @@
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
+ )
25
+
26
+
27
+ class ParseError(Exception):
28
+ """Raised when command parsing fails."""
29
+
30
+
31
+ @dataclass
32
+ class UnresolvedCommand:
33
+ """Phase-1 result: command identified, options tokenized but not validated."""
34
+
35
+ namespace: Namespace
36
+ command_name: str
37
+ command: Command | None # None if command not found
38
+ args: list[str] = field(default_factory=list)
39
+ raw_options: dict[str, Any] = field(default_factory=dict)
40
+
41
+
42
+ @dataclass
43
+ class ParsedCommand:
44
+ """Result of parsing a command line."""
45
+
46
+ namespace: Namespace
47
+ command_name: str
48
+ command: Command | None # None if command not found
49
+ args: list[str] = field(default_factory=list)
50
+ options: ResolvedOptions = field(default_factory=ResolvedOptions)
51
+ raw_options: dict[str, Any] = field(default_factory=dict)
52
+
53
+
54
+ class CommandParser:
55
+ """Parses argv into a resolved command with options."""
56
+
57
+ def __init__(self, registry: NamespaceRegistry) -> None:
58
+ self._registry = registry
59
+
60
+ def parse_command(self, argv: list[str]) -> UnresolvedCommand:
61
+ """Phase 1: resolve command and tokenize options. No validation.
62
+
63
+ 1. Split argv into positionals and option tokens
64
+ 2. Resolve namespace + command from positionals
65
+ 3. Build option registry from command declarations + OUTPUT_OPTIONS
66
+ 4. Parse option tokens into a raw dict (recovering misclassified positionals)
67
+ """
68
+ positionals, option_tokens = self._split_args(argv)
69
+ namespace, command_name, remaining = self._registry.resolve(positionals)
70
+
71
+ command = namespace.get_command(command_name) if command_name else None
72
+
73
+ option_reg = self._build_option_registry(command)
74
+ raw_options, recovered = self._parse_option_tokens(option_tokens, option_reg)
75
+
76
+ # Recovered positionals are tokens that _split_args heuristically
77
+ # consumed as option values but were actually after boolean flags.
78
+ # Prepend them to args so the command sees them.
79
+ if recovered:
80
+ remaining = recovered + remaining
81
+
82
+ return UnresolvedCommand(
83
+ namespace=namespace,
84
+ command_name=command_name,
85
+ command=command,
86
+ args=remaining,
87
+ raw_options=raw_options,
88
+ )
89
+
90
+ def resolve_options(self, unresolved: UnresolvedCommand) -> ParsedCommand:
91
+ """Phase 2: validate and resolve options.
92
+
93
+ Builds the option registry, validates types/choices/required,
94
+ and returns a fully resolved ``ParsedCommand``.
95
+ """
96
+ option_reg = self._build_option_registry(unresolved.command)
97
+ try:
98
+ resolved = option_reg.resolve(unresolved.raw_options)
99
+ except OptionError as e:
100
+ raise ParseError(str(e)) from e
101
+
102
+ return ParsedCommand(
103
+ namespace=unresolved.namespace,
104
+ command_name=unresolved.command_name,
105
+ command=unresolved.command,
106
+ args=unresolved.args,
107
+ options=resolved,
108
+ raw_options=unresolved.raw_options,
109
+ )
110
+
111
+ def parse(self, argv: list[str]) -> ParsedCommand:
112
+ """Full parse: resolve command + validate options in one call.
113
+
114
+ Convenience wrapper equivalent to ``parse_command()`` then
115
+ ``resolve_options()``.
116
+ """
117
+ return self.resolve_options(self.parse_command(argv))
118
+
119
+ def _split_args(self, argv: list[str]) -> tuple[list[str], list[str]]:
120
+ """Split argv into positional args and option tokens.
121
+
122
+ Supports GNU-style mixed placement: options can appear anywhere
123
+ in the command line (before, between, or after positionals).
124
+
125
+ Slash-path expansion: the first positional token starting with
126
+ ``/`` is expanded into namespace segments (``/a/b`` → ``a``, ``b``).
127
+ Once any positional has been emitted, later ``/``-prefixed tokens
128
+ are treated as literal arguments (file paths, etc.).
129
+ A bare ``/`` expands to nothing (root namespace).
130
+
131
+ The ``--`` separator ends option parsing: everything after it
132
+ is positional.
133
+
134
+ Heuristic: any ``--option`` followed by a non-dash token consumes
135
+ it as a value. If the option turns out to be a boolean flag,
136
+ ``_parse_option_tokens`` recovers the stray value (see there).
137
+ A lone ``-`` is consumed as a value (stdin convention).
138
+ """
139
+ positionals: list[str] = []
140
+ option_tokens: list[str] = []
141
+
142
+ i = 0
143
+ while i < len(argv):
144
+ token = argv[i]
145
+
146
+ # -- separator: everything after is positional
147
+ if token == "--":
148
+ positionals.extend(argv[i + 1 :])
149
+ break
150
+
151
+ if token.startswith("-") and len(token) > 1:
152
+ # Option token
153
+ option_tokens.append(token)
154
+
155
+ # If not --key=value form, consume next token as value
156
+ # (heuristic: assume non-dash options take a value)
157
+ if "=" not in token and i + 1 < len(argv):
158
+ next_token = argv[i + 1]
159
+ if next_token != "--" and not (
160
+ next_token.startswith("-") and len(next_token) > 1
161
+ ):
162
+ option_tokens.append(next_token)
163
+ i += 2
164
+ continue
165
+ i += 1
166
+ else:
167
+ # Positional token — expand slash path only for the first one
168
+ if not positionals and token.startswith("/"):
169
+ segments = [s for s in token.split("/") if s]
170
+ positionals.extend(segments)
171
+ else:
172
+ positionals.append(token)
173
+ i += 1
174
+
175
+ return positionals, option_tokens
176
+
177
+ def _build_option_registry(self, command: Command | None) -> OptionRegistry:
178
+ """Build option registry from command declarations + standard OUTPUT options."""
179
+ registry = OptionRegistry()
180
+ registry.register_all(OUTPUT_OPTIONS)
181
+ if command:
182
+ registry.register_all(command.get_options())
183
+ return registry
184
+
185
+ def _parse_option_tokens(
186
+ self, tokens: list[str], registry: OptionRegistry
187
+ ) -> tuple[dict[str, Any], list[str]]:
188
+ """Parse option tokens into a raw name->value dict.
189
+
190
+ Handles: --key value, --key=value, -k value, -k=value, boolean flags.
191
+
192
+ Returns (raw_options, recovered_positionals). Recovered positionals
193
+ are tokens that ``_split_args`` heuristically consumed as option values
194
+ but actually follow boolean flags — they belong in the positional stream.
195
+ """
196
+ raw: dict[str, Any] = {}
197
+ recovered: list[str] = []
198
+ i = 0
199
+
200
+ while i < len(tokens):
201
+ token = tokens[i]
202
+
203
+ if "=" in token:
204
+ # --key=value or -k=value
205
+ name_part, value = token.split("=", 1)
206
+ name = name_part.lstrip("-")
207
+ canonical = registry.resolve_name(name) or name
208
+ self._set_option(raw, canonical, value, registry)
209
+ i += 1
210
+ continue
211
+
212
+ if not token.startswith("-") or len(token) == 1:
213
+ # Stray positional (includes lone `-`) — recover for command args
214
+ recovered.append(token)
215
+ i += 1
216
+ continue
217
+
218
+ name = token.lstrip("-")
219
+ canonical = registry.resolve_name(name) or name
220
+
221
+ # Boolean flag: no value consumed
222
+ if registry.is_boolean_flag(name):
223
+ raw[canonical] = True
224
+ i += 1
225
+ continue
226
+
227
+ # Regular option: consume next token as value (lone `-` is valid)
228
+ if i + 1 < len(tokens) and not (
229
+ tokens[i + 1].startswith("-") and len(tokens[i + 1]) > 1
230
+ ):
231
+ self._set_option(raw, canonical, tokens[i + 1], registry)
232
+ i += 2
233
+ elif registry.get_declaration(name) is not None:
234
+ raise ParseError(f"Option --{canonical} requires a value")
235
+ else:
236
+ raise ParseError(f"Unknown option: --{name}")
237
+
238
+ return raw, recovered
239
+
240
+ @staticmethod
241
+ def _set_option(raw: dict[str, Any], name: str, value: str, registry: OptionRegistry) -> None:
242
+ """Set an option value, appending to a list for repeatable options."""
243
+ if registry.is_list_option(name):
244
+ raw.setdefault(name, []).append(value)
245
+ else:
246
+ raw[name] = value
@@ -0,0 +1,175 @@
1
+ """Namespace tree and registry.
2
+
3
+ A Namespace is a node in the command tree (e.g., /system, /system/config).
4
+ Each namespace holds commands and optional sub-namespaces.
5
+ The NamespaceRegistry is the root of the tree.
6
+
7
+ Resolution is greedy left-to-right: ``kd system config set url value``
8
+ resolves to namespace=/system/config, command=set, args=[url, value].
9
+
10
+ Built-in command synonyms (e.g., ``get`` → ``show``) are loaded from
11
+ ``kd/resources/command-synonyms.yaml`` and applied globally after
12
+ namespace registration. See :func:`load_synonyms` and :func:`apply_synonyms`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from importlib import resources as importlib_resources
18
+ from typing import TYPE_CHECKING
19
+
20
+ import yaml
21
+
22
+ if TYPE_CHECKING:
23
+ from kd.cli.core.command_base import Command
24
+
25
+
26
+ class Namespace:
27
+ """A node in the command tree."""
28
+
29
+ def __init__(
30
+ self,
31
+ name: str,
32
+ description: str,
33
+ path: str = "",
34
+ ) -> None:
35
+ self.name = name
36
+ self.description = description
37
+ self.path = path
38
+ self.commands: dict[str, Command] = {}
39
+ self.sub_namespaces: dict[str, Namespace] = {}
40
+ self.synonyms: dict[str, str] = {}
41
+
42
+ def register_command(self, name: str, command: Command) -> None:
43
+ """Register a command in this namespace."""
44
+ self.commands[name] = command
45
+
46
+ def register_sub_namespace(self, namespace: Namespace) -> None:
47
+ """Register a child namespace."""
48
+ self.sub_namespaces[namespace.name] = namespace
49
+
50
+ def get_command(self, name: str) -> Command | None:
51
+ """Look up a command by name, falling through to synonyms."""
52
+ cmd = self.commands.get(name)
53
+ if cmd is not None:
54
+ return cmd
55
+ canonical = self.synonyms.get(name)
56
+ if canonical is not None:
57
+ return self.commands.get(canonical)
58
+ return None
59
+
60
+ def get_sub_namespace(self, name: str) -> Namespace | None:
61
+ """Look up a child namespace by name."""
62
+ return self.sub_namespaces.get(name)
63
+
64
+
65
+ class NamespaceRegistry:
66
+ """Root of the namespace tree. Resolves token sequences to commands."""
67
+
68
+ def __init__(self) -> None:
69
+ self.root = Namespace("kd", "YouTrack CLI and SDK")
70
+
71
+ def register_namespace(self, namespace: Namespace) -> None:
72
+ """Place a namespace in the tree by its path.
73
+
74
+ Path segments are split on '/'. Intermediate namespaces are
75
+ created automatically if they don't exist.
76
+
77
+ Example: path="system/config" creates /system (if missing)
78
+ then places the namespace as /system/config.
79
+ """
80
+ if not namespace.path:
81
+ self.root.register_sub_namespace(namespace)
82
+ return
83
+
84
+ segments = namespace.path.split("/")
85
+ parent = self.root
86
+
87
+ # Navigate/create intermediate namespaces
88
+ for segment in segments[:-1]:
89
+ child = parent.get_sub_namespace(segment)
90
+ if child is None:
91
+ child = Namespace(segment, f"{segment} commands", path=segment)
92
+ parent.register_sub_namespace(child)
93
+ parent = child
94
+
95
+ # Register using the last path segment as key (not namespace.name)
96
+ parent.sub_namespaces[segments[-1]] = namespace
97
+
98
+ def resolve(self, tokens: list[str]) -> tuple[Namespace, str, list[str]]:
99
+ """Resolve tokens to (namespace, command_name, remaining_args).
100
+
101
+ Uses greedy left-to-right matching: consumes tokens as namespace
102
+ segments as long as matching sub-namespaces exist, then takes the
103
+ next token as the command name.
104
+
105
+ Returns empty command_name when no command token remains (bare
106
+ namespace invocation — triggers discovery).
107
+
108
+ Examples:
109
+ ["system", "config", "set", "url", "val"]
110
+ -> (system/config namespace, "set", ["url", "val"])
111
+
112
+ ["system", "config"]
113
+ -> (system/config namespace, "", [])
114
+
115
+ []
116
+ -> (root namespace, "", [])
117
+ """
118
+ if not tokens:
119
+ return self.root, "", []
120
+
121
+ current = self.root
122
+ consumed = 0
123
+
124
+ # Greedy namespace resolution
125
+ for token in tokens:
126
+ child = current.get_sub_namespace(token)
127
+ if child is None:
128
+ break
129
+ current = child
130
+ consumed += 1
131
+
132
+ remaining = tokens[consumed:]
133
+
134
+ if remaining:
135
+ command_name = remaining[0]
136
+ args = remaining[1:]
137
+ else:
138
+ command_name = ""
139
+ args = []
140
+
141
+ return current, command_name, args
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Built-in command synonyms
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def load_synonyms() -> list[tuple[str, str]]:
150
+ """Load synonym definitions from the YAML resource file.
151
+
152
+ Returns a list of ``(synonym, canonical)`` pairs.
153
+ """
154
+ ref = importlib_resources.files("kd.resources").joinpath("command-synonyms.yaml")
155
+ raw = yaml.safe_load(ref.read_text(encoding="utf-8"))
156
+ return [(entry["synonym"], entry["canonical"]) for entry in raw]
157
+
158
+
159
+ def _apply_to_namespace(ns: Namespace, synonyms: list[tuple[str, str]]) -> None:
160
+ """Apply synonyms to a single namespace (non-recursive)."""
161
+ for synonym, canonical in synonyms:
162
+ if canonical in ns.commands and synonym not in ns.commands:
163
+ ns.synonyms[synonym] = canonical
164
+
165
+
166
+ def apply_synonyms(root: Namespace, synonyms: list[tuple[str, str]]) -> None:
167
+ """Recursively apply synonym mappings to a namespace tree.
168
+
169
+ For each namespace that has the canonical command and does *not*
170
+ already define a command with the synonym name, a synonym entry
171
+ is added. Existing canonical commands are never overwritten.
172
+ """
173
+ _apply_to_namespace(root, synonyms)
174
+ for child in root.sub_namespaces.values():
175
+ apply_synonyms(child, synonyms)
@@ -0,0 +1,265 @@
1
+ """Option declaration and resolution.
2
+
3
+ Two option layers for kd:
4
+ - COMMAND: command-specific options (--project, --query, --limit)
5
+ - OUTPUT: output formatting options (--output, --fields)
6
+
7
+ Options are declared per-command and resolved by the parser.
8
+ Global options (--connection, --help, --version, --debug, --reference)
9
+ are handled separately in main.py, outside the layer system.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from typing import Any
17
+
18
+
19
+ class OptionLayer(Enum):
20
+ """Option layers — separates options by concern."""
21
+
22
+ GLOBAL = "global"
23
+ COMMAND = "command"
24
+ OUTPUT = "output"
25
+
26
+
27
+ @dataclass
28
+ class OptionDeclaration:
29
+ """Metadata for a single CLI option."""
30
+
31
+ name: str
32
+ layer: OptionLayer
33
+ type: type = str
34
+ short_name: str | None = None
35
+ default: Any = None
36
+ choices: list[str] | None = None
37
+ required: bool = False
38
+ help_text: str = ""
39
+ is_list: bool = False
40
+ aliases: list[str] = field(default_factory=list)
41
+
42
+
43
+ @dataclass
44
+ class ResolvedOptions:
45
+ """Parsed and validated options, organized by layer."""
46
+
47
+ command: dict[str, Any] = field(default_factory=dict)
48
+ output: dict[str, Any] = field(default_factory=dict)
49
+
50
+ def get_layer(self, layer: OptionLayer) -> dict[str, Any]:
51
+ """Get options dict for a specific layer."""
52
+ if layer == OptionLayer.COMMAND:
53
+ return self.command
54
+ return self.output
55
+
56
+
57
+ class OptionRegistry:
58
+ """Manages option declarations and resolves raw option dicts."""
59
+
60
+ def __init__(self) -> None:
61
+ self._declarations: dict[str, OptionDeclaration] = {}
62
+ self._short_names: dict[str, str] = {} # short_name -> full name
63
+ self._aliases: dict[str, str] = {} # alias -> canonical name
64
+
65
+ def register(self, declaration: OptionDeclaration) -> None:
66
+ """Register an option declaration."""
67
+ self._declarations[declaration.name] = declaration
68
+ if declaration.short_name:
69
+ self._short_names[declaration.short_name] = declaration.name
70
+ for alias in declaration.aliases:
71
+ self._aliases[alias] = declaration.name
72
+
73
+ def register_all(self, declarations: list[OptionDeclaration]) -> None:
74
+ """Register multiple declarations."""
75
+ for decl in declarations:
76
+ self.register(decl)
77
+
78
+ def get_declaration(self, name: str) -> OptionDeclaration | None:
79
+ """Look up declaration by name, short name, or alias."""
80
+ if name in self._declarations:
81
+ return self._declarations[name]
82
+ full_name = self._short_names.get(name) or self._aliases.get(name)
83
+ if full_name:
84
+ return self._declarations[full_name]
85
+ return None
86
+
87
+ def resolve_name(self, name: str) -> str | None:
88
+ """Resolve a short name or alias to its canonical name. Returns None if unknown."""
89
+ if name in self._declarations:
90
+ return name
91
+ return self._short_names.get(name) or self._aliases.get(name)
92
+
93
+ def is_boolean_flag(self, name: str) -> bool:
94
+ """Check if an option is a boolean flag (no value expected)."""
95
+ decl = self.get_declaration(name)
96
+ return decl is not None and decl.type is bool
97
+
98
+ def is_list_option(self, name: str) -> bool:
99
+ """Check if an option is a repeatable list option."""
100
+ decl = self.get_declaration(name)
101
+ return decl is not None and decl.is_list
102
+
103
+ def resolve(self, raw: dict[str, Any]) -> ResolvedOptions:
104
+ """Resolve raw options dict into layered ResolvedOptions.
105
+
106
+ Validates types, choices, and required options.
107
+ Applies defaults for missing options.
108
+ """
109
+ result = ResolvedOptions()
110
+
111
+ # Apply provided values
112
+ for raw_name, value in raw.items():
113
+ full_name = self.resolve_name(raw_name)
114
+ if full_name is None:
115
+ raise OptionError(f"Unknown option: --{raw_name}")
116
+ decl = self._declarations[full_name]
117
+ validated = self._validate_value(decl, value)
118
+ result.get_layer(decl.layer)[decl.name] = validated
119
+
120
+ # Apply defaults and check required
121
+ for decl in self._declarations.values():
122
+ layer_dict = result.get_layer(decl.layer)
123
+ if decl.name not in layer_dict:
124
+ if decl.required:
125
+ raise OptionError(f"Required option missing: --{decl.name}")
126
+ if decl.is_list:
127
+ layer_dict[decl.name] = []
128
+ elif decl.default is not None:
129
+ layer_dict[decl.name] = decl.default
130
+
131
+ return result
132
+
133
+ def _validate_value(self, decl: OptionDeclaration, value: Any) -> Any:
134
+ """Validate and coerce an option value (single or list)."""
135
+ if decl.is_list:
136
+ items = value if isinstance(value, list) else [value]
137
+ return [self._validate_single(decl, v) for v in items]
138
+ return self._validate_single(decl, value)
139
+
140
+ def _validate_single(self, decl: OptionDeclaration, value: Any) -> Any:
141
+ """Validate and coerce a single option value."""
142
+ if decl.type is bool:
143
+ if isinstance(value, bool):
144
+ return value
145
+ if isinstance(value, str):
146
+ if value.lower() in ("true", "1", "yes"):
147
+ return True
148
+ if value.lower() in ("false", "0", "no"):
149
+ return False
150
+ raise OptionError(f"Invalid boolean value for --{decl.name}: {value}")
151
+
152
+ if decl.type is int:
153
+ try:
154
+ return int(value)
155
+ except (ValueError, TypeError):
156
+ raise OptionError(f"Expected integer for --{decl.name}: {value}") from None
157
+
158
+ if decl.type is float:
159
+ try:
160
+ return float(value)
161
+ except (ValueError, TypeError):
162
+ raise OptionError(f"Expected number for --{decl.name}: {value}") from None
163
+
164
+ # str type — check choices
165
+ str_value = str(value)
166
+ if decl.choices and str_value not in decl.choices:
167
+ choices_str = ", ".join(decl.choices)
168
+ raise OptionError(
169
+ f"Invalid value for --{decl.name}: '{str_value}'. Choose from: {choices_str}"
170
+ )
171
+ return str_value
172
+
173
+
174
+ class OptionError(Exception):
175
+ """Raised when option parsing or validation fails."""
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Shared COMMAND option builders
180
+ # ---------------------------------------------------------------------------
181
+ # Factory functions for recurring options. Commands opt in by calling the
182
+ # builder in _register_options(). Flags and short names are locked; callers
183
+ # may override help_text, required, and default.
184
+
185
+
186
+ def project_option(
187
+ *, help_text: str = "Filter by project key", required: bool = False
188
+ ) -> OptionDeclaration:
189
+ """``--project / -p`` — project key filter."""
190
+ return OptionDeclaration(
191
+ name="project",
192
+ short_name="p",
193
+ layer=OptionLayer.COMMAND,
194
+ help_text=help_text,
195
+ required=required,
196
+ )
197
+
198
+
199
+ def limit_option(
200
+ *, default: int = 50, help_text: str = "Maximum number of results"
201
+ ) -> OptionDeclaration:
202
+ """``--limit / -l`` — result cap."""
203
+ return OptionDeclaration(
204
+ name="limit",
205
+ short_name="l",
206
+ layer=OptionLayer.COMMAND,
207
+ type=int,
208
+ default=default,
209
+ help_text=help_text,
210
+ )
211
+
212
+
213
+ def type_option(*, help_text: str = "Issue type (Bug, Task, Feature, etc.)") -> OptionDeclaration:
214
+ """``--type / -T`` — issue type field."""
215
+ return OptionDeclaration(
216
+ name="type",
217
+ short_name="T",
218
+ layer=OptionLayer.COMMAND,
219
+ help_text=help_text,
220
+ )
221
+
222
+
223
+ def priority_option(
224
+ *, help_text: str = "Priority (Critical, Major, Normal, Minor)"
225
+ ) -> OptionDeclaration:
226
+ """``--priority / -P`` — issue priority field."""
227
+ return OptionDeclaration(
228
+ name="priority",
229
+ short_name="P",
230
+ layer=OptionLayer.COMMAND,
231
+ help_text=help_text,
232
+ )
233
+
234
+
235
+ def state_option(
236
+ *,
237
+ help_text: str = "State (Submitted, Open, etc.). See: /project/field list KEY --field State",
238
+ ) -> OptionDeclaration:
239
+ """``--state / -S`` — issue state field."""
240
+ return OptionDeclaration(
241
+ name="state",
242
+ short_name="S",
243
+ layer=OptionLayer.COMMAND,
244
+ help_text=help_text,
245
+ )
246
+
247
+
248
+ # Standard OUTPUT options available to all commands
249
+ OUTPUT_OPTIONS: list[OptionDeclaration] = [
250
+ OptionDeclaration(
251
+ name="output",
252
+ short_name="o",
253
+ layer=OptionLayer.OUTPUT,
254
+ type=str,
255
+ choices=["table", "json", "yaml", "md"],
256
+ help_text="Output format (table, json, yaml, md)",
257
+ ),
258
+ OptionDeclaration(
259
+ name="fields",
260
+ short_name="f",
261
+ layer=OptionLayer.OUTPUT,
262
+ type=str,
263
+ help_text="Comma-separated field list",
264
+ ),
265
+ ]