kd-cli 2.6.1__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.1/kd_cli.egg-info → kd_cli-2.6.3}/PKG-INFO +2 -1
  2. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/api/youtrack.py +87 -4
  3. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cache.py +61 -43
  4. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/core/option_registry.py +16 -1
  5. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/main.py +124 -2
  6. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/article/__init__.py +143 -16
  7. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/issue/__init__.py +144 -17
  8. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/project/__init__.py +42 -6
  9. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/query/__init__.py +62 -6
  10. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/system/__init__.py +1 -0
  11. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/tag/__init__.py +55 -12
  12. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/time/__init__.py +55 -3
  13. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/user/__init__.py +24 -2
  14. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/output.py +170 -34
  15. kd_cli-2.6.3/kd/locking.py +68 -0
  16. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/CHANGELOG.md +135 -0
  17. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/operating-guide.md +64 -3
  18. {kd_cli-2.6.1 → kd_cli-2.6.3/kd_cli.egg-info}/PKG-INFO +2 -1
  19. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd_cli.egg-info/requires.txt +1 -0
  20. {kd_cli-2.6.1 → kd_cli-2.6.3}/pyproject.toml +2 -1
  21. kd_cli-2.6.1/kd/locking.py +0 -97
  22. {kd_cli-2.6.1 → kd_cli-2.6.3}/LICENSE +0 -0
  23. {kd_cli-2.6.1 → kd_cli-2.6.3}/MANIFEST.in +0 -0
  24. {kd_cli-2.6.1 → kd_cli-2.6.3}/README.md +0 -0
  25. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/__init__.py +0 -0
  26. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/api/__init__.py +0 -0
  27. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/api/articles.py +0 -0
  28. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/api/http.py +0 -0
  29. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/api/hub.py +0 -0
  30. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/api/saved_queries.py +0 -0
  31. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/api/tags.py +0 -0
  32. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/article_marker.py +0 -0
  33. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/auth.py +0 -0
  34. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/__init__.py +0 -0
  35. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/core/__init__.py +0 -0
  36. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/core/command_base.py +0 -0
  37. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/core/command_parser.py +0 -0
  38. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/core/confirm.py +0 -0
  39. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/core/namespaces.py +0 -0
  40. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/input.py +0 -0
  41. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/__init__.py +0 -0
  42. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/alias/__init__.py +0 -0
  43. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/cache/__init__.py +0 -0
  44. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/init.py +0 -0
  45. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/primer/__init__.py +0 -0
  46. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/release_notes.py +0 -0
  47. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/namespaces/search/__init__.py +0 -0
  48. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/cli/tree.py +0 -0
  49. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/client.py +0 -0
  50. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/config.py +0 -0
  51. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/config_read.py +0 -0
  52. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/config_schema.py +0 -0
  53. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/connection.py +0 -0
  54. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/debug.py +0 -0
  55. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/exceptions.py +0 -0
  56. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/history.py +0 -0
  57. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/__init__.py +0 -0
  58. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/article.py +0 -0
  59. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/assist.py +0 -0
  60. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/attachment.py +0 -0
  61. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/comment.py +0 -0
  62. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/common.py +0 -0
  63. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/issue.py +0 -0
  64. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/project.py +0 -0
  65. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/saved_query.py +0 -0
  66. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/search.py +0 -0
  67. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/tag.py +0 -0
  68. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/tag_entities.py +0 -0
  69. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/user.py +0 -0
  70. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/models/work_item.py +0 -0
  71. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/operating_guide.py +0 -0
  72. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/primer.py +0 -0
  73. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/query_parser.py +0 -0
  74. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/release_notes.py +0 -0
  75. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/__init__.py +0 -0
  76. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/command-synonyms.yaml +0 -0
  77. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/config-template.yaml +0 -0
  78. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/error-messages.yaml +0 -0
  79. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/primers/__init__.py +0 -0
  80. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/primers/hitl.yaml +0 -0
  81. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/resources/sdlc-doctrine.md +0 -0
  82. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd/sdlc.py +0 -0
  83. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd_cli.egg-info/SOURCES.txt +0 -0
  84. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd_cli.egg-info/dependency_links.txt +0 -0
  85. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd_cli.egg-info/entry_points.txt +0 -0
  86. {kd_cli-2.6.1 → kd_cli-2.6.3}/kd_cli.egg-info/top_level.txt +0 -0
  87. {kd_cli-2.6.1 → 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.1
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",
@@ -246,7 +246,12 @@ def state_option(
246
246
  )
247
247
 
248
248
 
249
- # Standard OUTPUT options available to all commands
249
+ # Standard OUTPUT options available to all commands.
250
+ #
251
+ # ``--quiet`` is registered globally so any command's parser accepts it,
252
+ # but it only affects mutation commands that opt into the write-output
253
+ # contract (``display_hint="mutation"``). On reads and structured output
254
+ # modes (``-o yaml`` / ``-o json``), ``--quiet`` is silently no-op.
250
255
  OUTPUT_OPTIONS: list[OptionDeclaration] = [
251
256
  OptionDeclaration(
252
257
  name="output",
@@ -263,4 +268,14 @@ OUTPUT_OPTIONS: list[OptionDeclaration] = [
263
268
  type=str,
264
269
  help_text="Comma-separated field list",
265
270
  ),
271
+ OptionDeclaration(
272
+ name="quiet",
273
+ layer=OptionLayer.OUTPUT,
274
+ type=bool,
275
+ default=False,
276
+ help_text=(
277
+ "Suppress detail block on mutation commands; print only the "
278
+ "single-line ack. No-op on reads and structured modes (-o yaml/json)."
279
+ ),
280
+ ),
266
281
  ]