kd-cli 2.9.1__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 (90) hide show
  1. {kd_cli-2.9.1/kd_cli.egg-info → kd_cli-2.11.0}/PKG-INFO +19 -15
  2. {kd_cli-2.9.1 → kd_cli-2.11.0}/README.md +18 -14
  3. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/http.py +115 -18
  4. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/youtrack.py +3 -3
  5. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/article_marker.py +23 -9
  6. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/auth.py +2 -23
  7. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cache.py +3 -3
  8. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/command_base.py +21 -0
  9. kd_cli-2.11.0/kd/cli/core/command_parser.py +348 -0
  10. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/option_registry.py +24 -8
  11. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/input.py +14 -0
  12. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/main.py +218 -12
  13. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/article/__init__.py +59 -29
  14. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/cache/__init__.py +33 -22
  15. kd_cli-2.11.0/kd/cli/namespaces/init.py +135 -0
  16. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/issue/__init__.py +89 -48
  17. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/primer/__init__.py +84 -27
  18. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/project/__init__.py +12 -12
  19. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/query/__init__.py +16 -20
  20. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/system/__init__.py +229 -120
  21. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/tag/__init__.py +10 -6
  22. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/time/__init__.py +2 -2
  23. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/user/__init__.py +5 -6
  24. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/output.py +69 -21
  25. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/client.py +164 -25
  26. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/config.py +661 -114
  27. kd_cli-2.11.0/kd/config_read.py +332 -0
  28. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/connection.py +110 -51
  29. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/exceptions.py +56 -18
  30. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/primer.py +114 -26
  31. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/CHANGELOG.md +247 -85
  32. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/command-synonyms.yaml +1 -1
  33. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/error-messages.yaml +124 -13
  34. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/operating-guide.md +179 -44
  35. kd_cli-2.11.0/kd/resources/primers/hitl.yaml +27 -0
  36. {kd_cli-2.9.1 → kd_cli-2.11.0/kd_cli.egg-info}/PKG-INFO +19 -15
  37. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/SOURCES.txt +0 -2
  38. {kd_cli-2.9.1 → kd_cli-2.11.0}/pyproject.toml +2 -1
  39. kd_cli-2.9.1/kd/cli/core/command_parser.py +0 -246
  40. kd_cli-2.9.1/kd/cli/namespaces/init.py +0 -61
  41. kd_cli-2.9.1/kd/config_read.py +0 -194
  42. kd_cli-2.9.1/kd/resources/primers/hitl.yaml +0 -41
  43. kd_cli-2.9.1/kd/resources/sdlc-doctrine.md +0 -602
  44. kd_cli-2.9.1/kd/sdlc.py +0 -21
  45. {kd_cli-2.9.1 → kd_cli-2.11.0}/LICENSE +0 -0
  46. {kd_cli-2.9.1 → kd_cli-2.11.0}/MANIFEST.in +0 -0
  47. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/__init__.py +0 -0
  48. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/__init__.py +0 -0
  49. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/articles.py +0 -0
  50. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/hub.py +0 -0
  51. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/saved_queries.py +0 -0
  52. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/tags.py +0 -0
  53. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/__init__.py +0 -0
  54. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/__init__.py +0 -0
  55. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/confirm.py +0 -0
  56. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/namespaces.py +0 -0
  57. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/__init__.py +0 -0
  58. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  59. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/release_notes.py +0 -0
  60. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/search/__init__.py +0 -0
  61. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/tree.py +0 -0
  62. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/config_schema.py +0 -0
  63. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/debug.py +0 -0
  64. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/history.py +0 -0
  65. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/locking.py +0 -0
  66. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/__init__.py +0 -0
  67. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/article.py +0 -0
  68. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/assist.py +0 -0
  69. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/attachment.py +0 -0
  70. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/comment.py +0 -0
  71. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/common.py +0 -0
  72. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/issue.py +0 -0
  73. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/project.py +0 -0
  74. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/saved_query.py +0 -0
  75. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/search.py +0 -0
  76. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/tag.py +0 -0
  77. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/tag_entities.py +0 -0
  78. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/user.py +0 -0
  79. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/work_item.py +0 -0
  80. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/operating_guide.py +0 -0
  81. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/query_parser.py +0 -0
  82. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/release_notes.py +0 -0
  83. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/__init__.py +0 -0
  84. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/config-template.yaml +0 -0
  85. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/primers/__init__.py +0 -0
  86. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  87. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/entry_points.txt +0 -0
  88. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/requires.txt +0 -0
  89. {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/top_level.txt +0 -0
  90. {kd_cli-2.9.1 → 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.9.1
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
 
@@ -167,8 +171,8 @@ pytest tests/ -v --tb=short
167
171
 
168
172
  ruff format kd/ tests/
169
173
  ruff check --fix kd/ tests/
170
- mypy kd/
174
+ 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
 
@@ -127,8 +131,8 @@ pytest tests/ -v --tb=short
127
131
 
128
132
  ruff format kd/ tests/
129
133
  ruff check --fix kd/ tests/
130
- mypy kd/
134
+ 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
 
@@ -23,11 +24,69 @@ from kd.exceptions import (
23
24
  ConflictError,
24
25
  NotFoundError,
25
26
  PermissionDeniedError,
27
+ ValidationError,
26
28
  enrich_error,
27
29
  )
28
30
 
29
- _RETRYABLE_STATUSES = {429, 502, 503}
30
- _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
36
+
37
+
38
+ def _refuse_control_characters(path: str) -> None:
39
+ """Refuse a path that no URL can carry, before it is sent.
40
+
41
+ The refused set is exactly what httpx rejects as non-printable ASCII:
42
+ C0 controls and DEL. The user values kd puts in a path are entity ids,
43
+ one per segment, so the error names the segment that carries the
44
+ character, escaped onto one line.
45
+ """
46
+ for segment in path.split("/"):
47
+ if any(c < " " or c == "\x7f" for c in segment):
48
+ raise ValidationError(
49
+ f"Invalid id {segment!r}: it contains a control character "
50
+ "(line break, tab or similar). Pass one id per argument."
51
+ )
52
+
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
31
90
 
32
91
 
33
92
  class HttpClient:
@@ -69,12 +128,17 @@ class HttpClient:
69
128
  json: dict[str, Any] | None = None,
70
129
  fields: str | None = None,
71
130
  params: dict[str, Any] | None = None,
131
+ safe: bool = False,
72
132
  ) -> Any:
73
- """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
+ """
74
138
  all_params: dict[str, Any] = dict(params or {})
75
139
  if fields:
76
140
  all_params["fields"] = fields
77
- 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)
78
142
  if response.status_code == 204 or not response.content:
