kd-cli 2.2.2__tar.gz → 2.4.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 (81) hide show
  1. {kd_cli-2.2.2/kd_cli.egg-info → kd_cli-2.4.0}/PKG-INFO +1 -1
  2. kd_cli-2.4.0/kd/cli/core/confirm.py +43 -0
  3. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/core/option_registry.py +1 -0
  4. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/main.py +2 -0
  5. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/article/__init__.py +12 -1
  6. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/primer/__init__.py +49 -0
  7. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/project/__init__.py +25 -1
  8. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/tag/__init__.py +25 -1
  9. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/user/__init__.py +34 -4
  10. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/client.py +88 -1
  11. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/primer.py +47 -0
  12. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/resources/CHANGELOG.md +14 -0
  13. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/resources/operating-guide.md +14 -1
  14. kd_cli-2.4.0/kd/resources/primers/__init__.py +0 -0
  15. kd_cli-2.4.0/kd/resources/primers/hitl.yaml +41 -0
  16. {kd_cli-2.2.2 → kd_cli-2.4.0/kd_cli.egg-info}/PKG-INFO +1 -1
  17. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd_cli.egg-info/SOURCES.txt +3 -0
  18. {kd_cli-2.2.2 → kd_cli-2.4.0}/pyproject.toml +2 -2
  19. {kd_cli-2.2.2 → kd_cli-2.4.0}/LICENSE +0 -0
  20. {kd_cli-2.2.2 → kd_cli-2.4.0}/MANIFEST.in +0 -0
  21. {kd_cli-2.2.2 → kd_cli-2.4.0}/README.md +0 -0
  22. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/__init__.py +0 -0
  23. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/api/__init__.py +0 -0
  24. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/api/articles.py +0 -0
  25. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/api/http.py +0 -0
  26. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/api/hub.py +0 -0
  27. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/api/saved_queries.py +0 -0
  28. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/api/tags.py +0 -0
  29. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/api/youtrack.py +0 -0
  30. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/article_marker.py +0 -0
  31. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/auth.py +0 -0
  32. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/__init__.py +0 -0
  33. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/core/__init__.py +0 -0
  34. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/core/command_base.py +0 -0
  35. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/core/command_parser.py +0 -0
  36. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/core/namespaces.py +0 -0
  37. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/input.py +0 -0
  38. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/__init__.py +0 -0
  39. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  40. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/init.py +0 -0
  41. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/issue/__init__.py +0 -0
  42. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/query/__init__.py +0 -0
  43. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/release_notes.py +0 -0
  44. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/search/__init__.py +0 -0
  45. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/system/__init__.py +0 -0
  46. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/namespaces/time/__init__.py +0 -0
  47. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/output.py +0 -0
  48. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/cli/tree.py +0 -0
  49. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/config.py +0 -0
  50. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/config_read.py +0 -0
  51. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/config_schema.py +0 -0
  52. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/connection.py +0 -0
  53. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/debug.py +0 -0
  54. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/exceptions.py +0 -0
  55. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/history.py +0 -0
  56. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/__init__.py +0 -0
  57. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/article.py +0 -0
  58. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/assist.py +0 -0
  59. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/attachment.py +0 -0
  60. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/comment.py +0 -0
  61. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/common.py +0 -0
  62. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/issue.py +0 -0
  63. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/project.py +0 -0
  64. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/saved_query.py +0 -0
  65. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/search.py +0 -0
  66. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/tag.py +0 -0
  67. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/tag_entities.py +0 -0
  68. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/user.py +0 -0
  69. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/models/work_item.py +0 -0
  70. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/operating_guide.py +0 -0
  71. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/query_parser.py +0 -0
  72. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/release_notes.py +0 -0
  73. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/resources/__init__.py +0 -0
  74. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/resources/command-synonyms.yaml +0 -0
  75. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/resources/config-template.yaml +0 -0
  76. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd/resources/error-messages.yaml +0 -0
  77. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  78. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd_cli.egg-info/entry_points.txt +0 -0
  79. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd_cli.egg-info/requires.txt +0 -0
  80. {kd_cli-2.2.2 → kd_cli-2.4.0}/kd_cli.egg-info/top_level.txt +0 -0
  81. {kd_cli-2.2.2 → kd_cli-2.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.2.2
3
+ Version: 2.4.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -0,0 +1,43 @@
1
+ """Confirmation guard for irreversible destructive commands.
2
+
3
+ Some destructive commands bypass YouTrack's Deleted Items safety net —
4
+ project, tag, and user deletes in particular. This helper enforces an
5
+ opt-in ``--confirm`` flag: without it the command aborts with an
6
+ actionable multi-line error; with it the delete proceeds.
7
+
8
+ The error follows the ``_missing_tag_resolution`` pattern from
9
+ ``kd.client``: a human-readable explanation plus a ``suggestion`` line
10
+ that spells out the literal retry command so an LLM reading the
11
+ stderr can proceed without extra reasoning.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shlex
17
+
18
+ from kd.exceptions import ValidationError
19
+
20
+
21
+ def require_confirm(
22
+ *,
23
+ confirmed: bool,
24
+ command: str,
25
+ entity_id: str,
26
+ caveat: str,
27
+ ) -> None:
28
+ """Abort unless ``--confirm`` was passed.
29
+
30
+ Args:
31
+ confirmed: Whether the caller passed ``--confirm``.
32
+ command: Full command path, e.g. ``/project delete``.
33
+ entity_id: The target identifier as given on the command line.
34
+ caveat: One or two sentences explaining why this particular
35
+ delete is unsafe — the per-entity failure mode (issues
36
+ vanishing, tag membership gone, identity refs detaching).
37
+ """
38
+ if confirmed:
39
+ return
40
+ raise ValidationError(
41
+ f"kd {command} {entity_id} is irreversible. {caveat}",
42
+ suggestion=(f"Retry with: kd {command} {shlex.quote(entity_id)} --confirm"),
43
+ )
@@ -38,6 +38,7 @@ class OptionDeclaration:
38
38
  help_text: str = ""
39
39
  is_list: bool = False
40
40
  aliases: list[str] = field(default_factory=list)
41
+ hidden_from_reference: bool = False
41
42
 
42
43
 
43
44
  @dataclass
@@ -547,6 +547,8 @@ def _print_namespace_reference(ns: Namespace, depth: int = 0) -> None:
547
547
 
548
548
  # Options
549
549
  for opt in cmd.get_options():
550
+ if opt.hidden_from_reference:
551
+ continue
550
552
  flag = f"--{opt.name}"
551
553
  if opt.short_name:
552
554
  flag += f"/-{opt.short_name}"
@@ -181,7 +181,7 @@ class ArticlePushCommand(Command):
181
181
  requires_client=True,
182
182
  usage=(
183
183
  "kd /article push FILE --project P --title T "
184
- "[--slug S] [--parent ID] [--tag T] [--force] [--dry-run]"
184
+ "[--slug S] [--parent ID] [--tag T] [--create-tags] [--force] [--dry-run]"
185
185
  ),
186
186
  long_description=(
187
187
  "Push a local markdown file to YouTrack as a knowledge base "
@@ -201,6 +201,7 @@ class ArticlePushCommand(Command):
201
201
  'kd /article push guide.md --project FB --title "User Guide" --parent FB-A-1',
202
202
  'kd /article push guide.md --project FB --title "Guide" --tag "type:guide"',
203
203
  'kd /article push guide.md --project FB --title "Guide" --tag "type:guide" --tag "platform:rdk-e"',
204
+ 'kd /article push guide.md --project FB --title "Guide" --tag "type:guide" --create-tags',
204
205
  'kd /article push guide.md --project FB --title "Guide" --dry-run',
205
206
  'kd /article push guide.md --project FB --title "Guide" --force',
206
207
  ],
@@ -264,6 +265,15 @@ class ArticlePushCommand(Command):
264
265
  help_text="Resolve and validate, but do not write anywhere",
265
266
  )
266
267
  )
268
+ self.register_option(
269
+ OptionDeclaration(
270
+ name="create-tags",
271
+ layer=OptionLayer.COMMAND,
272
+ type=bool,
273
+ default=False,
274
+ help_text="Create missing tags before pushing",
275
+ )
276
+ )
267
277
 
268
278
  def execute(
269
279
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -289,6 +299,7 @@ class ArticlePushCommand(Command):
289
299
  force=bool(options.command.get("force")),
290
300
  dry_run=bool(options.command.get("dry-run")),
291
301
  tags=tags or None,
302
+ create_tags=bool(options.command.get("create-tags")),
292
303
  )
293
304
  return {
294
305
  "ok": True,
@@ -26,6 +26,7 @@ from kd.primer import (
26
26
  clear_articles,
27
27
  clear_notes,
28
28
  clear_tags,
29
+ init_primer,
29
30
  load_primer,
30
31
  remove_article,
31
32
  remove_note,
@@ -107,6 +108,53 @@ class PrimerShowCommand(Command):
107
108
  }
108
109
 
109
110
 
111
+ # ---------------------------------------------------------------------------
112
+ # /primer init
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ class PrimerInitCommand(Command):
117
+ """Scaffold ``.kd/primer.yaml`` from a packaged template."""
118
+
119
+ def __init__(self) -> None:
120
+ super().__init__(
121
+ "init",
122
+ "Scaffold .kd/primer.yaml from a template",
123
+ usage="kd /primer init [TEMPLATE]",
124
+ long_description=(
125
+ "Creates .kd/primer.yaml from a packaged template. Default "
126
+ "template is `hitl` — a human-in-the-loop session workflow "
127
+ "scaffold with placeholder prose in the scope-guard, session-"
128
+ "workflow, and canonical-reference slots. The template is "
129
+ "copied through unchanged so its banner and example-shape "
130
+ "comments survive. Refuses if a primer already exists; "
131
+ "there is no --force (deletion is explicit operator work)."
132
+ ),
133
+ examples=[
134
+ "kd /primer init",
135
+ "kd /primer init hitl",
136
+ ],
137
+ see_also=["/primer show", "/primer/note list"],
138
+ )
139
+
140
+ def _register_options(self) -> None:
141
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
142
+
143
+ def execute(
144
+ self, args: list[str], options: ResolvedOptions, client: Any = None
145
+ ) -> CommandResult:
146
+ _assert_workspace_project(self, options)
147
+ template = args[0] if args else "hitl"
148
+ created = init_primer(template)
149
+ return {
150
+ "ok": True,
151
+ "action": "init",
152
+ "template": template,
153
+ "created": str(created),
154
+ "next": "kd /primer show -o yaml",
155
+ }
156
+
157
+
110
158
  # ---------------------------------------------------------------------------
111
159
  # /primer/note
112
160
  # ---------------------------------------------------------------------------
@@ -534,6 +582,7 @@ def create_primer_namespace() -> Namespace:
534
582
  path="primer",
535
583
  )
536
584
  ns.register_command("show", PrimerShowCommand())
585
+ ns.register_command("init", PrimerInitCommand())
537
586
 
538
587
  # /primer/note
539
588
  note_ns = Namespace("note", "Primer notes and conventions", path="primer/note")
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  from typing import Any
10
10
 
11
11
  from kd.cli.core.command_base import Command, CommandResult
12
+ from kd.cli.core.confirm import require_confirm
12
13
  from kd.cli.core.namespaces import Namespace
13
14
  from kd.cli.core.option_registry import (
14
15
  OptionDeclaration,
@@ -153,9 +154,22 @@ class ProjectDeleteCommand(Command):
153
154
  "Delete a project",
154
155
  requires_client=True,
155
156
  usage="kd /project delete KEY",
156
- examples=["kd /project delete TST"],
157
+ examples=["kd /project delete TST --confirm"],
157
158
  see_also=["/project list"],
158
159
  )
160
+ self.register_option(
161
+ OptionDeclaration(
162
+ name="confirm",
163
+ layer=OptionLayer.COMMAND,
164
+ type=bool,
165
+ default=False,
166
+ help_text=(
167
+ "Required — project delete is not recoverable from "
168
+ "Deleted Items (issues and articles are permanently removed)"
169
+ ),
170
+ hidden_from_reference=True,
171
+ )
172
+ )
159
173
 
160
174
  def execute(
161
175
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -163,6 +177,16 @@ class ProjectDeleteCommand(Command):
163
177
  if not args:
164
178
  raise ValidationError("Usage: kd /project delete KEY")
165
179
  key = args[0]
180
+ require_confirm(
181
+ confirmed=bool(options.command.get("confirm")),
182
+ command="/project delete",
183
+ entity_id=key,
184
+ caveat=(
185
+ "All issues and articles in the project are permanently "
186
+ "deleted (they are not moved to Deleted Items) — recovery "
187
+ "requires a database backup restore."
188
+ ),
189
+ )
166
190
  client.projects.delete(key)
167
191
  return {"ok": True, "action": "delete", "project": key}
168
192
 
@@ -10,6 +10,7 @@ import sys
10
10
  from typing import Any, Literal
11
11
 
12
12
  from kd.cli.core.command_base import Command, CommandResult
13
+ from kd.cli.core.confirm import require_confirm
13
14
  from kd.cli.core.namespaces import Namespace
14
15
  from kd.cli.core.option_registry import (
15
16
  OptionDeclaration,
@@ -203,9 +204,22 @@ class TagDeleteCommand(Command):
203
204
  "Delete a tag",
204
205
  requires_client=True,
205
206
  usage="kd /tag delete TAG_NAME",
206
- examples=["kd /tag delete sprint-47"],
207
+ examples=["kd /tag delete sprint-47 --confirm"],
207
208
  see_also=["/tag list", "/tag create"],
208
209
  )
210
+ self.register_option(
211
+ OptionDeclaration(
212
+ name="confirm",
213
+ layer=OptionLayer.COMMAND,
214
+ type=bool,
215
+ default=False,
216
+ help_text=(
217
+ "Required — tag delete is not recoverable from Deleted "
218
+ "Items (tag membership is lost across all entities)"
219
+ ),
220
+ hidden_from_reference=True,
221
+ )
222
+ )
209
223
 
210
224
  def execute(
211
225
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -213,6 +227,16 @@ class TagDeleteCommand(Command):
213
227
  if not args:
214
228
  raise ValidationError("Usage: kd /tag delete TAG_NAME")
215
229
  name = args[0]
230
+ require_confirm(
231
+ confirmed=bool(options.command.get("confirm")),
232
+ command="/tag delete",
233
+ entity_id=name,
234
+ caveat=(
235
+ "Tag membership is removed from every tagged issue and "
236
+ "article; recreating the tag does not restore assignments. "
237
+ "Full recovery requires a database backup restore."
238
+ ),
239
+ )
216
240
  client.tags.delete(name)
217
241
  return {"ok": True, "action": "delete", "tag": name}
218
242
 
@@ -9,8 +9,13 @@ from __future__ import annotations
9
9
  from typing import Any
10
10
 
11
11
  from kd.cli.core.command_base import Command, CommandResult
12
+ from kd.cli.core.confirm import require_confirm
12
13
  from kd.cli.core.namespaces import Namespace
13
- from kd.cli.core.option_registry import ResolvedOptions
14
+ from kd.cli.core.option_registry import (
15
+ OptionDeclaration,
16
+ OptionLayer,
17
+ ResolvedOptions,
18
+ )
14
19
  from kd.exceptions import ValidationError
15
20
 
16
21
 
@@ -77,17 +82,42 @@ class UserDeleteCommand(Command):
77
82
  "Delete a user",
78
83
  requires_client=True,
79
84
  usage="kd /user delete HUB_USER_ID",
80
- examples=["kd /user delete abc-123"],
85
+ examples=["kd /user delete abc-123 --confirm"],
81
86
  see_also=["/user list"],
82
87
  )
88
+ self.register_option(
89
+ OptionDeclaration(
90
+ name="confirm",
91
+ layer=OptionLayer.COMMAND,
92
+ type=bool,
93
+ default=False,
94
+ help_text=(
95
+ "Required — user delete is not recoverable from Deleted "
96
+ "Items (consider banning via the Hub UI instead)"
97
+ ),
98
+ hidden_from_reference=True,
99
+ )
100
+ )
83
101
 
84
102
  def execute(
85
103
  self, args: list[str], options: ResolvedOptions, client: Any = None
86
104
  ) -> CommandResult:
87
105
  if not args:
88
106
  raise ValidationError("Usage: kd /user delete HUB_USER_ID")
89
- client.users.delete(args[0])
90
- return {"ok": True, "action": "delete", "user": args[0]}
107
+ user_id = args[0]
108
+ require_confirm(
109
+ confirmed=bool(options.command.get("confirm")),
110
+ command="/user delete",
111
+ entity_id=user_id,
112
+ caveat=(
113
+ "Identity references in issue history become detached, and "
114
+ "the account is removed from the Hub. Consider banning the "
115
+ "user in the Hub UI instead; full recovery requires a "
116
+ "database backup restore."
117
+ ),
118
+ )
119
+ client.users.delete(user_id)
120
+ return {"ok": True, "action": "delete", "user": user_id}
91
121
 
92
122
 
93
123
  def create_user_namespace() -> Namespace:
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import builtins
22
22
  import difflib
23
+ import shlex
23
24
  from collections import OrderedDict
24
25
  from dataclasses import dataclass, field as dataclass_field
25
26
  from datetime import datetime, timezone
@@ -43,7 +44,7 @@ from kd.article_marker import (
43
44
  from kd.config import ResolvedConfig
44
45
  from kd.connection import ConnectionContext
45
46
  from kd.debug import DebugLog
46
- from kd.exceptions import APIError, ConfigError, NotFoundError, ValidationError, KdError
47
+ from kd.exceptions import APIError, ConfigError, KdError, NotFoundError, ValidationError
47
48
  from kd.query_parser import youtrack_query_literal
48
49
  from kd.models import (
49
50
  Article,
@@ -1013,6 +1014,44 @@ class TimeOperations:
1013
1014
  raise ValidationError(f"Unknown work item type '{name}'. Available: {available}")
1014
1015
 
1015
1016
 
1017
+ def _tag_prefix_for_list(tag: str) -> str:
1018
+ """Return the smallest useful prefix for missing-tag discovery."""
1019
+ if ":" in tag:
1020
+ return tag.split(":", 1)[0] + ":"
1021
+ return tag
1022
+
1023
+
1024
+ def _extract_tag_suggestions(error: NotFoundError) -> builtins.list[str]:
1025
+ """Extract close-match names from the existing tag resolver message."""
1026
+ message = str(error)
1027
+ marker = "Did you mean: "
1028
+ if marker not in message:
1029
+ return []
1030
+ raw = message.split(marker, 1)[1].rstrip(".?")
1031
+ return [part.strip() for part in raw.split(",") if part.strip()]
1032
+
1033
+
1034
+ def _missing_tag_resolution(tag: str, error: NotFoundError) -> str:
1035
+ """Format an actionable missing-tag instruction for article push."""
1036
+ lines = [f"Tag '{tag}' not found."]
1037
+ suggestions = _extract_tag_suggestions(error)
1038
+ if suggestions:
1039
+ lines.append(f" Near matches: {', '.join(suggestions)}")
1040
+ prefix = _tag_prefix_for_list(tag)
1041
+ lines.extend(
1042
+ [
1043
+ "",
1044
+ f"To use an existing tag: kd /tag list --prefix {shlex.quote(prefix)}",
1045
+ f"To create a new tag: kd /tag create {shlex.quote(tag)}",
1046
+ f"To retry push without this tag: omit --tag {shlex.quote(tag)}",
1047
+ "",
1048
+ "If you are still curating the tag set, push without --tag and apply tags",
1049
+ f"separately with kd /tag add ARTICLE-ID {shlex.quote(tag)}.",
1050
+ ]
1051
+ )
1052
+ return "\n".join(lines)
1053
+
1054
+
1016
1055
  class ArticleOperations:
1017
1056
  """High-level knowledge base article operations."""
1018
1057
 
@@ -1137,6 +1176,41 @@ class ArticleOperations:
1137
1176
  failed.append(f"{tag} ({exc})")
1138
1177
  return applied, failed
1139
1178
 
1179
+ def _preflight_tags(
1180
+ self,
1181
+ tags: builtins.list[str],
1182
+ *,
1183
+ create_missing: bool = False,
1184
+ dry_run: bool = False,
1185
+ ) -> builtins.list[str]:
1186
+ """Resolve tags before mutating an article.
1187
+
1188
+ Returns actionable failure blocks. When ``create_missing`` is true,
1189
+ missing tags are created before the article write; ``dry_run`` never
1190
+ writes tags and treats missing tags as would-create.
1191
+ """
1192
+ if self._tag_ops is None:
1193
+ raise KdError("Tag operations not available")
1194
+ failed: builtins.list[str] = []
1195
+ for tag in tags:
1196
+ try:
1197
+ self._tag_ops.get(tag)
1198
+ except NotFoundError as exc:
1199
+ if create_missing:
1200
+ if dry_run:
1201
+ continue
1202
+ try:
1203
+ self._tag_ops.create(tag)
1204
+ except KdError as create_exc:
1205
+ failed.append(
1206
+ f"Tag '{tag}' not found and could not be created. {create_exc}"
1207
+ )
1208
+ continue
1209
+ failed.append(_missing_tag_resolution(tag, exc))
1210
+ except KdError as exc:
1211
+ failed.append(f"Tag '{tag}' could not be resolved. {exc}")
1212
+ return failed
1213
+
1140
1214
  def update(
1141
1215
  self,
1142
1216
  article_id: str,
@@ -1160,6 +1234,7 @@ class ArticleOperations:
1160
1234
  force: bool = False,
1161
1235
  dry_run: bool = False,
1162
1236
  tags: builtins.list[str] | None = None,
1237
+ create_tags: bool = False,
1163
1238
  ) -> PushResult:
1164
1239
  """Idempotently upsert a local markdown file as a YouTrack article.
1165
1240
 
@@ -1278,6 +1353,18 @@ class ArticleOperations:
1278
1353
  f"move {existing_remote.id_readable} --parent {parent}."
1279
1354
  )
