kd-cli 2.6.2__tar.gz → 2.6.3__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.6.2/kd_cli.egg-info → kd_cli-2.6.3}/PKG-INFO +2 -1
  2. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/youtrack.py +87 -4
  3. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cache.py +61 -43
  4. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/main.py +115 -1
  5. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/system/__init__.py +1 -0
  6. kd_cli-2.6.3/kd/locking.py +68 -0
  7. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/CHANGELOG.md +65 -0
  8. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/operating-guide.md +26 -1
  9. {kd_cli-2.6.2 → kd_cli-2.6.3/kd_cli.egg-info}/PKG-INFO +2 -1
  10. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/requires.txt +1 -0
  11. {kd_cli-2.6.2 → kd_cli-2.6.3}/pyproject.toml +2 -1
  12. kd_cli-2.6.2/kd/locking.py +0 -97
  13. {kd_cli-2.6.2 → kd_cli-2.6.3}/LICENSE +0 -0
  14. {kd_cli-2.6.2 → kd_cli-2.6.3}/MANIFEST.in +0 -0
  15. {kd_cli-2.6.2 → kd_cli-2.6.3}/README.md +0 -0
  16. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/__init__.py +0 -0
  17. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/__init__.py +0 -0
  18. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/articles.py +0 -0
  19. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/http.py +0 -0
  20. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/hub.py +0 -0
  21. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/saved_queries.py +0 -0
  22. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/api/tags.py +0 -0
  23. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/article_marker.py +0 -0
  24. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/auth.py +0 -0
  25. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/__init__.py +0 -0
  26. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/__init__.py +0 -0
  27. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/command_base.py +0 -0
  28. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/command_parser.py +0 -0
  29. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/confirm.py +0 -0
  30. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/namespaces.py +0 -0
  31. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/core/option_registry.py +0 -0
  32. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/input.py +0 -0
  33. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/__init__.py +0 -0
  34. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/alias/__init__.py +0 -0
  35. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/article/__init__.py +0 -0
  36. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/cache/__init__.py +0 -0
  37. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/init.py +0 -0
  38. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/issue/__init__.py +0 -0
  39. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/primer/__init__.py +0 -0
  40. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/project/__init__.py +0 -0
  41. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/query/__init__.py +0 -0
  42. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/release_notes.py +0 -0
  43. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/search/__init__.py +0 -0
  44. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/tag/__init__.py +0 -0
  45. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/time/__init__.py +0 -0
  46. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/namespaces/user/__init__.py +0 -0
  47. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/output.py +0 -0
  48. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/cli/tree.py +0 -0
  49. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/client.py +0 -0
  50. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/config.py +0 -0
  51. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/config_read.py +0 -0
  52. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/config_schema.py +0 -0
  53. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/connection.py +0 -0
  54. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/debug.py +0 -0
  55. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/exceptions.py +0 -0
  56. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/history.py +0 -0
  57. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/__init__.py +0 -0
  58. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/article.py +0 -0
  59. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/assist.py +0 -0
  60. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/attachment.py +0 -0
  61. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/comment.py +0 -0
  62. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/common.py +0 -0
  63. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/issue.py +0 -0
  64. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/project.py +0 -0
  65. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/saved_query.py +0 -0
  66. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/search.py +0 -0
  67. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/tag.py +0 -0
  68. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/tag_entities.py +0 -0
  69. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/user.py +0 -0
  70. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/models/work_item.py +0 -0
  71. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/operating_guide.py +0 -0
  72. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/primer.py +0 -0
  73. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/query_parser.py +0 -0
  74. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/release_notes.py +0 -0
  75. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/__init__.py +0 -0
  76. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/command-synonyms.yaml +0 -0
  77. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/config-template.yaml +0 -0
  78. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/error-messages.yaml +0 -0
  79. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/primers/__init__.py +0 -0
  80. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/primers/hitl.yaml +0 -0
  81. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/resources/sdlc-doctrine.md +0 -0
  82. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd/sdlc.py +0 -0
  83. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/SOURCES.txt +0 -0
  84. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/dependency_links.txt +0 -0
  85. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/entry_points.txt +0 -0
  86. {kd_cli-2.6.2 → kd_cli-2.6.3}/kd_cli.egg-info/top_level.txt +0 -0
  87. {kd_cli-2.6.2 → kd_cli-2.6.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.6.2
3
+ Version: 2.6.3
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -22,6 +22,7 @@ Classifier: Topic :: Utilities
22
22
  Requires-Python: >=3.11
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
+ Requires-Dist: filelock>=3.12
25
26
  Requires-Dist: httpx>=0.27
26
27
  Requires-Dist: packaging>=23.0
27
28
  Requires-Dist: pydantic>=2.0
@@ -16,9 +16,11 @@ Uses HttpClient for transport and Pydantic models for serialization.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import re
19
20
  from typing import Any
20
21
 
21
22
  from kd.api.http import HttpClient
23
+ from kd.exceptions import KdError
22
24
  from kd.models import (
23
25
  AssistResponse,
24
26
  Attachment,
@@ -169,6 +171,24 @@ _PROJECT_CUSTOM_FIELDS_PARAM = (
169
171
  "canBeEmpty,emptyFieldText"
170
172
  ")"
171
173
  )
174
+ # Per-issue projection that surfaces the same projectCustomField shape
175
+ # /admin/projects/{id} returns, but readable by any token with Read Issue.
176
+ # See KDCLI-A-22 for the YouTrack permission-tier rationale.
177
+ _PROJECT_FIELD_VIA_ISSUE_PARAM = (
178
+ "id,idReadable,"
179
+ "customFields("
180
+ "projectCustomField("
181
+ "field(name,fieldType(id)),"
182
+ "$type,"
183
+ "bundle(values(name,ordinal,isResolved)),"
184
+ "canBeEmpty,emptyFieldText"
185
+ ")"
186
+ ")"
187
+ )
188
+ # YouTrack internal-id format: "<entity-class>-<seq>", e.g. "0-9". Project
189
+ # short-names (KDCLI, VTX, FB) are alphabetic and never match this shape,
190
+ # so a regex match is a reliable "this is an id, not a key" signal.
191
+ _PROJECT_INTERNAL_ID_RE = re.compile(r"\d+-\d+")
172
192
 
173
193
 
174
194
  class YouTrackAPI:
@@ -203,13 +223,76 @@ class YouTrackAPI:
203
223
  self._http.delete(f"/admin/projects/{project_id}")
204
224
 
205
225
  def list_project_custom_fields(self, project_id: str) -> list[dict[str, Any]]:
206
- """Fetch custom fields for a project with bundle values."""
207
- data: dict[str, Any] = self._http.get(
226
+ """Fetch a project's custom-field schema (name, type, bundle values).
227
+
228
+ Queries the per-issue ``projectCustomField`` projection so the call
229
+ works for any token with Read Issue, not just project admins. Falls
230
+ back to ``/admin/projects/{id}`` only when the project has no
231
+ issues to sample from. See KDCLI-A-22 for the permission-tier
232
+ rationale.
233
+
234
+ Accepts either the project short-name ("KDCLI") or the internal id
235
+ ("0-9"); the search-query DSL needs the short-name, so internal
236
+ ids are resolved via a cheap shortName lookup that any contributor
237
+ token can make.
238
+ """
239
+ short_name = self._resolve_project_short_name(project_id)
240
+ issues: list[dict[str, Any]] = self._http.get(
241
+ "/issues",
242
+ fields=_PROJECT_FIELD_VIA_ISSUE_PARAM,
243
+ params={"query": f"project: {short_name}", "$top": 1},
244
+ )
245
+ if issues:
246
+ sample = issues[0]
247
+ return [
248
+ cf["projectCustomField"]
249
+ for cf in sample.get("customFields") or []
250
+ if cf.get("projectCustomField")
251
+ ]
252
+
253
+ admin: dict[str, Any] = self._http.get(
208
254
  f"/admin/projects/{project_id}",
209
255
  fields=_PROJECT_CUSTOM_FIELDS_PARAM,
210
256
  )
211
- result: list[dict[str, Any]] = data.get("customFields", [])
212
- return result
257
+ admin_fields: list[dict[str, Any]] = admin.get("customFields") or []
258
+ if admin_fields:
259
+ return admin_fields
260
+ raise KdError(
261
+ f"No custom fields discovered for project '{project_id}'.",
262
+ suggestion=(
263
+ "Either the project has no issues to sample from, or the "
264
+ "active token lacks Read Project Full on this project. "
265
+ "Create one issue (any contributor token works), or use a "
266
+ "token with project-administration rights. See KDCLI-A-22."
267
+ ),
268
+ )
269
+
270
+ def _resolve_project_short_name(self, project_id: str) -> str:
271
+ """Return a project short-name (key) usable in a YouTrack query.
272
+
273
+ Pass-through if ``project_id`` already looks like a key. If it
274
+ looks like an internal id (``\\d+-\\d+``), look up the short-name
275
+ via ``GET /admin/projects/{id}?fields=shortName`` — that endpoint
276
+ is readable by any token with Read Project Basic, so it works
277
+ for contributor tokens.
278
+ """
279
+ if not _PROJECT_INTERNAL_ID_RE.fullmatch(project_id):
280
+ return project_id
281
+ meta: dict[str, Any] = self._http.get(
282
+ f"/admin/projects/{project_id}",
283
+ fields="shortName",
284
+ )
285
+ short_name = meta.get("shortName")
286
+ if not short_name:
287
+ raise KdError(
288
+ f"Could not resolve project '{project_id}' to a short name.",
289
+ suggestion=(
290
+ "Verify the project exists and the active token has "
291
+ "Read Project Basic. Try `kd /project list` to see "
292
+ "projects this token can read."
293
+ ),
294
+ )
295
+ return str(short_name)
213
296
 
214
297
  # --- Time Tracking ---
215
298
 
@@ -21,7 +21,7 @@ import dataclasses
21
21
  import re
22
22
  from dataclasses import dataclass, field
23
23
  from datetime import datetime, timedelta, timezone
24
- from pathlib import Path
24
+ from pathlib import Path, PurePath, PurePosixPath
25
25
  from typing import Any, Literal
26
26
 
27
27
  import yaml
@@ -597,6 +597,22 @@ class CacheScopeEnumeration:
597
597
  # ---------------------------------------------------------------------------
598
598
 
599
599
 
600
+ def _report_path(value: Path | PurePath | str) -> str:
601
+ """Normalize a filesystem path to POSIX form for structured-report fields.
602
+
603
+ Single chokepoint for every path-bearing field on the cache report
604
+ dataclasses (``HydratedIssue.path``, ``HydratedArticle.path``,
605
+ ``EpicRefreshReport.manifest_path``, ``CacheStatusInfo.path``,
606
+ ``CacheSearchMatch.path`` and siblings). The structured envelope of
607
+ ``/cache refresh``, ``/cache clear``, ``/cache status``, and
608
+ ``/cache search`` uses ``/`` separators on Windows and POSIX alike so
609
+ LLM consumers see a platform-stable shape.
610
+ """
611
+ if isinstance(value, (Path, PurePath)):
612
+ return value.as_posix()
613
+ return PurePosixPath(value.replace("\\", "/")).as_posix()
614
+
615
+
600
616
  def cache_dir(start: Path | None = None) -> Path:
601
617
  """Return the cache directory inside the nearest ``.kd/`` workspace.
602
618
 
@@ -1133,19 +1149,19 @@ def _clear_subtree(directory: Path, errors: list[ClearReportError]) -> int:
1133
1149
  _remove_file(entry)
1134
1150
  removed += 1
1135
1151
  except OSError as e:
1136
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1152
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1137
1153
  elif entry.is_dir():
1138
1154
  removed += _clear_subtree(entry, errors)
1139
1155
  try:
1140
1156
  entry.rmdir()
1141
1157
  except OSError as e:
1142
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1158
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1143
1159
  else:
1144
1160
  try:
1145
1161
  _remove_file(entry)
1146
1162
  removed += 1
1147
1163
  except OSError as e:
1148
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1164
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1149
1165
  return removed
1150
1166
 
1151
1167
 
@@ -1183,25 +1199,25 @@ def clear_cache(*, cache_root: Path | None = None) -> ClearReport:
1183
1199
  _remove_file(entry)
1184
1200
  counts["other"] += 1
1185
1201
  except OSError as e:
1186
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1202
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1187
1203
  elif entry.is_dir():
1188
1204
  kind = _classify(entry.name)
1189
1205
  counts[kind] += _clear_subtree(entry, errors)
1190
1206
  try:
1191
1207
  entry.rmdir()
1192
1208
  except OSError as e:
1193
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1209
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1194
1210
  else:
1195
1211
  try:
1196
1212
  _remove_file(entry)
1197
1213
  counts["other"] += 1
1198
1214
  except OSError as e:
1199
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1215
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1200
1216
 
1201
1217
  try:
1202
1218
  target.rmdir()
1203
1219
  except OSError as e:
1204
- errors.append(ClearReportError(path=str(target), reason=str(e)))
1220
+ errors.append(ClearReportError(path=_report_path(target), reason=str(e)))
1205
1221
 
1206
1222
  return ClearReport(
1207
1223
  existed=True,
@@ -1244,7 +1260,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
1244
1260
  try:
1245
1261
  entry.rmdir()
1246
1262
  except OSError as e:
1247
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1263
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1248
1264
 
1249
1265
  for entry in sorted(target.iterdir()):
1250
1266
  if entry.is_symlink():
@@ -1252,7 +1268,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
1252
1268
  _remove_file(entry)
1253
1269
  counts["other"] += 1
1254
1270
  except OSError as e:
1255
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1271
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1256
1272
  elif entry.is_dir():
1257
1273
  if entry.name == CONNECTIONS_DIRNAME:
1258
1274
  for scope in sorted(entry.iterdir()):
@@ -1261,7 +1277,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
1261
1277
  _remove_file(scope)
1262
1278
  counts["other"] += 1
1263
1279
  except OSError as e:
1264
- errors.append(ClearReportError(path=str(scope), reason=str(e)))
1280
+ errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
1265
1281
  elif scope.is_dir():
1266
1282
  for inner in sorted(scope.iterdir()):
1267
1283
  if inner.is_dir():
@@ -1271,21 +1287,23 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
1271
1287
  _remove_file(inner)
1272
1288
  counts["other"] += 1
1273
1289
  except OSError as e:
1274
- errors.append(ClearReportError(path=str(inner), reason=str(e)))
1290
+ errors.append(
1291
+ ClearReportError(path=_report_path(inner), reason=str(e))
1292
+ )
1275
1293
  try:
1276
1294
  scope.rmdir()
1277
1295
  except OSError as e:
1278
- errors.append(ClearReportError(path=str(scope), reason=str(e)))
1296
+ errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
1279
1297
  else:
1280
1298
  try:
1281
1299
  _remove_file(scope)
1282
1300
  counts["other"] += 1
1283
1301
  except OSError as e:
1284
- errors.append(ClearReportError(path=str(scope), reason=str(e)))
1302
+ errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
1285
1303
  try:
1286
1304
  entry.rmdir()
1287
1305
  except OSError as e:
1288
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1306
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1289
1307
  else:
1290
1308
  _tally_kind_subdir(entry)
1291
1309
  else:
@@ -1293,12 +1311,12 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
1293
1311
  _remove_file(entry)
1294
1312
  counts["other"] += 1
1295
1313
  except OSError as e:
1296
- errors.append(ClearReportError(path=str(entry), reason=str(e)))
1314
+ errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
1297
1315
 
1298
1316
  try:
1299
1317
  target.rmdir()
1300
1318
  except OSError as e:
1301
- errors.append(ClearReportError(path=str(target), reason=str(e)))
1319
+ errors.append(ClearReportError(path=_report_path(target), reason=str(e)))
1302
1320
 
1303
1321
  return ClearReport(
1304
1322
  existed=True,
@@ -1405,7 +1423,7 @@ def enumerate_cache_scopes(cache_root: Path | None = None) -> CacheScopeEnumerat
1405
1423
  # a logic error, not user-recoverable state.
1406
1424
  assert data is not None
1407
1425
  manifest = _validate_scope_manifest_schema(data, manifest_path)
1408
- scopes.append(CacheScopeRef(slug=entry.name, path=str(entry), manifest=manifest))
1426
+ scopes.append(CacheScopeRef(slug=entry.name, path=_report_path(entry), manifest=manifest))
1409
1427
 
1410
1428
  return CacheScopeEnumeration(scopes=scopes, orphan_dirs=orphans)
1411
1429
 
@@ -1459,7 +1477,7 @@ def _scan_articles_dir(
1459
1477
  CacheStatusWarning(
1460
1478
  code="article_pair_mismatch",
1461
1479
  message=f"article body without sidecar: {article_id}.md",
1462
- path=str(bodies[article_id]),
1480
+ path=_report_path(bodies[article_id]),
1463
1481
  )
1464
1482
  )
1465
1483
  for article_id in sorted(only_sidecar):
@@ -1467,7 +1485,7 @@ def _scan_articles_dir(
1467
1485
  CacheStatusWarning(
1468
1486
  code="article_pair_mismatch",
1469
1487
  message=f"article sidecar without body: {article_id}.meta.yaml",
1470
- path=str(sidecars[article_id]),
1488
+ path=_report_path(sidecars[article_id]),
1471
1489
  )
1472
1490
  )
1473
1491
 
@@ -1483,7 +1501,7 @@ def _scan_articles_dir(
1483
1501
  CacheStatusWarning(
1484
1502
  code="sidecar_unparseable",
1485
1503
  message=f"unable to parse fetched_at from {sidecar_path.name}: {exc}",
1486
- path=str(sidecar_path),
1504
+ path=_report_path(sidecar_path),
1487
1505
  )
1488
1506
  )
1489
1507
  continue
@@ -1522,7 +1540,7 @@ def _scan_yaml_kind_dir(
1522
1540
  CacheStatusWarning(
1523
1541
  code=code,
1524
1542
  message=f"unable to parse banner from {path.name}: {exc}",
1525
- path=str(path),
1543
+ path=_report_path(path),
1526
1544
  )
1527
1545
  )
1528
1546
  continue
@@ -1701,7 +1719,7 @@ def compute_cache_status(
1701
1719
  when = now if now is not None else datetime.now(timezone.utc)
1702
1720
  target = cache_dir() if cache_root is None else Path(cache_root).resolve()
1703
1721
 
1704
- info_path = str(target)
1722
+ info_path = _report_path(target)
1705
1723
  if not target.exists():
1706
1724
  return CacheStatusReport(
1707
1725
  cache=CacheStatusInfo(
@@ -1855,7 +1873,7 @@ def _refresh_primer_articles(
1855
1873
  id=article.id_readable,
1856
1874
  summary=article.summary,
1857
1875
  upstream_updated=meta.upstream_updated,
1858
- path=str(cache_root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
1876
+ path=_report_path(cache_root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
1859
1877
  )
1860
1878
  )
1861
1879
  except APIError as e:
@@ -1906,7 +1924,7 @@ def _refresh_primer_query(
1906
1924
  hydrated=[
1907
1925
  HydratedQuery(
1908
1926
  slug=PRIMER_SCOPE_QUERY_SLUG,
1909
- path=str(cache_root / QUERIES_DIRNAME / f"{PRIMER_SCOPE_QUERY_SLUG}.yaml"),
1927
+ path=_report_path(cache_root / QUERIES_DIRNAME / f"{PRIMER_SCOPE_QUERY_SLUG}.yaml"),
1910
1928
  issue_count=issue_count,
1911
1929
  article_count=article_count,
1912
1930
  )
@@ -2102,7 +2120,7 @@ def refresh_issue(
2102
2120
  id=issue.id_readable,
2103
2121
  summary=issue.summary,
2104
2122
  upstream_updated=_ms_to_iso(issue.updated),
2105
- path=str(root / ISSUES_DIRNAME / f"{issue.id_readable}.yaml"),
2123
+ path=_report_path(root / ISSUES_DIRNAME / f"{issue.id_readable}.yaml"),
2106
2124
  comments_count=comments_count,
2107
2125
  )
2108
2126
  ],
@@ -2314,7 +2332,7 @@ def refresh_query(
2314
2332
  query_name=sq.name,
2315
2333
  query_id=sq.id,
2316
2334
  slug=slug,
2317
- path=str(root / QUERIES_DIRNAME / f"{slug}.yaml"),
2335
+ path=_report_path(root / QUERIES_DIRNAME / f"{slug}.yaml"),
2318
2336
  count=len(issues),
2319
2337
  truncated=truncated,
2320
2338
  )
@@ -2458,7 +2476,7 @@ def refresh_milestone(
2458
2476
  field_name=actual_field_name,
2459
2477
  query_text=query_text,
2460
2478
  slug=slug,
2461
- path=str(root / QUERIES_DIRNAME / f"{slug}.yaml"),
2479
+ path=_report_path(root / QUERIES_DIRNAME / f"{slug}.yaml"),
2462
2480
  count=len(issues),
2463
2481
  truncated=truncated,
2464
2482
  )
@@ -2616,7 +2634,7 @@ def refresh_epic_with_children(
2616
2634
  id=epic.id_readable,
2617
2635
  summary=epic.summary,
2618
2636
  upstream_updated=_ms_to_iso(epic.updated),
2619
- path=str(root / ISSUES_DIRNAME / f"{epic.id_readable}.yaml"),
2637
+ path=_report_path(root / ISSUES_DIRNAME / f"{epic.id_readable}.yaml"),
2620
2638
  comments_count=None,
2621
2639
  )
2622
2640
 
@@ -2657,7 +2675,7 @@ def refresh_epic_with_children(
2657
2675
  id=child.id_readable,
2658
2676
  summary=child.summary,
2659
2677
  upstream_updated=_ms_to_iso(child.updated),
2660
- path=str(root / ISSUES_DIRNAME / f"{child.id_readable}.yaml"),
2678
+ path=_report_path(root / ISSUES_DIRNAME / f"{child.id_readable}.yaml"),
2661
2679
  comments_count=None,
2662
2680
  )
2663
2681
  )
@@ -2701,7 +2719,7 @@ def refresh_epic_with_children(
2701
2719
  "children": manifest_rows,
2702
2720
  }
2703
2721
  write_cached_query(slug, manifest_payload, fetched_at=when, cache_root=root)
2704
- manifest_path = str(root / QUERIES_DIRNAME / f"{slug}.yaml")
2722
+ manifest_path = _report_path(root / QUERIES_DIRNAME / f"{slug}.yaml")
2705
2723
 
2706
2724
  return EpicRefreshReport(
2707
2725
  epic=epic_hydrated,
@@ -2754,7 +2772,7 @@ def refresh_article(
2754
2772
  id=article.id_readable,
2755
2773
  summary=article.summary,
2756
2774
  upstream_updated=meta.upstream_updated,
2757
- path=str(root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
2775
+ path=_report_path(root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
2758
2776
  )
2759
2777
  ],
2760
2778
  failed=[],
@@ -2907,7 +2925,7 @@ def _search_article_dir(
2907
2925
  CacheStatusWarning(
2908
2926
  code="sidecar_unparseable",
2909
2927
  message=f"unable to parse {sidecar_path.name}: {exc}",
2910
- path=str(sidecar_path),
2928
+ path=_report_path(sidecar_path),
2911
2929
  )
2912
2930
  )
2913
2931
  try:
@@ -2917,7 +2935,7 @@ def _search_article_dir(
2917
2935
  CacheStatusWarning(
2918
2936
  code="banner_unparseable",
2919
2937
  message=f"unable to read {body_path.name}: {exc}",
2920
- path=str(body_path),
2938
+ path=_report_path(body_path),
2921
2939
  )
2922
2940
  )
2923
2941
  continue
@@ -2928,7 +2946,7 @@ def _search_article_dir(
2928
2946
  CacheStatusWarning(
2929
2947
  code="banner_unparseable",
2930
2948
  message=f"unable to parse banner from {body_path.name}: {exc}",
2931
- path=str(body_path),
2949
+ path=_report_path(body_path),
2932
2950
  )
2933
2951
  )
2934
2952
  continue
@@ -2940,7 +2958,7 @@ def _search_article_dir(
2940
2958
  continue
2941
2959
  matches.append(
2942
2960
  CacheSearchMatch(
2943
- path=str(body_path),
2961
+ path=_report_path(body_path),
2944
2962
  connection=connection,
2945
2963
  slug=slug,
2946
2964
  kind="article",
@@ -2972,7 +2990,7 @@ def _load_yaml_payload(
2972
2990
  CacheStatusWarning(
2973
2991
  code="payload_unparseable",
2974
2992
  message=f"unable to read {path.name}: {exc}",
2975
- path=str(path),
2993
+ path=_report_path(path),
2976
2994
  )
2977
2995
  )
2978
2996
  return None
@@ -2983,7 +3001,7 @@ def _load_yaml_payload(
2983
3001
  CacheStatusWarning(
2984
3002
  code="banner_unparseable",
2985
3003
  message=f"unable to parse banner from {path.name}: {exc}",
2986
- path=str(path),
3004
+ path=_report_path(path),
2987
3005
  )
2988
3006
  )
2989
3007
  body = raw # try the full file — payload may parse without banner
@@ -2994,7 +3012,7 @@ def _load_yaml_payload(
2994
3012
  CacheStatusWarning(
2995
3013
  code="payload_unparseable",
2996
3014
  message=f"unable to parse YAML payload from {path.name}: {exc}",
2997
- path=str(path),
3015
+ path=_report_path(path),
2998
3016
  )
2999
3017
  )
3000
3018
  return None
@@ -3003,7 +3021,7 @@ def _load_yaml_payload(
3003
3021
  CacheStatusWarning(
3004
3022
  code="payload_unparseable",
3005
3023
  message=f"YAML payload in {path.name} is not a mapping",
3006
- path=str(path),
3024
+ path=_report_path(path),
3007
3025
  )
3008
3026
  )
3009
3027
  return None
@@ -3052,7 +3070,7 @@ def _search_issue_dir(
3052
3070
  continue
3053
3071
  matches.append(
3054
3072
  CacheSearchMatch(
3055
- path=str(path),
3073
+ path=_report_path(path),
3056
3074
  connection=connection,
3057
3075
  slug=slug,
3058
3076
  kind="issue",
@@ -3181,7 +3199,7 @@ def _search_query_dir(
3181
3199
  CacheStatusWarning(
3182
3200
  code="query_shape_unrecognized",
3183
3201
  message=f"unknown query cache shape in {path.name}",
3184
- path=str(path),
3202
+ path=_report_path(path),
3185
3203
  )
3186
3204
  )
3187
3205
  continue
@@ -3189,7 +3207,7 @@ def _search_query_dir(
3189
3207
  continue
3190
3208
  matches.append(
3191
3209
  CacheSearchMatch(
3192
- path=str(path),
3210
+ path=_report_path(path),
3193
3211
  connection=connection,
3194
3212
  slug=slug,
3195
3213
  kind="query",
@@ -141,6 +141,74 @@ def _print_error(error: KdError) -> None:
141
141
  print(f"Hint: {error.suggestion}", file=sys.stderr)
142
142
 
143
143
 
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.
150
+ """
151
+ if sys.platform != "win32":
152
+ return
153
+ for stream in (sys.stdout, sys.stderr):
154
+ try:
155
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr,unused-ignore]
156
+ except Exception:
157
+ pass
158
+
159
+
160
+ _DEBUG_TOKEN = re.compile(r"^-D{1,3}$")
161
+
162
+
163
+ def _debug_requested(argv: list[str]) -> bool:
164
+ """Cheap preparse for ``--debug`` / ``-D{1,3}`` in the wrapper.
165
+
166
+ Used only by the friendly diagnostic handler to decide whether to re-raise
167
+ so the runtime prints the full traceback. Not a substitute for
168
+ :func:`_extract_globals`, which is the authoritative parser.
169
+ """
170
+ return any(token == "--debug" or _DEBUG_TOKEN.match(token) for token in argv)
171
+
172
+
173
+ def _command_summary(argv: list[str]) -> str:
174
+ """Best-effort command path for the internal-error block.
175
+
176
+ Returns the first non-flag tokens joined by space, or ``"<unknown>"``.
177
+ """
178
+ tokens = [t for t in argv if not t.startswith("-")]
179
+ return " ".join(tokens) if tokens else "<unknown>"
180
+
181
+
182
+ _MSYS_NAMESPACE_REGEX = re.compile(
183
+ r"^[A-Za-z]:[\\/].*?[\\/]"
184
+ r"(system|issue|article|primer|tag|user|time|project|query|search|alias|cache)$"
185
+ )
186
+
187
+
188
+ def _maybe_emit_msys_hint(args: list[str]) -> None:
189
+ """Stderr hint when git-bash MSYS rewrote a kd subcommand path.
190
+
191
+ From git-bash on Windows, ``kd /system info`` arrives as
192
+ ``["C:/Program Files/Git/system", "info"]`` because MSYS converts the
193
+ leading ``/`` to a Windows path before kd ever sees argv. Print a one-
194
+ line hint pointing at the operating-guide recovery recipes; normal
195
+ command parsing still runs and still fails with "Unknown command", but
196
+ the hint tells the operator how to retry.
197
+ """
198
+ if sys.platform != "win32" or not args:
199
+ return
200
+ if not _MSYS_NAMESPACE_REGEX.match(args[0]):
201
+ return
202
+ print(
203
+ "Hint: leading '/' in kd subcommand paths was converted to a "
204
+ "Windows path by git-bash / MSYS. Recover with `kd //system info`, "
205
+ "`MSYS_NO_PATHCONV=1 kd /system info`, or set "
206
+ "`alias kd='MSYS_NO_PATHCONV=1 kd'` in ~/.bashrc. "
207
+ "See `kd --guide` 'Windows shells' section.",
208
+ file=sys.stderr,
209
+ )
210
+
211
+
144
212
  def _resolve_config_or_exit(
145
213
  flags: dict[str, str] | None,
146
214
  debug_log: DebugLog,
@@ -158,7 +226,53 @@ def _resolve_config_or_exit(
158
226
 
159
227
 
160
228
  def main(argv: list[str] | None = None) -> None:
161
- """CLI entry point."""
229
+ """CLI entry point.
230
+
231
+ Wraps :func:`_main` with the UTF-8 stdio shim and a friendly diagnostic
232
+ handler. ``SystemExit`` and ``KeyboardInterrupt`` propagate naturally past
233
+ ``except Exception`` so existing exit paths and Ctrl-C behavior are
234
+ preserved.
235
+ """
236
+ _configure_windows_stdio()
237
+ args = argv if argv is not None else sys.argv[1:]
238
+ _maybe_emit_msys_hint(args)
239
+ debug = _debug_requested(args)
240
+ try:
241
+ _main(argv)
242
+ except KdError as e:
243
+ _print_error(e)
244
+ sys.exit(1)
245
+ except UnicodeEncodeError as e:
246
+ if debug:
247
+ raise
248
+ print(
249
+ f"Error: stdout encoding could not represent a character ({e.reason!s}). "
250
+ "Set PYTHONIOENCODING=utf-8 or upgrade kd to a build that forces UTF-8 stdout on Windows.",
251
+ file=sys.stderr,
252
+ )
253
+ sys.exit(1)
254
+ except Exception as e:
255
+ if debug:
256
+ raise
257
+ py_version = sys.version.split()[0]
258
+ cmd_summary = _command_summary(args)
259
+ print(
260
+ f"Internal error: {type(e).__name__}: {e}\n"
261
+ "\n"
262
+ f"kd version: {__version__}\n"
263
+ f"Python: {py_version}\n"
264
+ f"Platform: {sys.platform}\n"
265
+ f"Command: {cmd_summary}\n"
266
+ "\n"
267
+ "This is a bug. Please file an issue with the above context.\n"
268
+ "Re-run with --debug to see the full traceback.",
269
+ file=sys.stderr,
270
+ )
271
+ sys.exit(1)
272
+
273
+
274
+ def _main(argv: list[str] | None = None) -> None:
275
+ """CLI dispatch body. See :func:`main` for the friendly-diagnostic wrapper."""
162
276
  args = argv if argv is not None else sys.argv[1:]
163
277
 
164
278
  # Extract global options before namespace parsing
@@ -74,6 +74,7 @@ class SystemInfoCommand(Command):
74
74
  if base_url.endswith("/api"):
75
75
  base_url = base_url[:-4]
76
76
  return {
77
+ "connection": client.connection.name,
77
78
  "url": base_url,
78
79
  "user": me.get("login", "unknown"),
79
80
  "name": me.get("name", ""),
@@ -0,0 +1,68 @@
1
+ """Advisory file locking for read-modify-write transactions on kd-managed files.
2
+
3
+ Provides :func:`file_transaction` — a context manager that acquires an
4
+ exclusive advisory lock on a sidecar lockfile (``<path>.lock``) for the
5
+ duration of a load → mutate → save cycle. Concurrent kd processes that
6
+ enter the same transaction serialize rather than clobber each other.
7
+
8
+ Backed by :mod:`filelock`, which abstracts over ``fcntl.flock`` on POSIX
9
+ and ``msvcrt.locking`` on Windows. Behavior is identical on both:
10
+
11
+ * **Sidecar lockfile, not the target file.** Atomic writes elsewhere in
12
+ kd use ``tempfile.mkstemp`` + ``os.replace``, which changes the target
13
+ file's inode on every write. Holding an advisory lock on the target
14
+ inode would not protect a second writer that opens the freshly-replaced
15
+ file. Locking a separate sidecar that nobody rewrites keeps the lock
16
+ stable across atomic replaces of the data file.
17
+
18
+ * **Sidecar lifecycle is filelock-managed.** filelock owns sidecar
19
+ creation and removal: it unlinks the sidecar on release on both POSIX
20
+ and Windows. The kd contract is the *behavior* of the lock —
21
+ concurrent enterers serialize — not the on-disk artifact. Existence
22
+ of the sidecar is not evidence of an active lock; absence is not
23
+ evidence of release. Removing the sidecar under contention is a
24
+ two-inode race and is not part of any recovery recipe.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Generator
30
+ from contextlib import contextmanager
31
+ from pathlib import Path
32
+
33
+ from filelock import FileLock, Timeout
34
+
35
+ from kd.exceptions import ConfigError
36
+
37
+ LOCK_SUFFIX = ".lock"
38
+
39
+
40
+ @contextmanager
41
+ def file_transaction(path: Path, *, timeout: float = 30.0) -> Generator[None, None, None]:
42
+ """Hold an exclusive advisory lock on ``<path>.lock`` for the ``with`` body.
43
+
44
+ Acquisition blocks up to *timeout* seconds. On timeout, raises
45
+ :class:`ConfigError` naming the contested file. filelock unlinks
46
+ the sidecar on release; the kd contract is the lock behavior, not
47
+ the on-disk artifact.
48
+ """
49
+ lock_path = path.with_name(path.name + LOCK_SUFFIX)
50
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
51
+
52
+ lock = FileLock(str(lock_path))
53
+ try:
54
+ lock.acquire(timeout=timeout)
55
+ except Timeout:
56
+ raise ConfigError(
57
+ f"Another kd process is writing to {path}; retry in a moment.",
58
+ suggestion=(
59
+ "If the wait persists, verify no other kd process is mutating "
60
+ "this file and check parent-directory permissions. Do not "
61
+ "delete the sidecar lockfile while another process may be "
62
+ "running."
63
+ ),
64
+ ) from None
65
+ try:
66
+ yield
67
+ finally:
68
+ lock.release()
@@ -4,6 +4,71 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.6.3] — 2026-04-30
8
+
9
+ This release focuses on Windows shakedown: encoding crashes, primer
10
+ locking, mojibake in help text, the git-bash path-conversion gotcha,
11
+ and a contributor-token field-resolver bug surfaced along the way.
12
+
13
+ ### Fixed
14
+ - `/project/field list` and the `--type` / `--priority` / `--state` /
15
+ `--field` resolvers now work for tokens that lack project-administration
16
+ rights (e.g. contributor / bot accounts). When both the per-issue and
17
+ admin paths return no fields, kd raises a typed error pointing at
18
+ token permissions instead of failing the resolver silently. Surfaced
19
+ during the Windows shakedown but identity-class — the fix is
20
+ platform-agnostic (KDCLI-164).
21
+ - Primer mutations (`/primer/note add`, `/primer/tag add`,
22
+ `/primer/article add`, plus all update/remove paths) now succeed on
23
+ Windows. Replaces the previous POSIX-only implementation that raised
24
+ `ConfigError("File locking is not available on this platform (POSIX
25
+ only).")` at the very first acquisition on Windows. Concurrent kd
26
+ processes still serialize on the `.kd/primer.yaml.lock` file on both
27
+ platforms (KDCLI-159).
28
+ - Output containing characters outside cp1252 (`→`, `—`, `≤`) no longer
29
+ crashes with `UnicodeEncodeError` on Windows, and help/synopsis text
30
+ on the default Windows console no longer renders mojibake (`?` / `�`)
31
+ for en-dashes and similar. Both paths went through the same encoding
32
+ layer (KDCLI-158, KDCLI-161, KDCLI-156).
33
+ - Unhandled exceptions in the CLI now render a one-line (encoding) or
34
+ one-paragraph (generic) friendly diagnostic on stderr and exit `1`
35
+ instead of dumping a raw Python traceback. The traceback is preserved
36
+ under `--debug` / `-D` / `-DD` / `-DDD` so developer workflows are
37
+ intact. Applies on every platform (KDCLI-162).
38
+ - Operating guide and user guide now document the git-bash MSYS
39
+ path-conversion gotcha. `kd /primer show` from git-bash on Windows
40
+ arrives as `kd C:/Program Files/Git/primer show` because MSYS
41
+ rewrites the leading `/` before kd ever sees argv; recovery is the
42
+ recommended `alias kd='MSYS_NO_PATHCONV=1 kd'` in `~/.bashrc`,
43
+ `MSYS_NO_PATHCONV=1 kd …` per call, or the `kd //primer show`
44
+ double-slash form (KDCLI-163).
45
+
46
+ ### Added
47
+ - `kd /system info -o yaml` now exposes the active connection slug as
48
+ a top-level `connection` key. Paste `kd /system info -o yaml` into
49
+ a bug report and the slug used to reach the instance is captured
50
+ without hand-typing — `-c <slug>` overrides flow through to the
51
+ field as well. The previous payload named the URL but never the
52
+ slug, so reports against multi-connection setups had to spell the
53
+ slug out by hand (KDCLI-160).
54
+ - Git-bash MSYS path-conversion hint on Windows: when `kd <converted
55
+ path>` reaches argv as `C:/.../<known kd namespace>`, kd prints a
56
+ one-line stderr hint above the existing "Unknown command" failure
57
+ pointing at the recovery recipes (KDCLI-163).
58
+
59
+ ### Changed
60
+ - Added `filelock>=3.12` as a direct runtime dependency. Normal
61
+ `kd-cli` end-user installs gain one new direct runtime package; the
62
+ dev wheel set is unaffected because dev tooling already resolved
63
+ `filelock` transitively. See
64
+ `docs/design/ADR-012-cross-platform-locking.md` (KDCLI-159).
65
+ - Structured cache reports normalize file-path fields to POSIX form for
66
+ cross-platform stability. `path:` and `manifest_path:` fields inside the
67
+ YAML / JSON envelopes of `/cache refresh`, `/cache clear`, `/cache status`,
68
+ and `/cache search` now use `/` separators on Windows the same as on macOS
69
+ and Linux, so LLM consumers parsing the envelope see a platform-stable
70
+ shape (KDCLI-168).
71
+
7
72
  ## [2.6.2] — 2026-04-28
8
73
 
9
74
  ### Added
@@ -164,7 +164,7 @@ When both pinned tags and pinned articles exist, read them in order: notes tell
164
164
 
165
165
  Keep the primer local and curated. If a note becomes generally true for kd, move it into docs or this guide. If it is workspace-specific, keep it in `.kd/primer.yaml`.
166
166
 
167
- Primer mutations are serialized per file through an advisory lock on `.kd/primer.yaml.lock`; concurrent kd processes writing to the same primer queue rather than conflict, so agent-launched parallel `/primer/note`, `/primer/tag`, and `/primer/article` commands all converge without lost updates.
167
+ Primer mutations are serialized per file through an advisory lock on `.kd/primer.yaml.lock`; concurrent kd processes writing to the same primer queue rather than conflict, so agent-launched parallel `/primer/note`, `/primer/tag`, and `/primer/article` commands all converge without lost updates. The lock works identically on macOS, Linux, and Windows — the underlying primitive is `fcntl.flock` on POSIX and `msvcrt.locking` on Windows, both selected automatically.
168
168
 
169
169
  ## Articles And Push
170
170
 
@@ -244,6 +244,10 @@ kd -DDD /issue list --project KDCLI
244
244
 
245
245
  Debug output goes to stderr and tokens are redacted. Use `-DDD` when investigating upstream API behavior because it includes request and response details.
246
246
 
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.
248
+
249
+ 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
+
247
251
  If config resolution is confusing, inspect it directly:
248
252
 
249
253
  ```bash
@@ -284,6 +288,27 @@ Multi-target commands (`/issue/attachment add`, `/article/attachment add`) carry
284
288
 
285
289
  When filing a kd bug, include the output of `kd /system info -o yaml`. It names the active connection alongside `kd_version`, `python_version`, and `platform`, so the report is self-describing without hand-typing the version. Redact `url` / `email` before sharing externally.
286
290
 
291
+ ## Windows Shells
292
+
293
+ kd works directly from PowerShell and `cmd.exe` on Windows — `kd /system info`, `kd /primer show`, and the rest run as written.
294
+
295
+ From git-bash (and other MSYS environments), the leading `/` of a kd subcommand path is converted to a Windows path *before* kd ever sees argv, so `kd /primer show` arrives as `kd C:/Program Files/Git/primer show` and falls through to `Unknown command: C:/Program Files/Git/primer`. kd detects this shape on Windows and prints a one-line hint to stderr above the failure.
296
+
297
+ Per-invocation recovery:
298
+
299
+ - `MSYS_NO_PATHCONV=1 kd /primer show` — turn MSYS path conversion off for one call.
300
+ - `kd //primer show` — the double-slash form MSYS itself documents as "I meant a literal slash."
301
+
302
+ Recommended permanent fix — a kd-scoped bash alias in `~/.bashrc`:
303
+
304
+ ```bash
305
+ alias kd='MSYS_NO_PATHCONV=1 kd'
306
+ ```
307
+
308
+ Lowest blast radius: only `kd` invocations are affected. Other MSYS-aware tools (docker volume mounts, MinGW build scripts, etc.) keep their normal path-conversion behavior.
309
+
310
+ The global form (`export MSYS_NO_PATHCONV=1` in `~/.bashrc` or a Windows-level environment variable via `setx MSYS_NO_PATHCONV 1`) also works but turns path conversion off for *every* program in the shell — that surprises tools that rely on it. Prefer the alias unless you have a deliberate reason to disable conversion globally.
311
+
287
312
  ## Where To Go Next
288
313
 
289
314
  Use these sources together:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.6.2
3
+ Version: 2.6.3
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -22,6 +22,7 @@ Classifier: Topic :: Utilities
22
22
  Requires-Python: >=3.11
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
+ Requires-Dist: filelock>=3.12
25
26
  Requires-Dist: httpx>=0.27
26
27
  Requires-Dist: packaging>=23.0
27
28
  Requires-Dist: pydantic>=2.0
@@ -1,3 +1,4 @@
1
+ filelock>=3.12
1
2
  httpx>=0.27
2
3
  packaging>=23.0
3
4
  pydantic>=2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.6.2"
7
+ version = "2.6.3"
8
8
  description = "Knowledge Dispatcher for issue trackers and knowledge bases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -26,6 +26,7 @@ classifiers = [
26
26
  ]
27
27
 
28
28
  dependencies = [
29
+ "filelock>=3.12",
29
30
  "httpx>=0.27",
30
31
  "packaging>=23.0",
31
32
  "pydantic>=2.0",
@@ -1,97 +0,0 @@
1
- """Advisory file locking for read-modify-write transactions on kd-managed files.
2
-
3
- Provides :func:`file_transaction` — a context manager that acquires an
4
- exclusive advisory lock on a sidecar lockfile (``<path>.lock``) for the
5
- duration of a load → mutate → save cycle. Concurrent kd processes that
6
- enter the same transaction serialize rather than clobber each other.
7
-
8
- Design notes:
9
-
10
- * **Sidecar lockfile, not the target file.** Atomic writes elsewhere in
11
- kd use ``tempfile.mkstemp`` + ``os.replace``, which changes the target
12
- file's inode on every write. Holding ``flock`` on the target inode
13
- would not protect a second writer that opens the freshly-replaced file.
14
- Locking a separate sidecar file that nobody rewrites keeps the lock
15
- stable across atomic replaces of the data file.
16
-
17
- * **No unlink on release.** Removing the lockfile after releasing the
18
- lock is a classic race: process B can open the doomed inode while
19
- process C creates a fresh one, and both end up holding "exclusive"
20
- locks on different inodes. Empty sidecar files are cheap; leave them.
21
-
22
- * **POSIX only.** ``fcntl`` is imported lazily inside the context
23
- manager so that unrelated kd commands remain importable on Windows.
24
- On non-POSIX platforms, :func:`file_transaction` raises ``ConfigError``
25
- with a suggestion to run the command sequentially.
26
-
27
- * **NFS caveat.** ``flock`` on NFS has been unreliable historically.
28
- Modern Linux kernels implement it correctly, but operators using a
29
- networked ``$HOME`` should be aware that concurrency guarantees weaken.
30
- Local ``.kd/`` directories inside a repo are unaffected.
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import os
36
- import sys
37
- import time
38
- from collections.abc import Generator
39
- from contextlib import contextmanager
40
- from pathlib import Path
41
-
42
- from kd.exceptions import ConfigError
43
-
44
- LOCK_SUFFIX = ".lock"
45
- _POLL_INTERVAL_SECONDS = 0.05
46
-
47
-
48
- @contextmanager
49
- def file_transaction(path: Path, *, timeout: float = 30.0) -> Generator[None, None, None]:
50
- """Hold an exclusive advisory lock on ``<path>.lock`` for the ``with`` body.
51
-
52
- The sidecar lockfile is created if missing (``O_RDWR | O_CREAT``,
53
- never truncated). Acquisition polls non-blocking ``flock`` until
54
- the lock is granted or *timeout* seconds elapse. On timeout, raises
55
- :class:`ConfigError` naming the contested file. The lockfile is
56
- intentionally left on disk after release.
57
-
58
- Raises :class:`ConfigError` on non-POSIX platforms.
59
- """
60
- if sys.platform == "win32":
61
- raise ConfigError(
62
- "File locking is not available on this platform (POSIX only).",
63
- suggestion="Run the kd command sequentially; concurrent mutations are not supported on Windows.",
64
- )
65
-
66
- import fcntl # noqa: PLC0415 — lazy import keeps kd importable on Windows
67
-
68
- lock_path = path.with_name(path.name + LOCK_SUFFIX)
69
- lock_path.parent.mkdir(parents=True, exist_ok=True)
70
-
71
- fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
72
- try:
73
- deadline = time.monotonic() + timeout
74
- while True:
75
- try:
76
- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
77
- break
78
- except BlockingIOError:
79
- if time.monotonic() >= deadline:
80
- raise ConfigError(
81
- f"Another kd process is writing to {path}; retry in a moment.",
82
- suggestion=f"If no other kd process is running, remove the stale lockfile: {lock_path}",
83
- ) from None
84
- time.sleep(_POLL_INTERVAL_SECONDS)
85
-
86
- try:
87
- yield
88
- finally:
89
- try:
90
- fcntl.flock(fd, fcntl.LOCK_UN)
91
- except OSError:
92
- pass
93
- finally:
94
- try:
95
- os.close(fd)
96
- except OSError:
97
- pass
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
File without changes
File without changes
File without changes
File without changes