79
143
  return {}
80
144
  return self._decode_json(response)
@@ -114,9 +178,13 @@ class HttpClient:
114
178
  """DELETE. Returns nothing."""
115
179
  self._request("DELETE", path)
116
180
 
117
- 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:
118
184
  """Execute request with retry and error mapping."""
185
+ _refuse_control_characters(path)
119
186
  d = self._debug
187
+ safe = safe or method == "GET"
120
188
 
121
189
  # Log request details before first attempt
122
190
  if params := kwargs.get("params"):
@@ -130,8 +198,7 @@ class HttpClient:
130
198
  d.trace("http", " headers: Authorization=Bearer ***")
131
199
 
132
200
  start = time.monotonic()
133
- last_response: httpx.Response | None = None
134
- for attempt in range(_MAX_RETRIES):
201
+ for attempt in range(_MAX_ATTEMPTS):
135
202
  try:
136
203
  response = self._client.request(method, path, **kwargs)
137
204
  except httpx.HTTPError as e:
@@ -148,12 +215,14 @@ class HttpClient:
148
215
  d.trace("http", f" response: {response.text[:1000]}")
149
216
  return response
150
217
 
151
- last_response = response
152
- 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:
153
220
  d.debug(
154
221
  "http",
155
- 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})",
156
224
  )
225
+ time.sleep(delay)
157
226
  continue
158
227
 
159
228
  d.info(
@@ -161,11 +230,9 @@ class HttpClient:
161
230
  f"{method} {path} -> {response.status_code} ({duration_ms:.0f}ms) FAILED",
162
231
  )
163
232
  d.trace("http", f" response: {response.text[:1000]}")
164
- self._raise_for_status(response)
233
+ self._raise_for_status(response, unconfirmed_write=self._unconfirmed(response, safe))
165
234
 
166
- # Satisfy type checker — _raise_for_status always raises
167
- assert last_response is not None
168
- self._raise_for_status(last_response)
235
+ # Every attempt returns, raises or continues while attempts remain.
169
236
  raise AssertionError("unreachable") # pragma: no cover
170
237
 
171
238
  @staticmethod
@@ -231,15 +298,45 @@ class HttpClient:
231
298
  return body
232
299
 
233
300
  @staticmethod
234
- def _raise_for_status(response: httpx.Response) -> None:
235
- """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
+ """
236
313
  body = response.text
237
314
  status = response.status_code
238
315
  extracted = HttpClient._extract_message(body)
239
316
  request_path = response.request.url.path if response.request else None
240
- 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."
241
338
  if status == 401:
242
- raise AuthError(f"Authentication failed: {message}")
339
+ raise AuthError(f"Authentication failed: {message}", suggestion=suggestion)
243
340
  if status == 403:
244
341
  body_lower = body.lower()
245
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)
@@ -18,6 +18,9 @@ Grammar (informal):
18
18
  Rules (pinned down by ``tests/test_article_marker.py``):
19
19
 
20
20
  - Open and close lines must be exact (``<!-- kd-meta`` / ``-->``).
21
+ - Only a marker that opens on the first non-blank line counts; a
22
+ marker-open line after body content is body. A second marker block
23
+ directly below the top one (only blank lines between) is an error.
21
24
  - Keys match ``[a-z][a-z0-9-]*``; uppercase and underscores are
