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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {kd_cli-2.10.0/kd_cli.egg-info → kd_cli-2.11.0}/PKG-INFO +18 -14
  2. {kd_cli-2.10.0 → kd_cli-2.11.0}/README.md +17 -13
  3. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/http.py +97 -18
  4. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/youtrack.py +3 -3
  5. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/auth.py +2 -23
  6. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cache.py +3 -3
  7. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/command_base.py +3 -0
  8. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/input.py +14 -0
  9. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/main.py +218 -12
  10. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/article/__init__.py +35 -21
  11. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/cache/__init__.py +33 -22
  12. kd_cli-2.11.0/kd/cli/namespaces/init.py +135 -0
  13. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/issue/__init__.py +25 -26
  14. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/primer/__init__.py +28 -19
  15. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/project/__init__.py +1 -1
  16. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/query/__init__.py +2 -2
  17. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/system/__init__.py +223 -117
  18. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/tag/__init__.py +3 -3
  19. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/output.py +13 -2
  20. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/client.py +79 -17
  21. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/config.py +661 -114
  22. kd_cli-2.11.0/kd/config_read.py +332 -0
  23. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/connection.py +110 -51
  24. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/exceptions.py +56 -18
  25. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/primer.py +114 -26
  26. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/CHANGELOG.md +238 -237
  27. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/command-synonyms.yaml +1 -1
  28. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/error-messages.yaml +124 -13
  29. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/operating-guide.md +120 -38
  30. kd_cli-2.11.0/kd/resources/primers/hitl.yaml +27 -0
  31. {kd_cli-2.10.0 → kd_cli-2.11.0/kd_cli.egg-info}/PKG-INFO +18 -14
  32. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/SOURCES.txt +0 -2
  33. {kd_cli-2.10.0 → kd_cli-2.11.0}/pyproject.toml +1 -1
  34. kd_cli-2.10.0/kd/cli/namespaces/init.py +0 -62
  35. kd_cli-2.10.0/kd/config_read.py +0 -194
  36. kd_cli-2.10.0/kd/resources/primers/hitl.yaml +0 -41
  37. kd_cli-2.10.0/kd/resources/sdlc-doctrine.md +0 -602
  38. kd_cli-2.10.0/kd/sdlc.py +0 -21
  39. {kd_cli-2.10.0 → kd_cli-2.11.0}/LICENSE +0 -0
  40. {kd_cli-2.10.0 → kd_cli-2.11.0}/MANIFEST.in +0 -0
  41. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/__init__.py +0 -0
  42. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/__init__.py +0 -0
  43. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/articles.py +0 -0
  44. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/hub.py +0 -0
  45. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/saved_queries.py +0 -0
  46. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/tags.py +0 -0
  47. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/article_marker.py +0 -0
  48. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/__init__.py +0 -0
  49. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/__init__.py +0 -0
  50. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/command_parser.py +0 -0
  51. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/confirm.py +0 -0
  52. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/namespaces.py +0 -0
  53. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/option_registry.py +0 -0
  54. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/__init__.py +0 -0
  55. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  56. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/release_notes.py +0 -0
  57. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/search/__init__.py +0 -0
  58. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/time/__init__.py +0 -0
  59. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/user/__init__.py +0 -0
  60. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/tree.py +0 -0
  61. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/config_schema.py +0 -0
  62. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/debug.py +0 -0
  63. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/history.py +0 -0
  64. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/locking.py +0 -0
  65. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/__init__.py +0 -0
  66. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/article.py +0 -0
  67. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/assist.py +0 -0
  68. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/attachment.py +0 -0
  69. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/comment.py +0 -0
  70. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/common.py +0 -0
  71. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/issue.py +0 -0
  72. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/project.py +0 -0
  73. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/saved_query.py +0 -0
  74. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/search.py +0 -0
  75. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/tag.py +0 -0
  76. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/tag_entities.py +0 -0
  77. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/user.py +0 -0
  78. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/work_item.py +0 -0
  79. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/operating_guide.py +0 -0
  80. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/query_parser.py +0 -0
  81. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/release_notes.py +0 -0
  82. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/__init__.py +0 -0
  83. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/config-template.yaml +0 -0
  84. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/primers/__init__.py +0 -0
  85. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  86. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/entry_points.txt +0 -0
  87. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/requires.txt +0 -0
  88. {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/top_level.txt +0 -0
  89. {kd_cli-2.10.0 → kd_cli-2.11.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.10.0
3
+ Version: 2.11.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -59,7 +59,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
59
59
  It exists to solve a problem that has gotten worse as LLMs have become central to real work:
60
60
 
61
61
  - **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
62
- - **LLM-portable.** One primer works for any model. Switch from Claude to GPT-5 to the next generation — the primer travels with the project, not with a vendor.
62
+ - **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
63
63
  - **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
64
64
  - **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
65
65
 
@@ -83,20 +83,24 @@ uv tool install kd-cli
83
83
 
84
84
  The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
85
85
 
86
- Release notes for the installed version: `kd /release-notes`. The canonical changelog lives at [`kd/resources/CHANGELOG.md`](kd/resources/CHANGELOG.md).
86
+ Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
87
87
 
88
88
  ## Configure
89
89
 
90
90
  ```bash
91
- # Scaffold a project-local config in the current directory.
92
- kd init
91
+ # Global config with your YouTrack URL (recommended on first install).
92
+ kd init --global https://your-instance.youtrack.cloud
93
93
 
94
- # Or set via environment (overrides workspace + global config).
95
- export KD_TOKEN=your_token
96
- export KD_CONNECTION=main
94
+ # The scaffolded connection reads its token from KD_TOKEN (its token_env).
95
+ export KD_TOKEN=perm:your-token-here
96
+
97
+ # Check the connection.
98
+ kd /system info
97
99
  ```
98
100
 
99
- Global config lives at `~/.config/kd/config.yaml`; workspace config lives at `.kd/config.yaml`.
101
+ Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
102
+
103
+ Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
100
104
 
101
105
  ## Usage
102
106
 
@@ -116,13 +120,13 @@ kd /issue show FB-1
116
120
  kd /time log FB-1 45 --type Development --description "Router reconfiguration"
117
121
 
118
122
  # Articles
119
- kd /article list --project CMCSA
120
- kd /article push guide.md --project CMCSA --title "Setup Guide" --tag "type:guide"
123
+ kd /article list --project FB
124
+ kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
121
125
  kd /article pull FB-A-42 > setup.md
122
126
 
123
127
  # Tags
124
128
  kd /tag list
125
- kd /tag articles type:case --project CMCSA
129
+ kd /tag articles type:case --project FB
126
130
 
127
131
  # Saved queries
128
132
  kd /query list
@@ -156,7 +160,7 @@ client = YouTrackClient(
156
160
  projects = client.projects.list()
157
161
  issue = client.issues.create(project="FB", summary="Fix router config")
158
162
  client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
159
- articles = client.tags.articles(["type:case"], project="CMCSA")
163
+ articles = client.tags.articles(["type:case"], project="FB")
160
164
  results = client.search.search("router", types=["issue", "article"])
161
165
  ```
162
166
 
@@ -171,4 +175,4 @@ uv run mypy
171
175
  ```
172
176
 
173
177
  ---
174
- Updated: 2026-04-14
178
+ Updated: 2026-11-01
@@ -19,7 +19,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
19
19
  It exists to solve a problem that has gotten worse as LLMs have become central to real work:
20
20
 
21
21
  - **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
22
- - **LLM-portable.** One primer works for any model. Switch from Claude to GPT-5 to the next generation — the primer travels with the project, not with a vendor.
22
+ - **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
23
23
  - **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
24
24
  - **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
25
25
 
@@ -43,20 +43,24 @@ uv tool install kd-cli
43
43
 
44
44
  The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
45
45
 
46
- Release notes for the installed version: `kd /release-notes`. The canonical changelog lives at [`kd/resources/CHANGELOG.md`](kd/resources/CHANGELOG.md).
46
+ Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
47
47
 
48
48
  ## Configure
49
49
 
50
50
  ```bash
51
- # Scaffold a project-local config in the current directory.
52
- kd init
51
+ # Global config with your YouTrack URL (recommended on first install).
52
+ kd init --global https://your-instance.youtrack.cloud
53
53
 
54
- # Or set via environment (overrides workspace + global config).
55
- export KD_TOKEN=your_token
56
- export KD_CONNECTION=main
54
+ # The scaffolded connection reads its token from KD_TOKEN (its token_env).
55
+ export KD_TOKEN=perm:your-token-here
56
+
57
+ # Check the connection.
58
+ kd /system info
57
59
  ```
58
60
 
59
- Global config lives at `~/.config/kd/config.yaml`; workspace config lives at `.kd/config.yaml`.
61
+ Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
62
+
63
+ Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
60
64
 
61
65
  ## Usage
62
66
 
@@ -76,13 +80,13 @@ kd /issue show FB-1
76
80
  kd /time log FB-1 45 --type Development --description "Router reconfiguration"
77
81
 
78
82
  # Articles
79
- kd /article list --project CMCSA
80
- kd /article push guide.md --project CMCSA --title "Setup Guide" --tag "type:guide"
83
+ kd /article list --project FB
84
+ kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
81
85
  kd /article pull FB-A-42 > setup.md
82
86
 
83
87
  # Tags
84
88
  kd /tag list
85
- kd /tag articles type:case --project CMCSA
89
+ kd /tag articles type:case --project FB
86
90
 
87
91
  # Saved queries
88
92
  kd /query list
@@ -116,7 +120,7 @@ client = YouTrackClient(
116
120
  projects = client.projects.list()
117
121
  issue = client.issues.create(project="FB", summary="Fix router config")
118
122
  client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
119
- articles = client.tags.articles(["type:case"], project="CMCSA")
123
+ articles = client.tags.articles(["type:case"], project="FB")
120
124
  results = client.search.search("router", types=["issue", "article"])
121
125
  ```
122
126
 
@@ -131,4 +135,4 @@ uv run mypy
131
135
  ```
132
136
 
133
137
  ---
134
- Updated: 2026-04-14
138
+ Updated: 2026-11-01
@@ -11,6 +11,7 @@ Wraps httpx.Client with:
11
11
  from __future__ import annotations
12
12
 
13
13
  import json
14
+ import math
14
15
  import time
15
16
  from typing import Any
16
17
 
@@ -27,8 +28,11 @@ from kd.exceptions import (
27
28
  enrich_error,
28
29
  )
29
30
 
30
- _RETRYABLE_STATUSES = {429, 502, 503}
31
- _MAX_RETRIES = 3
31
+ _GATEWAY_RETRY = {502, 503} # retried, only for a request that cannot write
32
+ _GATEWAY_UNCONFIRMED = {502, 503, 504} # on a write: may have been applied
33
+ _MAX_ATTEMPTS = 3
34
+ _BACKOFF_BASE = 1.0 # seconds; doubles per attempt
35
+ _RETRY_AFTER_CAP = 10.0 # seconds; a longer Retry-After ends the request
32
36
 
33
37
 
34
38
  def _refuse_control_characters(path: str) -> None:
@@ -47,6 +51,44 @@ def _refuse_control_characters(path: str) -> None:
47
51
  )
48
52
 
49
53
 
54
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
55
+ """Return ``Retry-After`` as non-negative seconds, or ``None``.
56
+
57
+ The HTTP-date form, a negative value and anything unparseable count
58
+ as no header.
59
+ """
60
+ raw = response.headers.get("Retry-After")
61
+ if raw is None:
62
+ return None
63
+ try:
64
+ value = float(raw)
65
+ except ValueError:
66
+ return None
67
+ if value < 0 or not math.isfinite(value):
68
+ return None
69
+ return value
70
+
71
+
72
+ def _retry_delay(response: httpx.Response, attempt: int, *, safe: bool) -> float | None:
73
+ """Return the delay before the next attempt, or ``None`` to stop.
74
+
75
+ A 429 is retried for any method: the server refused before processing
76
+ it. It waits ``Retry-After`` up to the cap, and above the cap it stops.
77
+ A 502 or 503 is retried only for a request that cannot write, since a
78
+ proxy can answer one after the server applied the write. A 504 is not
79
+ retried.
80
+ """
81
+ backoff: float = _BACKOFF_BASE * 2.0**attempt
82
+ if response.status_code == 429:
83
+ wait = _retry_after_seconds(response)
84
+ if wait is None:
85
+ return backoff
86
+ return wait if wait <= _RETRY_AFTER_CAP else None
87
+ if response.status_code in _GATEWAY_RETRY and safe:
88
+ return backoff
89
+ return None
90
+
91
+
50
92
  class HttpClient:
51
93
  """Low-level HTTP client with auth, retry, and error mapping."""
52
94
 
@@ -86,12 +128,17 @@ class HttpClient:
86
128
  json: dict[str, Any] | None = None,
87
129
  fields: str | None = None,
88
130
  params: dict[str, Any] | None = None,
131
+ safe: bool = False,
89
132
  ) -> Any:
90
- """POST with optional fields on query string."""
133
+ """POST with optional fields on query string.
134
+
135
+ ``safe=True`` marks a POST that only reads (an assist call): it is
136
+ retried like a GET, and a failure is never reported as a write.
137
+ """
91
138
  all_params: dict[str, Any] = dict(params or {})
92
139
  if fields:
93
140
  all_params["fields"] = fields
94
- response = self._request("POST", path, json=json, params=all_params or None)
141
+ response = self._request("POST", path, json=json, params=all_params or None, safe=safe)
95
142
  if response.status_code == 204 or not response.content:
96
143
  return {}
97
144
  return self._decode_json(response)
@@ -131,10 +178,13 @@ class HttpClient:
131
178
  """DELETE. Returns nothing."""
132
179
  self._request("DELETE", path)
133
180
 
134
- def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
181
+ def _request(
182
+ self, method: str, path: str, *, safe: bool = False, **kwargs: Any
183
+ ) -> httpx.Response:
135
184
  """Execute request with retry and error mapping."""
136
185
  _refuse_control_characters(path)
137
186
  d = self._debug
187
+ safe = safe or method == "GET"
138
188
 
139
189
  # Log request details before first attempt
140
190
  if params := kwargs.get("params"):
@@ -148,8 +198,7 @@ class HttpClient:
148
198
  d.trace("http", " headers: Authorization=Bearer ***")
149
199
 
150
200
  start = time.monotonic()
151
- last_response: httpx.Response | None = None
152
- for attempt in range(_MAX_RETRIES):
201
+ for attempt in range(_MAX_ATTEMPTS):
153
202
  try:
154
203
  response = self._client.request(method, path, **kwargs)
155
204
  except httpx.HTTPError as e:
@@ -166,12 +215,14 @@ class HttpClient:
166
215
  d.trace("http", f" response: {response.text[:1000]}")
167
216
  return response
168
217
 
169
- last_response = response
170
- if response.status_code in _RETRYABLE_STATUSES and attempt < _MAX_RETRIES - 1:
218
+ delay = _retry_delay(response, attempt, safe=safe)
219
+ if delay is not None and attempt < _MAX_ATTEMPTS - 1:
171
220
  d.debug(
172
221
  "http",
173
- f" retry {attempt + 1}/{_MAX_RETRIES} (status {response.status_code})",
222
+ f" retry {attempt + 1}/{_MAX_ATTEMPTS} in {delay:g}s "
223
+ f"(status {response.status_code})",
174
224
  )
225
+ time.sleep(delay)
175
226
  continue
176
227
 
177
228
  d.info(
@@ -179,11 +230,9 @@ class HttpClient:
179
230
  f"{method} {path} -> {response.status_code} ({duration_ms:.0f}ms) FAILED",
180
231
  )
181
232
  d.trace("http", f" response: {response.text[:1000]}")
182
- self._raise_for_status(response)
233
+ self._raise_for_status(response, unconfirmed_write=self._unconfirmed(response, safe))
183
234
 
184
- # Satisfy type checker — _raise_for_status always raises
185
- assert last_response is not None
186
- self._raise_for_status(last_response)
235
+ # Every attempt returns, raises or continues while attempts remain.
187
236
  raise AssertionError("unreachable") # pragma: no cover
188
237
 
189
238
  @staticmethod
@@ -249,15 +298,45 @@ class HttpClient:
249
298
  return body
250
299
 
251
300
  @staticmethod
252
- def _raise_for_status(response: httpx.Response) -> None:
253
- """Map HTTP status to kd exceptions. Always raises."""
301
+ def _unconfirmed(response: httpx.Response, safe: bool) -> bool:
302
+ """A 502, 503 or 504 on a request that can write: it may have been applied."""
303
+ return response.status_code in _GATEWAY_UNCONFIRMED and not safe
304
+
305
+ @staticmethod
306
+ def _raise_for_status(response: httpx.Response, *, unconfirmed_write: bool = False) -> None:
307
+ """Map HTTP status to kd exceptions. Always raises.
308
+
309
+ *unconfirmed_write* marks a 502, 503 or 504 on a request kd did not
310
+ resend because it can write: the message says so and the hint
311
+ names a read that checks it.
312
+ """
254
313
  body = response.text
255
314
  status = response.status_code
256
315
  extracted = HttpClient._extract_message(body)
257
316
  request_path = response.request.url.path if response.request else None
258
- message, suggestion = enrich_error(extracted, status=status, path=request_path)
317
+ message, suggestion = enrich_error(
318
+ extracted,
319
+ status=status,
320
+ path=request_path,
321
+ unconfirmed_write=unconfirmed_write,
322
+ )
323
+ if unconfirmed_write:
324
+ method = response.request.method if response.request else "request"
325
+ lines = message.strip().splitlines()
326
+ server = lines[0] if lines else "(empty response)"
327
+ raise APIError(
328
+ status,
329
+ f"API error ({status}) on {method} {request_path}: kd did not resend it, "
330
+ f"because the server may have applied it. Server said: {server}",
331
+ body,
332
+ suggestion,
333
+ )
334
+ if status == 429:
335
+ wait = _retry_after_seconds(response)
336
+ if wait is not None:
337
+ message = f"{message}; the server asked to wait {wait:g} s."
259
338
  if status == 401:
260
- raise AuthError(f"Authentication failed: {message}")
339
+ raise AuthError(f"Authentication failed: {message}", suggestion=suggestion)
261
340
  if status == 403:
262
341
  body_lower = body.lower()
263
342
  if suggestion or "permission" in body_lower or "access" in body_lower:
@@ -267,7 +267,7 @@ class YouTrackAPI:
267
267
  "Create one issue (any contributor token works), or use a "
268
268
  "token with project-administration rights. Or omit "
269
269
  "--type / --priority / --state to create a minimal first "
270
- "issue, then update it via `/issue update`. See KDCLI-A-22."
270
+ "issue, then update it via `/issue update`."
271
271
  ),
272
272
  )
