kd-cli 2.6.3__tar.gz → 2.6.5__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 (86) hide show
  1. {kd_cli-2.6.3/kd_cli.egg-info → kd_cli-2.6.5}/PKG-INFO +1 -1
  2. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/youtrack.py +5 -3
  3. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/input.py +23 -3
  4. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/main.py +14 -7
  5. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/system/__init__.py +6 -6
  6. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/client.py +74 -2
  7. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/config.py +7 -7
  8. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/config_read.py +1 -1
  9. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/exceptions.py +13 -0
  10. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/primer.py +1 -1
  11. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/CHANGELOG.md +53 -0
  12. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/operating-guide.md +3 -1
  13. {kd_cli-2.6.3 → kd_cli-2.6.5/kd_cli.egg-info}/PKG-INFO +1 -1
  14. {kd_cli-2.6.3 → kd_cli-2.6.5}/pyproject.toml +1 -1
  15. {kd_cli-2.6.3 → kd_cli-2.6.5}/LICENSE +0 -0
  16. {kd_cli-2.6.3 → kd_cli-2.6.5}/MANIFEST.in +0 -0
  17. {kd_cli-2.6.3 → kd_cli-2.6.5}/README.md +0 -0
  18. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/__init__.py +0 -0
  19. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/__init__.py +0 -0
  20. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/articles.py +0 -0
  21. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/http.py +0 -0
  22. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/hub.py +0 -0
  23. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/saved_queries.py +0 -0
  24. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/tags.py +0 -0
  25. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/article_marker.py +0 -0
  26. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/auth.py +0 -0
  27. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cache.py +0 -0
  28. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/__init__.py +0 -0
  29. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/__init__.py +0 -0
  30. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/command_base.py +0 -0
  31. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/command_parser.py +0 -0
  32. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/confirm.py +0 -0
  33. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/namespaces.py +0 -0
  34. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/option_registry.py +0 -0
  35. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/__init__.py +0 -0
  36. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/alias/__init__.py +0 -0
  37. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/article/__init__.py +0 -0
  38. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/cache/__init__.py +0 -0
  39. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/init.py +0 -0
  40. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/issue/__init__.py +0 -0
  41. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/primer/__init__.py +0 -0
  42. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/project/__init__.py +0 -0
  43. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/query/__init__.py +0 -0
  44. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/release_notes.py +0 -0
  45. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/search/__init__.py +0 -0
  46. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/tag/__init__.py +0 -0
  47. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/time/__init__.py +0 -0
  48. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/user/__init__.py +0 -0
  49. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/output.py +0 -0
  50. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/tree.py +0 -0
  51. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/config_schema.py +0 -0
  52. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/connection.py +0 -0
  53. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/debug.py +0 -0
  54. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/history.py +0 -0
  55. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/locking.py +0 -0
  56. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/__init__.py +0 -0
  57. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/article.py +0 -0
  58. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/assist.py +0 -0
  59. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/attachment.py +0 -0
  60. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/comment.py +0 -0
  61. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/common.py +0 -0
  62. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/issue.py +0 -0
  63. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/project.py +0 -0
  64. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/saved_query.py +0 -0
  65. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/search.py +0 -0
  66. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/tag.py +0 -0
  67. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/tag_entities.py +0 -0
  68. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/user.py +0 -0
  69. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/work_item.py +0 -0
  70. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/operating_guide.py +0 -0
  71. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/query_parser.py +0 -0
  72. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/release_notes.py +0 -0
  73. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/__init__.py +0 -0
  74. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/command-synonyms.yaml +0 -0
  75. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/config-template.yaml +0 -0
  76. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/error-messages.yaml +0 -0
  77. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/primers/__init__.py +0 -0
  78. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/primers/hitl.yaml +0 -0
  79. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/sdlc-doctrine.md +0 -0
  80. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/sdlc.py +0 -0
  81. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/SOURCES.txt +0 -0
  82. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/dependency_links.txt +0 -0
  83. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/entry_points.txt +0 -0
  84. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/requires.txt +0 -0
  85. {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/top_level.txt +0 -0
  86. {kd_cli-2.6.3 → kd_cli-2.6.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.6.3
3
+ Version: 2.6.5
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -20,7 +20,7 @@ import re
20
20
  from typing import Any
21
21
 
22
22
  from kd.api.http import HttpClient
23
- from kd.exceptions import KdError
23
+ from kd.exceptions import FieldDiscoveryError, KdError
24
24
  from kd.models import (
25
25
  AssistResponse,
26
26
  Attachment,
@@ -257,13 +257,15 @@ class YouTrackAPI:
257
257
  admin_fields: list[dict[str, Any]] = admin.get("customFields") or []
258
258
  if admin_fields:
259
259
  return admin_fields
260
- raise KdError(
260
+ raise FieldDiscoveryError(
261
261
  f"No custom fields discovered for project '{project_id}'.",
262
262
  suggestion=(
263
263
  "Either the project has no issues to sample from, or the "
264
264
  "active token lacks Read Project Full on this project. "
265
265
  "Create one issue (any contributor token works), or use a "
266
- "token with project-administration rights. See KDCLI-A-22."
266
+ "token with project-administration rights. Or omit "
267
+ "--type / --priority / --state to create a minimal first "
268
+ "issue, then update it via `/issue update`. See KDCLI-A-22."
267
269
  ),
268
270
  )
269
271
 
@@ -25,9 +25,23 @@ def resolve_text_input(value: str | None) -> str | None:
25
25
  if not value:
26
26
  return value
27
27
  if value == "-":
28
- return sys.stdin.read()
28
+ try:
29
+ return sys.stdin.read()
30
+ except UnicodeDecodeError as e:
31
+ raise ValidationError(
32
+ f"Stdin input could not be decoded as UTF-8: {e.reason}",
33
+ suggestion="Ensure the upstream shell pipes UTF-8 bytes "
34
+ "(heredoc, pipe, or redirected file).",
35
+ ) from e
29
36
  if value == "@-":
30
- text = sys.stdin.read()
37
+ try:
38
+ text = sys.stdin.read()
39
+ except UnicodeDecodeError as e:
40
+ raise ValidationError(
41
+ f"Stdin input could not be decoded as UTF-8: {e.reason}",
42
+ suggestion="Ensure the upstream shell pipes UTF-8 bytes "
43
+ "(heredoc, pipe, or redirected file).",
44
+ ) from e
31
45
  if not text:
32
46
  raise ValidationError("No input received from stdin")
33
47
  return text
@@ -35,7 +49,13 @@ def resolve_text_input(value: str | None) -> str | None:
35
49
  file_path = Path(value[1:])
36
50
  if not file_path.is_file():
37
51
  raise ValidationError(f"File not found: {file_path}")
38
- text = file_path.read_text(encoding="utf-8")
52
+ try:
53
+ text = file_path.read_text(encoding="utf-8")
54
+ except UnicodeDecodeError as e:
55
+ raise ValidationError(
56
+ f"File {file_path} could not be decoded as UTF-8: {e.reason}",
57
+ suggestion="Save the file as UTF-8 (without a BOM) and retry.",
58
+ ) from e
39
59
  if not text:
40
60
  raise ValidationError(f"File is empty: {file_path}")
41
61
  return text
@@ -142,17 +142,24 @@ def _print_error(error: KdError) -> None:
142
142
 
143
143
 
144
144
  def _configure_windows_stdio() -> None:
145
- """Force UTF-8 on stdout/stderr when running on Windows.
146
-
147
- No-op on POSIX. Errors during reconfigure are swallowed; the wrapper's
148
- UnicodeEncodeError handler is the defensive backstop for the rare
149
- embedded-interpreter case where reconfigure is unavailable.
145
+ """Force UTF-8 on stdin/stdout/stderr when running on Windows.
146
+
147
+ No-op on POSIX. Stdout/stderr use ``errors="replace"`` so a single
148
+ corrupted byte never aborts a whole command's output. Stdin uses
149
+ ``errors="strict"`` so invalid bytes raise loudly rather than silently
150
+ writing replacement chars to disk via the input layer (see
151
+ ``kd/cli/input.py``). Errors during reconfigure are swallowed; embedded
152
+ interpreters and pytest captures may lack ``reconfigure`` on any of the
153
+ three streams.
150
154
  """
151
155
  if sys.platform != "win32":
152
156
  return
153
- for stream in (sys.stdout, sys.stderr):
157
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
154
158
  try:
155
- stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr,unused-ignore]
159
+ stream.reconfigure( # type: ignore[union-attr,unused-ignore]
160
+ encoding="utf-8",
161
+ errors="strict" if stream is sys.stdin else "replace",
162
+ )
156
163
  except Exception:
157
164
  pass
158
165
 
@@ -709,7 +709,7 @@ def _resolve_target(name: str) -> tuple[Path, Path, str]:
709
709
 
710
710
  def _diff_summary(snapshot: SnapshotInfo, current_text: str) -> str:
711
711
  """One-line diff summary: ``+N -M`` lines vs current file."""
712
- snap_lines = snapshot.path.read_text().splitlines()
712
+ snap_lines = snapshot.path.read_text(encoding="utf-8").splitlines()
713
713
  curr_lines = current_text.splitlines()
714
714
  added = removed = 0
715
715
  for line in difflib.unified_diff(snap_lines, curr_lines, n=0):
@@ -751,7 +751,7 @@ class SystemHistoryCommand(Command):
751
751
  rows: list[dict[str, Any]] = []
752
752
  for target_name in targets:
753
753
  live_path, history_dir, filename = _resolve_target(target_name)
754
- current_text = live_path.read_text() if live_path.exists() else ""
754
+ current_text = live_path.read_text(encoding="utf-8") if live_path.exists() else ""
755
755
  for snap in list_snapshots(history_dir, filename):
756
756
  rows.append(
757
757
  {
@@ -791,7 +791,7 @@ class SystemRestoreCommand(Command):
791
791
  snap_path = get_snapshot_path(history_dir, filename, timestamp)
792
792
  # Snapshot current before restoring (so restore is undoable)
793
793
  snapshot_before_write(live_path)
794
- restored_text = snap_path.read_text()
794
+ restored_text = snap_path.read_text(encoding="utf-8")
795
795
  atomic_write_text(live_path, restored_text)
796
796
  return {
797
797
  "ok": True,
@@ -830,15 +830,15 @@ class SystemDiffCommand(Command):
830
830
  live_path, history_dir, filename = _resolve_target(target_name)
831
831
 
832
832
  snap_a = get_snapshot_path(history_dir, filename, args[1])
833
- text_a = snap_a.read_text()
833
+ text_a = snap_a.read_text(encoding="utf-8")
834
834
  label_a = f"{filename}.{args[1]}"
835
835
 
836
836
  if len(args) >= 3:
837
837
  snap_b = get_snapshot_path(history_dir, filename, args[2])
838
- text_b = snap_b.read_text()
838
+ text_b = snap_b.read_text(encoding="utf-8")
839
839
  label_b = f"{filename}.{args[2]}"
840
840
  else:
841
- text_b = live_path.read_text() if live_path.exists() else ""
841
+ text_b = live_path.read_text(encoding="utf-8") if live_path.exists() else ""
842
842
  label_b = filename
843
843
 
844
844
  result = diff_text(text_a, text_b, label_a, label_b)
@@ -45,7 +45,14 @@ from kd.article_marker import (
45
45
  from kd.config import ResolvedConfig
46
46
  from kd.connection import ConnectionContext
47
47
  from kd.debug import DebugLog
48
- from kd.exceptions import APIError, ConfigError, KdError, NotFoundError, ValidationError
48
+ from kd.exceptions import (
49
+ APIError,
50
+ ConfigError,
51
+ FieldDiscoveryError,
52
+ KdError,
53
+ NotFoundError,
54
+ ValidationError,
55
+ )
49
56
  from kd.query_parser import youtrack_query_literal
50
57
  from kd.models import (
51
58
  Article,
@@ -178,6 +185,19 @@ _SLOT_DEFAULTS: dict[str, str] = {
178
185
  }
179
186
 
180
187
 
188
+ # Bootstrap synthesis dispatch: semantic slot -> project-side field
189
+ # ``$type`` used to look up the issue-side ``$type`` and payload builder
190
+ # in :data:`_FIELD_DISPATCH` when schema discovery returned no fields
191
+ # (KDCLI-171). All three semantic slots are wired: HITL #2 confirmed
192
+ # YouTrack accepts a synthetic ``StateIssueCustomField`` payload with a
193
+ # ``name``-only value reference on an empty fresh project.
194
+ _BOOTSTRAP_SLOT_TO_PROJECT_TYPE: dict[str, str] = {
195
+ "type": "EnumProjectCustomField",
196
+ "priority": "EnumProjectCustomField",
197
+ "state": "StateProjectCustomField",
198
+ }
199
+
200
+
181
201
  # ---------------------------------------------------------------------------
182
202
  # Article push — types and helpers
183
203
  # ---------------------------------------------------------------------------
@@ -806,9 +826,61 @@ class IssueOperations:
806
826
  ``project`` is the project shortName (KEY).
807
827
  ``slots`` maps semantic names to values, e.g. ``{"type": "Bug"}``.
808
828
  ``aliases`` maps semantic names to actual field names from config.
829
+
830
+ On a fresh (zero-issue + non-admin) project, schema discovery
831
+ returns no fields and ``resolve_semantic_fields`` raises
832
+ :class:`FieldDiscoveryError`. For semantic-slot creates we
833
+ synthesize a best-effort payload using the post-discovery
834
+ ``$type`` mapping and let YouTrack accept or reject (KDCLI-171).
835
+ Generic ``--field`` flows route through
836
+ :meth:`resolve_generic_fields` instead and stay uncaught — without
837
+ server-side ``$type`` knowledge, synthesizing a guess is unsafe.
809
838
  """
810
839
  project_obj = self._yt.get_project(project)
811
- return self._field_schema.resolve_semantic_fields(project_obj.id, slots, aliases)
840
+ try:
841
+ return self._field_schema.resolve_semantic_fields(project_obj.id, slots, aliases)
842
+ except FieldDiscoveryError as e:
843
+ return self._synthesize_for_empty_discovery(slots, aliases, original=e)
844
+
845
+ def _synthesize_for_empty_discovery(
846
+ self,
847
+ slots: dict[str, str],
848
+ aliases: dict[str, str] | None,
849
+ original: FieldDiscoveryError,
850
+ ) -> builtins.list[CustomField]:
851
+ """Synthesize CustomField payloads when schema discovery is empty.
852
+
853
+ Honors the alias layer (``state`` -> ``Stage`` for Scrum projects)
854
+ via ``aliases.get(slot, _SLOT_DEFAULTS[slot])`` — same shape as
855
+ :meth:`FieldSchema.resolve_semantic_fields`. Re-raises the
856
+ *original* :class:`FieldDiscoveryError` (full three-bullet hint
857
+ preserved verbatim) when any slot is not in
858
+ :data:`_BOOTSTRAP_SLOT_TO_PROJECT_TYPE` — partial-bootstrap is
859
+ not a valid posture; the operator either gets all slots they
860
+ asked for, or the unchanged improved hint pointing at all three
861
+ workarounds (sample issue, admin token, or omit-and-update).
862
+ """
863
+ aliases = aliases or {}
864
+ unsupported = [s for s in slots if s not in _BOOTSTRAP_SLOT_TO_PROJECT_TYPE]
865
+ if unsupported:
866
+ raise original
867
+
868
+ result: builtins.list[CustomField] = []
869
+ for slot, value in slots.items():
870
+ project_field_type = _BOOTSTRAP_SLOT_TO_PROJECT_TYPE[slot]
871
+ issue_type, builder = _FIELD_DISPATCH[(project_field_type, "")]
872
+ field_name = aliases.get(slot, _SLOT_DEFAULTS.get(slot, slot))
873
+ self._debug.debug(
874
+ "fields",
875
+ f"synthesizing slot '{slot}' -> field '{field_name}' "
876
+ f"($type={issue_type}) on empty discovery",
877
+ )
878
+ result.append(
879
+ CustomField.model_validate(
880
+ {"name": field_name, "value": builder(value), "$type": issue_type}
881
+ )
882
+ )
883
+ return result
812
884
 
813
885
  def resolve_generic_fields(
814
886
  self,
@@ -406,7 +406,7 @@ def load_config(path: Path | None = None) -> YtctlConfig:
406
406
  if not config_path.exists():
407
407
  return YtctlConfig()
408
408
  try:
409
- raw = yaml.safe_load(config_path.read_text()) or {}
409
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
410
410
  return YtctlConfig(**raw)
411
411
  except (yaml.YAMLError, TypeError, ValueError) as e:
412
412
  raise ConfigError(f"Invalid config at {config_path}: {e}") from e
@@ -511,7 +511,7 @@ def update_config_path(path: str, value: str, config_path: Path) -> tuple[str, A
511
511
  # Load raw YAML, capture original text for rollback.
512
512
  raw = _load_raw_yaml(config_path)
513
513
  original_text: str | None
514
- original_text = config_path.read_text() if config_path.exists() else None
514
+ original_text = config_path.read_text(encoding="utf-8") if config_path.exists() else None
515
515
 
516
516
  retention = _retention_from_raw(raw, DEFAULT_RETENTION)
517
517
  snapshot_before_write(config_path, retention=retention)
@@ -619,7 +619,7 @@ def _load_raw_yaml(path: Path) -> dict[str, Any]:
619
619
  if not path.exists():
620
620
  return {}
621
621
  try:
622
- data = yaml.safe_load(path.read_text())
622
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
623
623
  except yaml.YAMLError as e:
624
624
  raise ConfigError(f"Invalid YAML in {path}: {e}") from e
625
625
  return data or {}
@@ -1395,16 +1395,16 @@ def _manage_gitignore(directory: Path) -> str:
1395
1395
 
1396
1396
  gitignore = git_root / ".gitignore"
1397
1397
  if gitignore.exists():
1398
- content = gitignore.read_text()
1398
+ content = gitignore.read_text(encoding="utf-8")
1399
1399
  lines = {line.strip() for line in content.splitlines()}
1400
1400
  if lines & _GITIGNORE_PATTERNS:
1401
1401
  return "already_ignored"
1402
1402
  # Append with a trailing newline if file doesn't end with one
1403
1403
  sep = "" if content.endswith("\n") else "\n"
1404
- gitignore.write_text(f"{content}{sep}\n# kd local config\n.kd/\n")
1404
+ gitignore.write_text(f"{content}{sep}\n# kd local config\n.kd/\n", encoding="utf-8")
1405
1405
  return "added"
1406
1406
 
1407
- gitignore.write_text("# kd local config\n.kd/\n")
1407
+ gitignore.write_text("# kd local config\n.kd/\n", encoding="utf-8")
1408
1408
  return "created"
1409
1409
 
1410
1410
 
@@ -1466,7 +1466,7 @@ def init_project_config(
1466
1466
  )
1467
1467
 
1468
1468
  config_dir.mkdir(parents=True, exist_ok=True)
1469
- config_path.write_text(template)
1469
+ config_path.write_text(template, encoding="utf-8")
1470
1470
 
1471
1471
  if global_tier:
1472
1472
  gitignore_action = "not_applicable"
@@ -174,7 +174,7 @@ def _get_history(path: str, key: str) -> dict[str, Any] | None:
174
174
  if value is None:
175
175
  continue
176
176
  # Only surface values actually written to the file, not schema defaults.
177
- raw = yaml.safe_load(tier_path.read_text()) or {}
177
+ raw = yaml.safe_load(tier_path.read_text(encoding="utf-8")) or {}
178
178
  history_block = raw.get("history") if isinstance(raw.get("history"), dict) else {}
179
179
  if not isinstance(history_block, dict) or key not in history_block:
180
180
  continue
@@ -95,6 +95,19 @@ class ValidationError(KdError):
95
95
  """Invalid input before API call."""
96
96
 
97
97
 
98
+ class FieldDiscoveryError(KdError):
99
+ """Schema discovery returned no fields for a project.
100
+
101
+ Raised by ``list_project_custom_fields`` when both the per-issue
102
+ projection and the admin projection produce empty results — the
103
+ 'fresh project + non-admin token' bootstrap shape. Caught at the
104
+ SDK layer (``IssueOperations.resolve_custom_fields``) so semantic
105
+ create-with-slots flows can synthesize a best-effort payload;
106
+ generic ``--field`` flows let it propagate so the operator sees
107
+ the improved hint.
108
+ """
109
+
110
+
98
111
  class QueryLiteralError(KdError):
99
112
  """Name cannot be encoded as a YouTrack DSL value literal.
100
113
 
@@ -121,7 +121,7 @@ def load_primer(path: Path | None = None) -> PrimerData:
121
121
  if not primer_file.exists():
122
122
  return PrimerData()
123
123
  try:
124
- raw = yaml.safe_load(primer_file.read_text()) or {}
124
+ raw = yaml.safe_load(primer_file.read_text(encoding="utf-8")) or {}
125
125
  return PrimerData(**raw)
126
126
  except (yaml.YAMLError, TypeError, ValueError) as e:
127
127
  raise ConfigError(f"Invalid primer at {primer_file}: {e}") from e
@@ -4,6 +4,59 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.6.5] — 2026-04-30
8
+
9
+ This release closes M011 by adding empty-project bootstrap support to
10
+ `/issue create`. Together with M011 S1 + S2 (the Windows UTF-8 audit in
11
+ 2.6.4), the milestone delivers a kd-only path from a fresh primer to
12
+ the first ticket on a brand-new project on Windows: file IO, stdin,
13
+ and field-resolution all work without falling back to the web UI.
14
+
15
+ ### Fixed
16
+ - `/issue create` semantic bootstrap now succeeds on fresh
17
+ (zero-issue, contributor-token) projects: when both schema-discovery
18
+ paths return empty, kd synthesizes a best-effort payload for
19
+ `--type`, `--priority`, and `--state` using the post-discovery
20
+ `$type` mapping and lets YouTrack accept or reject. Pre-fix, kd
21
+ bailed before the API call with `Error: No custom fields discovered
22
+ for project '<id>'`, telling the operator to fall back to the web UI
23
+ for the very first issue — a kd-only bootstrap workflow could not
24
+ populate Type / Priority / State on a brand-new project.
25
+ `--field NAME=VALUE` still raises with the improved hint (kd cannot
26
+ guess server-side `$type` for arbitrary fields). The hint gains a
27
+ third bullet pointing at the simplest workaround: `Or omit --type /
28
+ --priority / --state to create a minimal first issue, then update it
29
+ via `/issue update``. See KDCLI-A-22 for the empty-project bootstrap
30
+ detail (KDCLI-171).
31
+
32
+ ## [2.6.4] — 2026-04-30
33
+
34
+ This release closes the Windows UTF-8 audit started in 2.6.3. The
35
+ stream-encoding side (stdout/stderr) shipped earlier; 2.6.4 closes the
36
+ file-IO and stdin sides so primer notes, `--content @file`, and heredoc
37
+ or piped input round-trip non-cp1252 characters cleanly.
38
+
39
+ ### Fixed
40
+ - File reads and writes on Windows are now explicit UTF-8 at every
41
+ kd-owned text-IO call site (primer, config, history/diff snapshots,
42
+ `--description @file` / `--content @file` content). The headline
43
+ reproducer was `kd /primer show` raising `'charmap' codec can't
44
+ decode byte 0x9d` whenever `.kd/primer.yaml` contained any non-cp1252
45
+ byte (em-dash, arrow, right double quotation mark) — a single primer
46
+ note authored on macOS/Linux was enough to read-block every
47
+ downstream Windows session (KDCLI-170).
48
+ - Stdin on Windows is now forced to UTF-8 with strict error handling,
49
+ so heredocs and pipes carrying non-ASCII content (em-dash, arrow,
50
+ right double quotation mark) into `kd /primer/note update -`,
51
+ `kd /article create --content @-`, `--description -`, or any other
52
+ stdin-consuming command no longer get cp1252-mojibaked at the input
53
+ layer before being persisted. Invalid UTF-8 bytes on stdin (and
54
+ non-UTF-8 `@file` content) now surface as a clean
55
+ `Error: Stdin input could not be decoded as UTF-8 ... / Hint: Ensure
56
+ the upstream shell pipes UTF-8 bytes ...` (or
57
+ `Hint: Save the file as UTF-8 ...`) instead of the generic "internal
58
+ error: ... This is a bug." block (KDCLI-169).
59
+
7
60
  ## [2.6.3] — 2026-04-30
8
61
 
9
62
  This release focuses on Windows shakedown: encoding crashes, primer
@@ -126,6 +126,8 @@ Clear optional custom fields with an empty value, for example `kd /issue update
126
126
 
127
127
  `--field` supports bundle families (enum, state, owned, version, build), user fields, period, and string `SimpleProjectCustomField` subtypes; whitespace inside the value is preserved. Unsupported families (text, `Multi*` enum/user variants, and `SimpleProjectCustomField` with `integer` / `float` / `date` subtypes) fail client-side with a message naming both the project `$type` and the `fieldType.id` subtype so a follow-on bug report can skip the probe.
128
128
 
129
+ On fresh (zero-issue, contributor-token) projects where schema discovery returns no fields, kd synthesizes a best-effort bootstrap payload for `--type`, `--priority`, and `--state` and lets YouTrack accept or reject; YouTrack's rejection text surfaces unchanged so an unusual project setup is auditable upstream. `--field NAME=VALUE` still requires a sample issue or admin token because kd lacks server-side `$type` knowledge to synthesize generic fields safely. KDCLI-A-22 carries the detail.
130
+
129
131
  Attachment metadata download URLs may start with `/api/`. kd handles this internally on download commands; do not assume the raw URL can be appended unchanged to an API base URL in custom scripts.
130
132
 
131
133
  Tag existence, tag visibility, and tag sharing rights are different concerns. A visible tag may not be usable for every operation, and sharing settings are provider-specific security posture, not just display metadata.
@@ -244,7 +246,7 @@ kd -DDD /issue list --project KDCLI
244
246
 
245
247
  Debug output goes to stderr and tokens are redacted. Use `-DDD` when investigating upstream API behavior because it includes request and response details.
246
248
 
247
- On Windows, kd forces UTF-8 stdout and stderr at the entry point so non-ASCII characters (`→`, `—`, `≤`, em-dash) render correctly regardless of the console's default code page (cp1252 on most installs). No `PYTHONIOENCODING` workaround is needed.
249
+ On Windows, kd forces UTF-8 on stdin, stdout, and stderr at the entry point so non-ASCII characters (`→`, `—`, `≤`, em-dash) render correctly regardless of the console's default code page (cp1252 on most installs), and every kd-owned file read/write (primer, config, history snapshots, `--content @file` and `--description @file`) uses explicit UTF-8 so primer/config files written by macOS or Linux load cleanly on Windows. Stdin uses strict decoding so invalid UTF-8 piped into `kd /primer/note update -` or `kd /article create --content @-` fails loudly with a clear diagnostic (`Error: Stdin input could not be decoded as UTF-8 ...`) rather than silently corrupting persisted content. No `PYTHONIOENCODING` or `PYTHONUTF8` workaround is needed.
248
250
 
249
251
  Unhandled exceptions render a one-line or one-paragraph friendly diagnostic and exit non-zero. To see the full traceback (for bug reports or local debugging), re-run with `--debug` / `-D`; the wrapper re-raises the original exception so the runtime prints the traceback. `KdError` (expected user-facing failures) keep their existing one-line `Error: ...` / optional `Hint: ...` shape on every platform.
250
252
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.6.3
3
+ Version: 2.6.5
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.6.3"
7
+ version = "2.6.5"
8
8
  description = "Knowledge Dispatcher for issue trackers and knowledge bases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes