kd-cli 2.1.6__tar.gz → 2.2.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 (77) hide show
  1. {kd_cli-2.1.6/kd_cli.egg-info → kd_cli-2.2.0}/PKG-INFO +1 -1
  2. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/tags.py +12 -2
  3. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/youtrack.py +25 -2
  4. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/issue/__init__.py +30 -21
  5. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/primer/__init__.py +200 -10
  6. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/system/__init__.py +22 -326
  7. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/tag/__init__.py +233 -11
  8. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/output.py +159 -7
  9. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/client.py +274 -15
  10. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/config.py +172 -0
  11. kd_cli-2.2.0/kd/config_read.py +194 -0
  12. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/exceptions.py +11 -0
  13. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/__init__.py +4 -0
  14. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/issue.py +3 -0
  15. kd_cli-2.2.0/kd/models/tag_entities.py +48 -0
  16. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/primer.py +59 -5
  17. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/query_parser.py +68 -7
  18. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/CHANGELOG.md +27 -0
  19. kd_cli-2.2.0/kd/resources/operating-guide.md +209 -0
  20. {kd_cli-2.1.6 → kd_cli-2.2.0/kd_cli.egg-info}/PKG-INFO +1 -1
  21. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/SOURCES.txt +3 -0
  22. {kd_cli-2.1.6 → kd_cli-2.2.0}/pyproject.toml +1 -1
  23. {kd_cli-2.1.6 → kd_cli-2.2.0}/LICENSE +0 -0
  24. {kd_cli-2.1.6 → kd_cli-2.2.0}/MANIFEST.in +0 -0
  25. {kd_cli-2.1.6 → kd_cli-2.2.0}/README.md +0 -0
  26. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/__init__.py +0 -0
  27. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/__init__.py +0 -0
  28. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/articles.py +0 -0
  29. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/http.py +0 -0
  30. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/hub.py +0 -0
  31. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/saved_queries.py +0 -0
  32. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/article_marker.py +0 -0
  33. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/auth.py +0 -0
  34. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/__init__.py +0 -0
  35. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/__init__.py +0 -0
  36. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/command_base.py +0 -0
  37. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/command_parser.py +0 -0
  38. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/namespaces.py +0 -0
  39. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/option_registry.py +0 -0
  40. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/input.py +0 -0
  41. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/main.py +0 -0
  42. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/__init__.py +0 -0
  43. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  44. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/article/__init__.py +0 -0
  45. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/init.py +0 -0
  46. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/project/__init__.py +0 -0
  47. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/query/__init__.py +0 -0
  48. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/release_notes.py +0 -0
  49. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/search/__init__.py +0 -0
  50. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/time/__init__.py +0 -0
  51. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/user/__init__.py +0 -0
  52. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/tree.py +0 -0
  53. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/config_schema.py +0 -0
  54. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/connection.py +0 -0
  55. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/debug.py +0 -0
  56. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/history.py +0 -0
  57. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/article.py +0 -0
  58. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/assist.py +0 -0
  59. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/attachment.py +0 -0
  60. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/comment.py +0 -0
  61. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/common.py +0 -0
  62. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/project.py +0 -0
  63. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/search.py +0 -0
  65. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/tag.py +0 -0
  66. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/user.py +0 -0
  67. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/work_item.py +0 -0
  68. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/release_notes.py +0 -0
  69. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/__init__.py +0 -0
  70. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/command-synonyms.yaml +0 -0
  71. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/config-template.yaml +0 -0
  72. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/error-messages.yaml +0 -0
  73. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  74. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/entry_points.txt +0 -0
  75. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/requires.txt +0 -0
  76. {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/top_level.txt +0 -0
  77. {kd_cli-2.1.6 → kd_cli-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.1.6
3
+ Version: 2.2.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -36,6 +36,7 @@ _TAGGED_ISSUE_FIELDS = str(
36
36
  "id",
37
37
  "idReadable",
38
38
  "summary",
39
+ "updated",
39
40
  project=FieldSet("id", "name", "shortName"),
40
41
  )
41
42
  )
@@ -49,11 +50,20 @@ class TagsAPI:
49
50
 
50
51
  # --- Tag CRUD ---
51
52
 
52
- def list_tags(self, top: int = 50, skip: int = 0) -> list[Tag]:
53
+ def list_tags(
54
+ self,
55
+ top: int = 50,
56
+ skip: int = 0,
57
+ *,
58
+ query: str | None = None,
59
+ ) -> list[Tag]:
60
+ params: dict[str, object] = {"$top": top, "$skip": skip}
61
+ if query is not None:
62
+ params["query"] = query
53
63
  data = self._http.get(
54
64
  "/tags",
55
65
  fields=_TAG_LIST_FIELDS,
56
- params={"$top": top, "$skip": skip},
66
+ params=params,
57
67
  )
58
68
  return [Tag.model_validate(t) for t in data]
59
69
 
@@ -68,11 +68,29 @@ _ISSUE_FIELDS = str(
68
68
  "summary",
69
69
  "description",
70
70
  "created",
71
+ "updated",
71
72
  project=FieldSet("id", "name", "shortName"),
72
73
  reporter=FieldSet("id", "login", "name"),
73
74
  customFields=FieldSet("name", value=FieldSet("name")),
74
75
  )
75
76
  )