22
25
  rejected to keep the grammar ASCII-boring.
23
26
  - Unknown keys are preserved verbatim on round-trip — the load-bearing
@@ -49,8 +52,9 @@ Legacy marker synonym (transitional, proposal 28 / M003 §S4):
49
52
  upstream population of ``ytctl-meta`` is empty. No auto-disable,
50
53
  no timer — a deliberate future milestone.
51
54
 
52
- Stdlib only; no third-party deps. The push workflow (in
53
- ``kd.client.ArticleOperations.push``) and its tests are the only
55
+ Stdlib only; no third-party deps. The push workflow
56
+ (``kd.client.ArticleOperations.push``), ``/article update``
57
+ (``kd.client.ArticleOperations.update``) and their tests are the only
54
58
  importers.
55
59
  """
56
60
 
@@ -248,13 +252,17 @@ def parse_content(text: str) -> ParsedFile:
248
252
  if close_idx is None:
249
253
  raise MarkerError("marker block not closed (expected '-->' on its own line)")
250
254
 
251
- # Refuse a second marker anywhere later in the file — native or
252
- # legacy. Prevents a stray ``<!-- ytctl-meta`` further down from
253
- # being silently accepted as body content on a file that also
254
- # carries a native header at the top.
255
- for k in range(close_idx + 1, len(lines)):
256
- if lines[k] in _RECOGNIZED_OPENS:
257
- raise MarkerError("file contains more than one kd-meta block")
255
+ # A second marker block with only blank lines above it would become
256
+ # the identity once the top block is stripped (``/article update
257
+ # --content``) or edited away, so it is refused. A marker-open line
258
+ # after body content stays body. This check only refuses, so it
259
+ # compares leniently (whitespace, stray CR, BOM): an editor or
260
+ # encoding round trip can turn a near-exact open into an exact one.
261
+ k = close_idx + 1
262
+ while k < len(lines) and _loose(lines[k]) == "":
263
+ k += 1
264
+ if k < len(lines) and _loose(lines[k]) in _RECOGNIZED_OPENS:
265
+ raise MarkerError("a second kd-meta block directly follows the top marker; keep one")
258
266
 
259
267
  # Parse key:value lines between open and close.
260
268
  marker = ArticleMarker()
@@ -291,6 +299,12 @@ def parse_content(text: str) -> ParsedFile:
291
299
  )
292
300
 
293
301
 
302
+ def _loose(line: str) -> str:
303
+ """Normalize a line for the stacked-block refusal only: drop BOMs and
304
+ surrounding whitespace. Never used to detect the top marker."""
305
+ return line.replace("\ufeff", "").strip()
306
+
307
+
294
308
  def _join_body(lines: list[str], trailing_newline: bool) -> str:
295
309
  """Join body lines with ``\\n``, restoring the final terminator if any."""
296
310
  if not lines:
@@ -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
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
11
11
  from typing import TYPE_CHECKING, Any
12
12
 
13
13
  from kd.cli.core.option_registry import OptionDeclaration, ResolvedOptions
14
+ from kd.exceptions import ValidationError
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from kd.config import ResolvedConfig
@@ -20,6 +21,18 @@ if TYPE_CHECKING:
20
21
  CommandResult = list[dict[str, Any]] | dict[str, Any] | str | None
21
22
 
22
23
 
24
+ def reject_extra_args(args: list[str], allowed: int, usage: str) -> None:
25
+ """Refuse positionals beyond the first *allowed* ones.
26
+
27
+ For commands whose positional count is fixed and whose effect is
28
+ outward or irreversible: a word left over by the parser (for example
29
+ ``off`` after a boolean option) must not be dropped silently. The
30
+ error names the first extra word and the command's usage line.
31
+ """
32
+ if len(args) > allowed:
33
+ raise ValidationError(f"Unexpected argument '{args[allowed]}'. Usage: {usage}")
34
+
35
+
23
36
  class Command(ABC):
24
37
  """Base class for all CLI commands."""
25
38
 
@@ -31,6 +44,7 @@ class Command(ABC):
31
44
  requires_client: bool = False,
32
45
  requires_config: bool = False,
33
46
  usage: str = "",
47
+ joins_text: bool = False,
34
48
  long_description: str = "",
35
49
  examples: list[str] | None = None,
36
50
  see_also: list[str] | None = None,
@@ -40,10 +54,17 @@ class Command(ABC):
40
54
  self.requires_client = requires_client
41
55
  self.requires_config = requires_config
42
56
  self.usage = usage
57
+ # The command joins its arguments into one text (a summary, comment,
58
+ # note, name or query). The parser then refuses a bare switch that is
59
+ # directly followed by a word when an argument comes before it.
60
+ self.joins_text = joins_text
43
61
  self.long_description = long_description
44
62
  self.examples: list[str] = examples or []
45
63
  self.see_also: list[str] = see_also or []
46
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
47
68
  self._options: list[OptionDeclaration] = []
48
69
  self._resolved_config: ResolvedConfig | None = None
49
70
  self._register_options()