1280
1355
 
1356
+ if tags:
1357
+ preflight_failed = self._preflight_tags(
1358
+ tags,
1359
+ create_missing=create_tags,
1360
+ dry_run=dry_run,
1361
+ )
1362
+ if preflight_failed:
1363
+ raise KdError(
1364
+ f"Article {'update' if is_update else 'create'} aborted because "
1365
+ "tagging failed.\n\n" + "\n\n".join(preflight_failed)
1366
+ )
1367
+
1281
1368
  # Dry-run bails out here with all validation done but no writes.
1282
1369
  if dry_run:
1283
1370
  existing_sec = 0
@@ -10,6 +10,7 @@ knowledge). The file is created lazily on first write operation.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import importlib.resources
13
14
  from pathlib import Path
14
15
  from typing import Any
15
16
 
@@ -283,3 +284,49 @@ def clear_tags(path: Path | None = None) -> int:
283
284
  data.tags.clear()
284
285
  save_primer(data, primer_file)
285
286
  return count
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Scaffolding
291
+ # ---------------------------------------------------------------------------
292
+
293
+ _PRIMER_TEMPLATE_PACKAGE = "kd.resources.primers"
294
+
295
+
296
+ def list_primer_templates() -> list[str]:
297
+ """Return sorted names of shipped primer templates (without ``.yaml``)."""
298
+ root = importlib.resources.files(_PRIMER_TEMPLATE_PACKAGE)
299
+ return sorted(p.name[: -len(".yaml")] for p in root.iterdir() if p.name.endswith(".yaml"))
300
+
301
+
302
+ def init_primer(template: str = "hitl", path: Path | None = None) -> Path:
303
+ """Scaffold ``.kd/primer.yaml`` from a packaged template.
304
+
305
+ Copies the template file through unchanged so its banner and inline
306
+ comments survive; no YAML load/dump round-trip. Refuses if a primer
307
+ already exists — deletion is explicit operator work and there is no
308
+ ``--force``.
309
+ """
310
+ from kd.history import atomic_write_text
311
+
312
+ available = list_primer_templates()
313
+ if template not in available:
314
+ raise ValidationError(
315
+ f"Unknown primer template '{template}'. Available: {', '.join(available)}"
316
+ )
317
+
318
+ primer_file = path or _primer_path()
319
+ if primer_file.exists():
320
+ raise ValidationError(
321
+ f"Primer already exists at {primer_file}. "
322
+ f"Run `kd /primer show` to inspect it, or delete the file to start over."
323
+ )
324
+
325
+ content = (
326
+ importlib.resources.files(_PRIMER_TEMPLATE_PACKAGE)
327
+ .joinpath(f"{template}.yaml")
328
+ .read_text(encoding="utf-8")
329
+ )
330
+ primer_file.parent.mkdir(parents=True, exist_ok=True)
331
+ atomic_write_text(primer_file, content)
332
+ return primer_file
@@ -4,6 +4,20 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.4.0] — 2026-04-19
8
+
9
+ ### Added
10
+ - `kd /primer init [TEMPLATE]` scaffolds `.kd/primer.yaml` from a packaged template so fresh workspaces don't start from a blank page. The default `hitl` template ships placeholder prose in three slots (scope guard, session workflow, canonical reference); the banner and example-shape comments are preserved verbatim. Refuses if a primer already exists; unknown template names reject with the available-templates list (KDCLI-97).
11
+ - `--confirm` flag on `/project delete`, `/tag delete`, and `/user delete` — these three deletes bypass YouTrack's Deleted Items and require a backup restore to undo, so kd now rejects them without `--confirm` and prints the literal retry command in the error. The flag is hidden from `kd --reference` but visible in per-command `--help` (KDCLI-96).
12
+
13
+ ## [2.3.0] — 2026-04-18
14
+
15
+ ### Added
16
+ - `/article push --create-tags` explicitly creates missing tags before pushing and attaching them, for scripted flows that own their tag namespace (KDCLI-85).
17
+
18
+ ### Fixed
19
+ - `/article push --tag` missing-tag failures now print actionable recovery commands instead of a bare not-found / close-match message (KDCLI-85).
20
+
7
21
  ## [2.2.2] — 2026-04-18