273
273
 
@@ -525,7 +525,7 @@ class YouTrackAPI:
525
525
  body: dict[str, Any] = {"query": query}
526
526
  if caret is not None:
527
527
  body["caret"] = caret
528
- data = self._http.post("/search/assist", json=body, fields=_ASSIST_FIELDS)
528
+ data = self._http.post("/search/assist", json=body, fields=_ASSIST_FIELDS, safe=True)
529
529
  return AssistResponse.model_validate(data)
530
530
 
531
531
  def commands_assist(
@@ -546,5 +546,5 @@ class YouTrackAPI:
546
546
  }
547
547
  if caret is not None:
548
548
  body["caret"] = caret
549
- data = self._http.post("/commands/assist", json=body, fields=_ASSIST_FIELDS)
549
+ data = self._http.post("/commands/assist", json=body, fields=_ASSIST_FIELDS, safe=True)
550
550
  return AssistResponse.model_validate(data)
@@ -69,9 +69,8 @@ def resolve_connection_token(
69
69
  value = os.environ.get(conn.token_env)
70
70
  if value:
71
71
  return value
72
- raise _missing_token_error(conn, name, origin)
73
-
74
- raise _no_auth_source_error(conn, name, origin)
72
+ # ConnectionConfig requires token_cmd or token_env, so only an unset env var lands here.
73
+ raise _missing_token_error(conn, name, origin)
75
74
 
76
75
 
77
76
  def _run_token_cmd(cmd: str) -> str:
@@ -122,26 +121,6 @@ def _missing_token_error(
122
121
  return AuthError(message, suggestion=suggestion)
123
122
 
124
123
 
125
- def _no_auth_source_error(
126
- conn: ConnectionConfig,
127
- name: str | None,
128
- origin: ConnectionOrigin | None,
129
- ) -> AuthError:
130
- """Build an ``AuthError`` for a connection with no ``token_cmd`` or ``token_env``."""
131
- where = _describe_connection(conn, name)
132
- message = f"{where} has no auth source configured."
133
- suggestion = _build_suggestion(
134
- lines=[
135
- "Set one of `token_cmd:` or `token_env:` on the connection:",
136
- f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
137
- f" connections.{name or '<name>'}.token_env: KD_{(name or 'NAME').upper()}_TOKEN",
138
- ],
139
- origin=origin,
140
- key=None,
141
- )
142
- return AuthError(message, suggestion=suggestion)
143
-
144
-
145
124
  def _describe_connection(conn: ConnectionConfig, name: str | None) -> str:
146
125
  """Render the ``Connection '<name>' (<url>)`` prefix, degraded when no name."""
147
126
  if name:
@@ -153,7 +153,7 @@ SCOPE_MANIFEST_KIND = "cache_scope"
153
153
 
154
154
  #: Markdown banner for cached article bodies. Five-space continuation
155
155
  #: indent matches ``"<!-- "`` width so the banner reads as one logical
156
- #: line. Pinned by tests; aligned with KDCLI-A-18 §Cache contract.
156
+ #: line. Pinned by tests; described in ``kd --guide`` §Cache.
157
157
  BANNER_TEMPLATE_MD = (
158
158
  "<!-- kd-cache: upstream={id} fetched={iso}.\n"
159
159
  " Disposable mirror. Edits lost on refresh. Not authoritative. -->"
@@ -2026,7 +2026,7 @@ def refresh_primer_scope(
2026
2026
  Read-only by contract: writes only under ``cache_root``; the primer
2027
2027
  is read but never mutated; no upstream mutation.
2028
2028
 
2029
- See KDCLI-A-18 §Cache contract; M008 proposal 39.
2029
+ See ``kd --guide`` §Cache.
2030
2030
  """
2031
2031
  workspace_dir = require_workspace_dir()
2032
2032
  primer = load_primer(workspace_dir / PRIMER_FILENAME)
@@ -2440,7 +2440,7 @@ def refresh_milestone(
2440
2440
  """Hydrate a milestone-scoped query into ``cache_root/queries/milestone-<slug>.yaml``.
2441
2441
 
2442
2442
  Resolves the project's milestone field through the per-project
2443
- ``field_aliases`` map (KDCLI-A-18 §Project tier) — if the project
2443
+ ``field_aliases`` map — if the project
2444
2444
  aliased ``milestone`` → ``Stage``, the discovered field is ``Stage``
2445
2445
  and the cache file's ``source.field_name`` records that. Without an
2446
2446
  alias, falls back to the literal ``Milestone`` name. The discovery
@@ -62,6 +62,9 @@ class Command(ABC):
62
62
  self.examples: list[str] = examples or []
63
63
  self.see_also: list[str] = see_also or []
64
64
  self.display_hint: str | None = None
65
+ # A connection the command's own output names as invalid: the dispatcher
66
+ # adds no connection_invalid warning about it (kd init's next line).
67
+ self.invalid_connection_named: str | None = None
65
68
  self._options: list[OptionDeclaration] = []
66
69
  self._resolved_config: ResolvedConfig | None = None
67
70
  self._register_options()
@@ -12,6 +12,20 @@ from pathlib import Path
12
12
 
13
13
  from kd.exceptions import ValidationError
14
14
 
15
+ #: Help clause for every option and argument read through
16
+ #: :func:`resolve_text_input`; it names the guide section with the shell recipes.
17
+ TEXT_INPUT_HELP = (
18
+ 'Pass "@file.md" to read a file or "@-" to read stdin, in quotes '
19
+ "(PowerShell needs them); see kd --guide §Markdown Bodies"
20
+ )
21
+
22
+ #: Help sentence for the attachment commands: how a body references the file
23
+ #: the user passed in.
24
+ IMAGE_REFERENCE_HELP = (
25
+ "Reference an attached image in the body by its file name alone, as in "
26
+ "![Diagram](diagram.png); see kd --guide §Markdown Bodies."
27
+ )
28
+
15
29
 
16
30
  def resolve_text_input(value: str | None) -> str | None:
17
31
  """Resolve a text value that may reference a file or stdin.