77
+ # Tag-aware list field set. Kept separate from _ISSUE_FIELDS so default
78
+ # /issue list calls don't pay the tag payload cost. Symmetric to
79
+ # _ARTICLE_TAG_SCAN_FIELDS in kd/api/articles.py.
80
+ _ISSUE_LIST_FIELDS_WITH_TAGS = str(
81
+ FieldSet(
82
+ "id",
83
+ "idReadable",
84
+ "summary",
85
+ "description",
86
+ "created",
87
+ "updated",
88
+ project=FieldSet("id", "name", "shortName"),
89
+ reporter=FieldSet("id", "login", "name"),
90
+ customFields=FieldSet("name", value=FieldSet("name")),
91
+ tags=FieldSet("id", "name"),
92
+ )
93
+ )
76
94
  _ISSUE_RICH_FIELDS = str(
77
95
  FieldSet(
78
96
  "id",
@@ -80,6 +98,7 @@ _ISSUE_RICH_FIELDS = str(
80
98
  "summary",
81
99
  "description",
82
100
  "created",
101
+ "updated",
83
102
  project=FieldSet("id", "name", "shortName"),
84
103
  reporter=FieldSet("id", "login", "name"),
85
104
  customFields=FieldSet("name", value=FieldSet("name")),
@@ -93,6 +112,7 @@ _ISSUE_RICH_FIELDS = str(
93
112
  attachments=FieldSet(
94
113
  "id", "name", "size", "mimeType", "created", author=FieldSet("id", "login", "name")
95
114
  ),
115
+ tags=FieldSet("id", "name"),
96
116
  )
97
117
  )
98
118
  _COMMENT_FIELDS = "id,text,author(id,login,name),created,updated"
@@ -188,11 +208,14 @@ class YouTrackAPI:
188
208
 
189
209
  # --- Issues ---
190
210
 
191
- def list_issues(self, query: str = "", top: int = 50, skip: int = 0) -> list[Issue]:
211
+ def list_issues(
212
+ self, query: str = "", top: int = 50, skip: int = 0, with_tags: bool = False
213
+ ) -> list[Issue]:
192
214
  params: dict[str, Any] = {"$top": top, "$skip": skip}
193
215
  if query:
194
216
  params["query"] = query
195
- data = self._http.get("/issues", fields=_ISSUE_FIELDS, params=params)
217
+ fields = _ISSUE_LIST_FIELDS_WITH_TAGS if with_tags else _ISSUE_FIELDS
218
+ data = self._http.get("/issues", fields=fields, params=params)
196
219
  return [Issue.model_validate(i) for i in data]
197
220
 
198
221
  def get_issue(self, issue_id: str, rich: bool = False) -> Issue:
@@ -30,19 +30,6 @@ from kd.models import Attachment, CustomField, Issue
30
30
  _SEMANTIC_SLOTS = ("type", "priority", "state")
31
31
 
32
32
 
33
- def _get_field_aliases(client: Any, project_key: str) -> dict[str, str]:
34
- """Extract field aliases for a project from config."""
35
- if not client or not hasattr(client, "config") or not client.config:
36
- return {}
37
- for source in (client.config.local_projects, client.config.global_projects):
38
- proj_cfg = source.get(project_key)
39
- if isinstance(proj_cfg, dict):
40
- aliases = proj_cfg.get("field_aliases")
41
- if isinstance(aliases, dict):
42
- return {str(k): str(v) for k, v in aliases.items()}
43
- return {}
44
-
45
-
46
33
  def _build_custom_fields(
47
34
  options: dict[str, Any],
48
35
  client: Any = None,
@@ -64,7 +51,7 @@ def _build_custom_fields(
64
51
 
65
52
  semantic_fields: list[CustomField] = []
66
53
  if slots and client and project_key:
67
- aliases = _get_field_aliases(client, project_key)
54
+ aliases = client.config.field_aliases_for(project_key) if client.config else {}
68
55
  semantic_fields = client.issues.resolve_custom_fields(project_key, slots, aliases) or []
69
56
  elif slots:
70
57
  # Fallback: hardcoded defaults (no client available)
@@ -122,9 +109,15 @@ def _extract_semantic_field(issue: Issue, candidates: set[str]) -> str:
122
109
  return ""
123
110
 
124
111
 
125
- def _issue_to_row(issue: Issue) -> dict[str, Any]:
126
- """Convert an Issue to a flat dict for table output."""
127
- return {
112
+ def _issue_to_row(issue: Issue, with_tags: bool = False) -> dict[str, Any]:
113
+ """Convert an Issue to a flat dict for table output.
114
+
115
+ When ``with_tags`` is True, a ``tags`` column is appended (comma-joined
116
+ tag names, parity with ``_article_to_detail``). Without the flag, the
117
+ column is omitted entirely so cheap ``/issue list`` calls don't surface
118
+ an empty column.
119
+ """
120
+ row: dict[str, Any] = {
128
121
  "id": issue.id_readable,
129
122
  "summary": issue.summary,
130
123
  "project": issue.project.name if issue.project else "",
@@ -132,6 +125,9 @@ def _issue_to_row(issue: Issue) -> dict[str, Any]:
132
125
  "priority": _extract_semantic_field(issue, _PRIORITY_NAMES),
133
126
  "state": _extract_semantic_field(issue, _STATE_NAMES),
134
127
  }
128
+ if with_tags:
129
+ row["tags"] = ", ".join(t.name for t in issue.tags)
130
+ return row
135
131
 
136
132
 
137
133
  _RICH_SECTIONS = {"description", "comments", "links", "attachments"}
@@ -151,6 +147,8 @@ def _issue_to_detail(issue: Issue, sections: set[str] | None = None) -> dict[str
151
147
  "project": issue.project.name if issue.project else "",
152
148
  "reporter": issue.reporter.name if issue.reporter else "",
153
149
  "created": issue.created,
150
+ "updated": issue.updated,
151
+ "tags": ", ".join(t.name for t in issue.tags),
154
152
  }
155
153
  for cf in issue.custom_fields:
156
154
  name = cf.name.lower().replace(" ", "_")
@@ -215,13 +213,14 @@ class IssueListCommand(Command):
215
213
  "list",
216
214
  "List issues",
217
215
  requires_client=True,
218
- usage="kd /issue list [--project P] [--query Q] [--limit N]",
216
+ usage="kd /issue list [--project P] [--query Q] [--limit N] [--with-tags]",
219
217
  examples=[
220
218
  "kd /issue list --project FB",
221
219
  'kd /issue list --query "State: Open"',
222
220
  "kd /issue list --project FB --limit 10",
221
+ "kd /issue list --project FB --with-tags",
223
222
  ],
224
- see_also=["/issue show", "/issue create"],
223
+ see_also=["/issue show", "/issue create", "/tag issues"],
225
224
  )
226
225
 
227
226
  def _register_options(self) -> None:
@@ -235,6 +234,15 @@ class IssueListCommand(Command):
235
234
  )
236
235
  )
237
236
  self.register_option(limit_option())
237
+ self.register_option(
238
+ OptionDeclaration(
239
+ name="with-tags",
240
+ layer=OptionLayer.COMMAND,
241
+ type=bool,
242
+ default=False,
243
+ help_text="Include tags in the response (opt-in; doubles payload size)",
244
+ )
245
+ )
238
246
 
239
247
  def execute(
240
248
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -248,8 +256,9 @@ class IssueListCommand(Command):
248
256
  parts.append(query)
249
257
  full_query = " ".join(parts)
250
258
  limit = options.command.get("limit", 50)
251
- issues = client.issues.list(query=full_query, top=limit)
252
- return [_issue_to_row(i) for i in issues]
259
+ with_tags = options.command.get("with-tags", False)
260
+ issues = client.issues.list(query=full_query, top=limit, with_tags=with_tags)
261
+ return [_issue_to_row(i, with_tags=with_tags) for i in issues]
253
262
 
254
263
 
255
264
  class IssueCreateCommand(Command):
@@ -17,15 +17,19 @@ from kd.cli.core.option_registry import (
17
17
  project_option,
18
18
  )
19
19
  from kd.cli.input import resolve_text_input
20
- from kd.exceptions import ValidationError
20
+ from kd.cli.output import emit_warning
21
+ from kd.exceptions import KdError, NotFoundError, ValidationError
21
22
  from kd.primer import (
22
23
  add_article,
23
24
  add_note,
25
+ add_tag,
24
26
  clear_articles,
25
27
  clear_notes,
28
+ clear_tags,
26
29
  load_primer,
27
30
  remove_article,
28
31
  remove_note,
32
+ remove_tag,
29
33
  update_note,
30
34
  )
31
35
 
@@ -33,19 +37,33 @@ from kd.primer import (
33
37
  def _assert_workspace_project(command: Command, options: ResolvedOptions) -> None:
34
38
  """Enforce the ``--project`` assertion on workspace-scoped primer commands.
35
39
 
36
- The primer lives in ``.kd/primer.yaml`` and has no project dimension.
37
- ``--project`` is accepted as an assertion of which workspace's primer the
38
- caller thinks it's editing: matches ``default_project`` → silent no-op;
39
- mismatches → raise so an agent running from the wrong workspace fails loud.
40
+ The primer lives in ``.kd/primer.yaml`` and has no project dimension:
41
+ every workspace owns exactly one primer, and any entity (tag, article,
42
+ issue) from any project the token can see can be pinned from that
43
+ workspace. ``--project`` is therefore accepted as an **assertion** of
44
+ which workspace's primer the caller thinks it's editing, not an
45
+ override that selects a different primer file. Matches
46
+ ``default_project`` → silent no-op; mismatches → raise so an agent
47
+ running from the wrong workspace fails loud.
40
48
  """
41
49
  asserted = options.command.get("project")
42
50
  if not asserted:
43
51
  return
52
+ # Read commands (/primer show, /primer/*/list) declare the flag for
53
+ # symmetry with write commands but do not declare requires_config=True
54
+ # so they work in a fresh workspace before `kd init`. When no config
55
+ # is loaded the assertion silently passes — the flag is accepted, the
56
+ # user sees no "unknown option" rejection (KDCLI-84 symptom).
57
+ if command._resolved_config is None:
58
+ return
44
59
  current = command.config.default_project
45
60
  if current and asserted != current:
46
61
  raise ValidationError(
47
- f"Primer is workspace-scoped. --project={asserted} does not match "
48
- f"this workspace's default_project={current}. To edit {asserted}'s "
62
+ f"Primer is workspace-scoped (one .kd/primer.yaml per workspace). "
63
+ f"--project={asserted} does not match this workspace's "
64
+ f"default_project={current}. Primer pinning does not restrict "
65
+ f"which project an entity can belong to — you can pin {asserted} "
66
+ f"entities from this workspace. To edit a *different* workspace's "
49
67
  f"primer, run from that workspace."
50
68
  )
51
69
 
@@ -63,22 +81,28 @@ class PrimerShowCommand(Command):
63
81
  "show",
64
82
  "Show project primer",
65
83
  usage="kd /primer show",
66
- long_description="Displays the full project primer — notes, pinned articles, "
67
- "and all other sections. Returns an empty primer if none exists yet.",
84
+ long_description="Displays the full project primer — notes, pinned tags, "
85
+ "pinned articles, and all other sections. Returns an empty primer if "
86
+ "none exists yet.",
68
87
  examples=[
69
88
  "kd /primer show",
70
89
  "kd /primer show --output json",
71
90
  ],
72
- see_also=["/primer/note list", "/primer/article list"],
91
+ see_also=["/primer/note list", "/primer/tag list", "/primer/article list"],
73
92
  )
74
93
  self.display_hint = "primer_show"
75
94
 
95
+ def _register_options(self) -> None:
96
+ self.register_option(project_option(help_text="Assert which project's primer this shows"))
97
+
76
98
  def execute(
77
99
  self, args: list[str], options: ResolvedOptions, client: Any = None
78
100
  ) -> CommandResult:
101
+ _assert_workspace_project(self, options)
79
102
  data = load_primer()
80
103
  return {
81
104
  "notes": [{"#": i + 1, "note": n} for i, n in enumerate(data.notes)],
105
+ "tags": [{"name": t.name, "summary": t.summary or ""} for t in data.tags],
82
106
  "articles": [{"id": a.id, "summary": a.summary or ""} for a in data.articles],
83
107
  }
84
108
 
@@ -101,9 +125,13 @@ class PrimerNoteListCommand(Command):
101
125
  see_also=["/primer/note add", "/primer/note remove"],
102
126
  )
103
127
 
128
+ def _register_options(self) -> None:
129
+ self.register_option(project_option(help_text="Assert which project's primer this reads"))
130
+
104
131
  def execute(
105
132
  self, args: list[str], options: ResolvedOptions, client: Any = None
106
133
  ) -> CommandResult:
134
+ _assert_workspace_project(self, options)
107
135
  data = load_primer()
108
136
  return [{"index": i + 1, "note": note} for i, note in enumerate(data.notes)]
109
137
 
@@ -248,9 +276,13 @@ class PrimerArticleListCommand(Command):
248
276
  see_also=["/primer/article add", "/primer/article remove"],
249
277
  )
250
278
 
279
+ def _register_options(self) -> None:
280
+ self.register_option(project_option(help_text="Assert which project's primer this reads"))
281
+
251
282
  def execute(
252
283
  self, args: list[str], options: ResolvedOptions, client: Any = None
253
284
  ) -> CommandResult:
285
+ _assert_workspace_project(self, options)
254
286
  data = load_primer()
255
287
  return [{"id": a.id, "summary": a.summary or ""} for a in data.articles]
256
288
 
@@ -342,6 +374,153 @@ class PrimerArticleRemoveCommand(Command):
342
374
  return {"ok": True, "action": "remove", "id": removed.id}
343
375
 
344
376
 
377
+ # ---------------------------------------------------------------------------
378
+ # /primer/tag
379
+ # ---------------------------------------------------------------------------
380
+
381
+
382
+ class PrimerTagListCommand(Command):
383
+ """List pinned tags."""
384
+
385
+ def __init__(self) -> None:
386
+ super().__init__(
387
+ "list",
388
+ "List pinned tags",
389
+ usage="kd /primer/tag list",
390
+ long_description="Lists all tags pinned in the project primer. Pinned "
391
+ "tags act as the OR scope for `kd /tag entities --primer-scope`.",
392
+ examples=["kd /primer/tag list"],
393
+ see_also=["/primer/tag add", "/primer/tag remove", "/tag entities"],
394
+ )
395
+
396
+ def _register_options(self) -> None:
397
+ self.register_option(project_option(help_text="Assert which project's primer this reads"))
398
+
399
+ def execute(
400
+ self, args: list[str], options: ResolvedOptions, client: Any = None
401
+ ) -> CommandResult:
402
+ _assert_workspace_project(self, options)
403
+ data = load_primer()
404
+ return [{"name": t.name, "summary": t.summary or ""} for t in data.tags]
405
+
406
+
407
+ class PrimerTagAddCommand(Command):
408
+ """Pin a tag to the primer."""
409
+
410
+ def __init__(self) -> None:
411
+ super().__init__(
412
+ "add",
413
+ "Pin a tag to the primer",
414
+ requires_config=True,
415
+ requires_client=True,
416
+ usage="kd /primer/tag add TAG_NAME [--summary S]",
417
+ long_description="Adds a tag to the project primer. Upstream existence "
418
+ "is validated via the tag namespace: tags not found upstream are still "
419
+ "written but return a `tag_not_found` warning — this supports declaring "
420
+ "tag intent before the tag is created. A failed validation call "
421
+ "(network / auth / timeout) writes the entry with a "
422
+ "`tag_validation_skipped` warning so a local primer edit is not "
423
+ "silently blocked by an unreachable server.",
424
+ examples=[
425
+ "kd /primer/tag add kind:incident",
426
+ 'kd /primer/tag add kind:incident --summary "Active incidents"',
427
+ ],
428
+ see_also=["/primer/tag list", "/primer/tag remove", "/tag entities"],
429
+ )
430
+
431
+ def _register_options(self) -> None:
432
+ self.register_option(
433
+ OptionDeclaration(
434
+ name="summary",
435
+ short_name="s",
436
+ layer=OptionLayer.COMMAND,
437
+ help_text="Description to store with the tag reference",
438
+ )
439
+ )
440
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
441
+
442
+ def execute(
443
+ self, args: list[str], options: ResolvedOptions, client: Any = None
444
+ ) -> CommandResult:
445
+ _assert_workspace_project(self, options)
446
+ if not args:
447
+ raise ValidationError("Usage: kd /primer/tag add TAG_NAME")
448
+ name = args[0]
449
+ summary = options.command.get("summary")
450
+
451
+ warning: dict[str, Any] | None = None
452
+ try:
453
+ client.tags.get(name)
454
+ except NotFoundError as exc:
455
+ warning = emit_warning(
456
+ "tag_not_found",
457
+ f"Tag '{name}' not found upstream — pinning anyway (declare-intent).",
458
+ name=name,
459
+ detail=str(exc),
460
+ )
461
+ except KdError as exc:
462
+ warning = emit_warning(
463
+ "tag_validation_skipped",
464
+ f"Could not validate tag '{name}' upstream ({type(exc).__name__}) — "
465
+ "pinning the entry; verify the name when the upstream is reachable.",
466
+ name=name,
467
+ error_code=type(exc).__name__,
468
+ detail=str(exc),
469
+ )
470
+
471
+ tag = add_tag(name, summary=summary)
472
+ result: dict[str, Any] = {"ok": True, "action": "add", "name": tag.name}
473
+ if tag.summary:
474
+ result["summary"] = tag.summary
475
+ if warning is not None:
476
+ result["warnings"] = [warning]
477
+ return result
478
+
479
+
480
+ class PrimerTagRemoveCommand(Command):
481
+ """Unpin a tag by name, or all tags with --all."""
482
+
483
+ def __init__(self) -> None:
484
+ super().__init__(
485
+ "remove",
486
+ "Unpin a tag or all tags",
487
+ requires_config=True,
488
+ usage="kd /primer/tag remove TAG_NAME | --all",
489
+ long_description="Removes the tag with the given name from the primer, "
490
+ "or all pinned tags if --all is passed. Local-only edit — no upstream "
491
+ "call.",
492
+ examples=[
493
+ "kd /primer/tag remove kind:incident",
494
+ "kd /primer/tag remove --all",
495
+ ],
496
+ see_also=["/primer/tag list", "/primer/tag add"],
497
+ )
498
+
499
+ def _register_options(self) -> None:
500
+ self.register_option(
501
+ OptionDeclaration(
502
+ name="all",
503
+ layer=OptionLayer.COMMAND,
504
+ type=bool,
505
+ default=False,
506
+ help_text="Remove all pinned tags",
507
+ )
508
+ )
509
+ self.register_option(project_option(help_text="Assert which project's primer this edits"))
510
+
511
+ def execute(
512
+ self, args: list[str], options: ResolvedOptions, client: Any = None
513
+ ) -> CommandResult:
514
+ _assert_workspace_project(self, options)
515
+ if options.command.get("all"):
516
+ count = clear_tags()
517
+ return {"ok": True, "action": "clear", "removed": count}
518
+ if not args:
519
+ raise ValidationError("Usage: kd /primer/tag remove TAG_NAME | --all")
520
+ removed = remove_tag(args[0])
521
+ return {"ok": True, "action": "remove", "name": removed.name}
522
+
523
+
345
524
  # ---------------------------------------------------------------------------
346
525
  # Namespace factory
347
526
  # ---------------------------------------------------------------------------
@@ -364,6 +543,17 @@ def create_primer_namespace() -> Namespace:
364
543
  note_ns.register_command("update", PrimerNoteUpdateCommand())
365
544
  ns.register_sub_namespace(note_ns)
366
545
 
546
+ # /primer/tag
547
+ tag_ns = Namespace(
548
+ "tag",
549
+ "Pinned tag references",
550
+ path="primer/tag",
551
+ )
552
+ tag_ns.register_command("list", PrimerTagListCommand())
553
+ tag_ns.register_command("add", PrimerTagAddCommand())
554
+ tag_ns.register_command("remove", PrimerTagRemoveCommand())
555
+ ns.register_sub_namespace(tag_ns)
556
+
367
557
  # /primer/article
368
558
  article_ns = Namespace(
369
559
  "article",