8
22
 
9
23
  ### Fixed
@@ -80,6 +80,15 @@ kd /issue update KDCLI-42 --state Fixed -o yaml
80
80
  ```
81
81
 
82
82
  For article push, use `--dry-run` before a risky write and inspect conflicts with `/article pull`.
83
+ `/article push --tag` rejects missing tags by default and prints the concrete recovery commands. Use `--create-tags` only for scripted flows that already own the tag namespace.
84
+
85
+ Three delete commands bypass YouTrack's Deleted Items safety net and require an explicit `--confirm` flag:
86
+
87
+ - `/project delete` — all issues and articles in the project are permanently removed, not moved to Deleted Items.
88
+ - `/tag delete` — tag membership is lost across every tagged issue and article; recreating the tag does not restore assignments.
89
+ - `/user delete` — identity references in issue history become detached; consider banning in the Hub UI instead.
90
+
91
+ Without `--confirm` these commands exit non-zero with an error that names the failure mode and prints the literal retry command. Recovery for any of the three requires a database backup restore. `/issue delete`, `/article delete`, and attachment deletes do not require `--confirm` — they go to Deleted Items and are admin-restorable for roughly 14 days.
83
92
 
84
93
  ## YouTrack Gotchas
85
94
 
@@ -105,6 +114,8 @@ Tag existence, tag visibility, and tag sharing rights are different concerns. A
105
114
 
106
115
  ## Primer Practices
107
116
 
117
+ For a fresh workspace, scaffold the primer with `kd /primer init`. The default `hitl` template drops placeholder prose into three slots — scope guard, session workflow, canonical reference — that the operator fills in an editor. The command refuses if a primer already exists and has no `--force`; deletion is explicit operator work.
118
+
108
119
  Primer notes should capture durable project rules, not temporary status. Good notes explain naming conventions, required workflows, dangerous assumptions, and pointers to canonical references.
109
120
 
110
121
  Do not use the primer as a session log. Avoid recording current focus, completed session lists, release-cadence details copied from a plan, or other progress facts that can become false by the next agent session. If the primer needs to point at active work, stop at the current master plan or equivalent planning document; the agent should open that document and derive the next step from there.
@@ -143,6 +154,8 @@ Use `/article show` for metadata plus rendered content. Use `/article pull` when
143
154
 
144
155
  Do not use push for one-off web edits or broad tree migration. It is single-file by design. If the remote changed, pull, diff, merge by hand, then push again. Use `--force` only after inspecting the remote state.
145
156
 
157
+ When pushing with `--tag`, kd validates all tags before the article write. Missing tags abort with commands to list the namespace, create the tag, retry without it, or apply the tag separately after push. `--create-tags` is the explicit opt-in for automation that should create missing tags first.
158
+
146
159
  ## Tags And Discovery
147
160
 
148
161
  Tags are the main YouTrack-native cross-entity discovery primitive. A namespaced taxonomy such as `kind:*`, `component:*`, and `outcome:*` gives both humans and LLMs a stable way to move across issues and articles.
@@ -208,4 +221,4 @@ Use these sources together:
208
221
  - `docs/design/architecture.md` and ADRs for architectural constraints.
209
222
 
210
223
  ---
211
- Updated: 2026-04-18
224
+ Updated: 2026-04-19
File without changes
@@ -0,0 +1,41 @@
1
+ # DO NOT EDIT THIS FILE DIRECTLY. Use `kd /primer --help` to manage it.
2
+ #
3
+ # This is the `hitl` template — human-in-the-loop session workflow. Edit the
4
+ # placeholders below to fit this workspace. Three slots:
5
+ # 1. Scope guard — what this workspace is / is not; how to reject drift.
6
+ # 2. Session workflow — the repeatable loop and local conventions.
7
+ # 3. Canonical reference — pinned article(s) with durable background.
8
+
9
+ notes:
10
+ - '<WORKSPACE> is <one-sentence identity: what kind of tool, codebase, or knowledge
11
+ base this workspace represents>. Primary loop: <describe the repeatable action —
12
+ for example, pull context → reason → push focused updates back>. <State what this
13
+ workspace is NOT — adjacent shapes it should not drift toward, such as migration
14
+ platform, data warehouse, silent local mirror>. Before adding features that look
15
+ like the adjacent shape, re-read this guard. Test question: does this keep the
16
+ workspace <identity>-shaped, or does it drag it toward <adjacent shape>? Reject
17
+ drift toward the latter.'
18
+ - 'Active planning entry point: <path to master plan, roadmap doc, or tracking
19
+ project>. The primer must not record session-level progress, current focus,
20
+ completed-session lists, or release-cadence details copied from the plan; those
21
+ stale quickly. A fresh agent reads this pointer, checks the active plan or
22
+ assigned ticket for scope, then follows the default session workflow recorded
23
+ in this primer.'
24
+ - 'Default session workflow: 1) Load primer with `kd /primer show -o yaml`. 2) Read
25
+ the assigned ticket or active planning source; if tracked work lacks a ticket,
26
+ create one with `kd /issue create ... --project <PROJECT> --type <Feature|Bug|Task>`
27
+ and use the returned ID. 3) Enter plan mode for a full tracked session and write
28
+ the session doc from the local template, naming the file with the ticket ID when
29
+ present. 4) Execute code, tests, and docs together; run the project''s test, lint,
30
+ and type checks before commit. 5) Commit with `<TICKET-PREFIX>-NN: <subject>` for
31
+ tracked work. 6) Install the built artifact, run applicable HITL against a real
32
+ target, append the session conclusion, comment on the ticket, transition it to
33
+ <done-state>, and update the relevant plan and docs. If no ticket or user-visible
34
+ HITL surface exists, record that explicitly in the session doc.'
35
+
36
+ tags: []
37
+
38
+ # Pin canonical reference articles after `kd /primer init`. Example shape:
39
+ # - id: <ARTICLE-ID>
40
+ # summary: <one-line hook — what this pinned article gives the operator>
41
+ articles: []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.2.2
3
+ Version: 2.4.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -32,6 +32,7 @@ kd/cli/tree.py
32
32
  kd/cli/core/__init__.py
33
33
  kd/cli/core/command_base.py
34
34
  kd/cli/core/command_parser.py
35
+ kd/cli/core/confirm.py
35
36
  kd/cli/core/namespaces.py
36
37
  kd/cli/core/option_registry.py
37
38
  kd/cli/namespaces/__init__.py
@@ -68,6 +69,8 @@ kd/resources/command-synonyms.yaml
68
69
  kd/resources/config-template.yaml
69
70
  kd/resources/error-messages.yaml
70
71
  kd/resources/operating-guide.md
72
+ kd/resources/primers/__init__.py
73
+ kd/resources/primers/hitl.yaml
71
74
  kd_cli.egg-info/PKG-INFO
72
75
  kd_cli.egg-info/SOURCES.txt
73
76
  kd_cli.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.2.2"
7
+ version = "2.4.0"
8
8
  description = "Knowledge Dispatcher for issue trackers and knowledge bases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -57,7 +57,7 @@ kd = "kd.cli.main:main"
57
57
  include = ["kd*"]
58
58
 
59
59
  [tool.setuptools.package-data]
60
- kd = ["resources/*.yaml", "resources/*.md"]
60
+ kd = ["resources/*.yaml", "resources/*.md", "resources/primers/*.yaml"]
61
61
 
62
62
  [tool.ruff]
63
63
  exclude = ["docs"]
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