kd-cli 2.0.4__py3-none-any.whl

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 (64) hide show
  1. kd/__init__.py +45 -0
  2. kd/api/__init__.py +15 -0
  3. kd/api/articles.py +232 -0
  4. kd/api/http.py +234 -0
  5. kd/api/hub.py +69 -0
  6. kd/api/saved_queries.py +76 -0
  7. kd/api/tags.py +107 -0
  8. kd/api/youtrack.py +414 -0
  9. kd/article_marker.py +348 -0
  10. kd/auth.py +96 -0
  11. kd/cli/__init__.py +5 -0
  12. kd/cli/core/__init__.py +25 -0
  13. kd/cli/core/command_base.py +133 -0
  14. kd/cli/core/command_parser.py +246 -0
  15. kd/cli/core/namespaces.py +175 -0
  16. kd/cli/core/option_registry.py +265 -0
  17. kd/cli/input.py +42 -0
  18. kd/cli/main.py +612 -0
  19. kd/cli/namespaces/__init__.py +38 -0
  20. kd/cli/namespaces/alias/__init__.py +87 -0
  21. kd/cli/namespaces/article/__init__.py +843 -0
  22. kd/cli/namespaces/init.py +42 -0
  23. kd/cli/namespaces/issue/__init__.py +959 -0
  24. kd/cli/namespaces/primer/__init__.py +337 -0
  25. kd/cli/namespaces/project/__init__.py +243 -0
  26. kd/cli/namespaces/query/__init__.py +465 -0
  27. kd/cli/namespaces/search/__init__.py +82 -0
  28. kd/cli/namespaces/system/__init__.py +528 -0
  29. kd/cli/namespaces/tag/__init__.py +337 -0
  30. kd/cli/namespaces/time/__init__.py +307 -0
  31. kd/cli/namespaces/user/__init__.py +99 -0
  32. kd/cli/output.py +287 -0
  33. kd/cli/tree.py +65 -0
  34. kd/client.py +1953 -0
  35. kd/config.py +853 -0
  36. kd/connection.py +156 -0
  37. kd/debug.py +107 -0
  38. kd/exceptions.py +189 -0
  39. kd/history.py +169 -0
  40. kd/models/__init__.py +75 -0
  41. kd/models/article.py +64 -0
  42. kd/models/assist.py +47 -0
  43. kd/models/attachment.py +20 -0
  44. kd/models/comment.py +25 -0
  45. kd/models/common.py +39 -0
  46. kd/models/issue.py +67 -0
  47. kd/models/project.py +60 -0
  48. kd/models/saved_query.py +54 -0
  49. kd/models/search.py +20 -0
  50. kd/models/tag.py +30 -0
  51. kd/models/user.py +42 -0
  52. kd/models/work_item.py +59 -0
  53. kd/primer.py +231 -0
  54. kd/query_parser.py +181 -0
  55. kd/resources/__init__.py +1 -0
  56. kd/resources/command-synonyms.yaml +19 -0
  57. kd/resources/config-template.yaml +71 -0
  58. kd/resources/error-messages.yaml +101 -0
  59. kd_cli-2.0.4.dist-info/METADATA +149 -0
  60. kd_cli-2.0.4.dist-info/RECORD +64 -0
  61. kd_cli-2.0.4.dist-info/WHEEL +5 -0
  62. kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
  63. kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
  64. kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/primer.py ADDED
@@ -0,0 +1,231 @@
1
+ """Project primer: local knowledge capsule for LLM consumers.
2
+
3
+ The primer stores project-specific context — conventions, key article
4
+ references, do's and don'ts — in ``.kd/primer.yaml``. LLMs read
5
+ the primer once via ``/primer show`` to get instant project context.
6
+
7
+ Storage is separate from ``config.yaml`` (tool settings vs project
8
+ knowledge). The file is created lazily on first write operation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+ from pydantic import BaseModel
18
+
19
+ from kd.exceptions import ConfigError, ValidationError
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Data model
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ class PrimerArticle(BaseModel):
28
+ """A pinned article reference."""
29
+
30
+ id: str
31
+ summary: str | None = None
32
+
33
+
34
+ class PrimerData(BaseModel):
35
+ """Root primer document."""
36
+
37
+ notes: list[str] = []
38
+ articles: list[PrimerArticle] = []
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # File I/O
43
+ # ---------------------------------------------------------------------------
44
+
45
+ PRIMER_FILENAME = "primer.yaml"
46
+
47
+
48
+ def _find_workspace_dir(start: Path | None = None) -> Path | None:
49
+ """Walk up from *start*, bounded by the git root, return first ``.kd/`` dir found.
50
+
51
+ Stops at the git repository root to prevent walking into a parent
52
+ directory's ``.kd/`` that would act as an unintended global primer.
53
+ If not in a git repo, only checks *start* itself.
54
+ """
55
+ current = (start or Path.cwd()).resolve()
56
+ # Find the ceiling — git root or cwd-only if no repo
57
+ ceiling = _find_git_root(current)
58
+ if ceiling is None:
59
+ # Not in a git repo — only check the start directory
60
+ candidate = current / ".kd"
61
+ return candidate if candidate.is_dir() else None
62
+ while True:
63
+ candidate = current / ".kd"
64
+ if candidate.is_dir():
65
+ return candidate
66
+ if current == ceiling:
67
+ return None
68
+ parent = current.parent
69
+ if parent == current:
70
+ return None
71
+ current = parent
72
+
73
+
74
+ def _find_git_root(start: Path) -> Path | None:
75
+ """Walk up from *start* looking for a ``.git`` directory."""
76
+ current = start.resolve()
77
+ while True:
78
+ if (current / ".git").is_dir():
79
+ return current
80
+ parent = current.parent
81
+ if parent == current:
82
+ return None
83
+ current = parent
84
+
85
+
86
+ def require_workspace_dir(start: Path | None = None) -> Path:
87
+ """Find ``.kd/`` directory or raise."""
88
+ workspace_dir = _find_workspace_dir(start)
89
+ if workspace_dir is None:
90
+ raise ConfigError("No .kd/ directory found. Run 'kd init' first.")
91
+ return workspace_dir
92
+
93
+
94
+ def _primer_path(start: Path | None = None) -> Path:
95
+ """Return path to primer.yaml inside the nearest .kd/ dir."""
96
+ return require_workspace_dir(start) / PRIMER_FILENAME
97
+
98
+
99
+ def load_primer(path: Path | None = None) -> PrimerData:
100
+ """Load primer from YAML. Returns empty primer if file or .kd/ doesn't exist."""
101
+ if path is not None:
102
+ primer_file = path
103
+ else:
104
+ workspace_dir = _find_workspace_dir()
105
+ if workspace_dir is None:
106
+ return PrimerData()
107
+ primer_file = workspace_dir / PRIMER_FILENAME
108
+ if not primer_file.exists():
109
+ return PrimerData()
110
+ try:
111
+ raw = yaml.safe_load(primer_file.read_text()) or {}
112
+ return PrimerData(**raw)
113
+ except (yaml.YAMLError, TypeError, ValueError) as e:
114
+ raise ConfigError(f"Invalid primer at {primer_file}: {e}") from e
115
+
116
+
117
+ def save_primer(data: PrimerData, path: Path | None = None) -> None:
118
+ """Write primer to YAML.
119
+
120
+ Snapshots the current file before writing (copy-on-write) and uses
121
+ atomic writes to prevent partial-write corruption.
122
+ """
123
+ from kd.history import atomic_write_text, snapshot_before_write
124
+
125
+ primer_file = path or _primer_path()
126
+ snapshot_before_write(primer_file)
127
+ dumped = data.model_dump(exclude_none=True)
128
+ # Omit empty sections from the file
129
+ if not dumped.get("notes"):
130
+ dumped.pop("notes", None)
131
+ if not dumped.get("articles"):
132
+ dumped.pop("articles", None)
133
+ # Serialize articles as list of dicts (not bare IDs)
134
+ if "articles" in dumped:
135
+ dumped["articles"] = [_article_dump(a) for a in dumped["articles"]]
136
+ atomic_write_text(
137
+ primer_file,
138
+ yaml.dump(dumped, default_flow_style=False, sort_keys=False, allow_unicode=True),
139
+ )
140
+
141
+
142
+ def _article_dump(article: dict[str, Any]) -> dict[str, Any]:
143
+ """Ensure article dict always has id; include summary only if set."""
144
+ result: dict[str, Any] = {"id": article["id"]}
145
+ if article.get("summary"):
146
+ result["summary"] = article["summary"]
147
+ return result
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Mutations
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def add_note(text: str, path: Path | None = None) -> int:
156
+ """Append a note. Returns the 1-based index."""
157
+ primer_file = path or _primer_path()
158
+ data = load_primer(primer_file)
159
+ data.notes.append(text)
160
+ save_primer(data, primer_file)
161
+ return len(data.notes)
162
+
163
+
164
+ def remove_note(index: int, path: Path | None = None) -> str:
165
+ """Remove a note by 1-based index. Returns the removed text."""
166
+ primer_file = path or _primer_path()
167
+ data = load_primer(primer_file)
168
+ if index < 1 or index > len(data.notes):
169
+ raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
170
+ removed = data.notes.pop(index - 1)
171
+ save_primer(data, primer_file)
172
+ return removed
173
+
174
+
175
+ def update_note(index: int, text: str, path: Path | None = None) -> str:
176
+ """Replace a note by 1-based index. Returns the old text."""
177
+ primer_file = path or _primer_path()
178
+ data = load_primer(primer_file)
179
+ if index < 1 or index > len(data.notes):
180
+ raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
181
+ old = data.notes[index - 1]
182
+ data.notes[index - 1] = text
183
+ save_primer(data, primer_file)
184
+ return old
185
+
186
+
187
+ def clear_notes(path: Path | None = None) -> int:
188
+ """Remove all notes. Returns the count removed."""
189
+ primer_file = path or _primer_path()
190
+ data = load_primer(primer_file)
191
+ count = len(data.notes)
192
+ data.notes.clear()
193
+ save_primer(data, primer_file)
194
+ return count
195
+
196
+
197
+ def add_article(
198
+ article_id: str, summary: str | None = None, path: Path | None = None
199
+ ) -> PrimerArticle:
200
+ """Pin an article. Raises if already pinned."""
201
+ primer_file = path or _primer_path()
202
+ data = load_primer(primer_file)
203
+ for existing in data.articles:
204
+ if existing.id == article_id:
205
+ raise ValidationError(f"Article '{article_id}' is already pinned")
206
+ article = PrimerArticle(id=article_id, summary=summary)
207
+ data.articles.append(article)
208
+ save_primer(data, primer_file)
209
+ return article
210
+
211
+
212
+ def remove_article(article_id: str, path: Path | None = None) -> PrimerArticle:
213
+ """Unpin an article by ID. Returns the removed entry."""
214
+ primer_file = path or _primer_path()
215
+ data = load_primer(primer_file)
216
+ for i, article in enumerate(data.articles):
217
+ if article.id == article_id:
218
+ removed = data.articles.pop(i)
219
+ save_primer(data, primer_file)
220
+ return removed
221
+ raise ValidationError(f"Article '{article_id}' is not pinned")
222
+
223
+
224
+ def clear_articles(path: Path | None = None) -> int:
225
+ """Remove all pinned articles. Returns the count removed."""
226
+ primer_file = path or _primer_path()
227
+ data = load_primer(primer_file)
228
+ count = len(data.articles)
229
+ data.articles.clear()
230
+ save_primer(data, primer_file)
231
+ return count
kd/query_parser.py ADDED
@@ -0,0 +1,181 @@
1
+ """Parse YouTrack query strings for article-compatible field extraction.
2
+
3
+ Extracts ``project:`` and ``tag:`` clauses from saved-query text so that
4
+ tag-based queries can be translated into article-tag lookups. Queries
5
+ containing issue-specific fields (state, assignee, priority, etc.) are
6
+ rejected with a clear error.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from kd.exceptions import ValidationError
14
+
15
+ # Fields that only apply to issues and cannot be translated to article lookups.
16
+ _UNSUPPORTED_FIELDS = frozenset(
17
+ {
18
+ "state",
19
+ "assignee",
20
+ "for",
21
+ "priority",
22
+ "type",
23
+ "has",
24
+ "created",
25
+ "updated",
26
+ "resolved",
27
+ "reporter",
28
+ "sprint",
29
+ "fix versions",
30
+ "affected versions",
31
+ "subsystem",
32
+ }
33
+ )
34
+
35
+ _SUPPORTED_FIELDS = frozenset({"project", "tag"})
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ParsedQuery:
40
+ """Result of parsing a YouTrack query for article-compatible fields."""
41
+
42
+ project: str | None = None
43
+ tags: list[str] = field(default_factory=list)
44
+
45
+
46
+ def parse_article_query(query: str) -> ParsedQuery:
47
+ """Parse a YouTrack query string for article-compatible fields.
48
+
49
+ Supported fields: ``project`` (at most one) and ``tag`` (zero or more).
50
+ Raises :class:`~kd.exceptions.ValidationError` if unsupported
51
+ issue-specific fields or ``#macros`` are found.
52
+ """
53
+ stripped = query.strip()
54
+ if not stripped:
55
+ return ParsedQuery()
56
+
57
+ tokens = _tokenize(stripped)
58
+ _reject_unsupported(tokens)
59
+
60
+ project: str | None = None
61
+ tags: list[str] = []
62
+
63
+ for field_name, value in tokens:
64
+ key = field_name.lower()
65
+ if key == "project":
66
+ project = value
67
+ elif key == "tag":
68
+ tags.append(value)
69
+
70
+ return ParsedQuery(project=project, tags=tags)
71
+
72
+
73
+ def _tokenize(query: str) -> list[tuple[str, str]]:
74
+ """Tokenize a YouTrack query into (field, value) pairs.
75
+
76
+ Handles braced values (``tag: {multi word}``) and bare values
77
+ (``tag: single``). Also detects ``#macros`` like ``#Unresolved``.
78
+ """
79
+ tokens: list[tuple[str, str]] = []
80
+ pos = 0
81
+ length = len(query)
82
+
83
+ while pos < length:
84
+ # Skip whitespace.
85
+ if query[pos].isspace():
86
+ pos += 1
87
+ continue
88
+
89
+ # Detect #macros (e.g. #Unresolved, #Resolved).
90
+ if query[pos] == "#":
91
+ start = pos
92
+ pos += 1
93
+ while pos < length and not query[pos].isspace():
94
+ pos += 1
95
+ macro = query[start:pos]
96
+ tokens.append(("#macro", macro))
97
+ continue
98
+
99
+ # Read a field name: word characters (letters, digits, underscore)
100
+ # and spaces (for multi-word fields like "fix versions").
101
+ field_start = pos
102
+ while pos < length and query[pos] != ":":
103
+ if query[pos].isspace():
104
+ # Peek ahead: if there's a colon after optional words, it's
105
+ # still part of a multi-word field name. Otherwise stop.
106
+ peek = pos
107
+ while peek < length and query[peek].isspace():
108
+ peek += 1
109
+ # If we hit a colon or another word char followed eventually
110
+ # by a colon, continue. Simple heuristic: look for colon
111
+ # within the next token.
112
+ colon_ahead = False
113
+ scan = peek
114
+ while scan < length and query[scan] not in (":", "#"):
115
+ if query[scan].isspace():
116
+ break
117
+ scan += 1
118
+ if scan < length and query[scan] == ":":
119
+ # The next word ends with colon → multi-word field.
120
+ pos = peek
121
+ continue
122
+ else:
123
+ colon_ahead = False # noqa: F841
124
+ break
125
+ pos += 1
126
+
127
+ if pos >= length or query[pos] != ":":
128
+ # Not a field: value pair — skip this word.
129
+ pos += 1
130
+ while pos < length and not query[pos].isspace():
131
+ pos += 1
132
+ continue
133
+
134
+ field_name = query[field_start:pos].strip()
135
+ pos += 1 # skip the colon
136
+
137
+ # Skip whitespace after colon.
138
+ while pos < length and query[pos].isspace():
139
+ pos += 1
140
+
141
+ if pos >= length:
142
+ tokens.append((field_name, ""))
143
+ break
144
+
145
+ # Read the value: braced or bare.
146
+ if query[pos] == "{":
147
+ pos += 1 # skip opening brace
148
+ value_start = pos
149
+ while pos < length and query[pos] != "}":
150
+ pos += 1
151
+ value = query[value_start:pos]
152
+ if pos < length:
153
+ pos += 1 # skip closing brace
154
+ else:
155
+ value_start = pos
156
+ while pos < length and not query[pos].isspace():
157
+ pos += 1
158
+ value = query[value_start:pos]
159
+
160
+ tokens.append((field_name, value))
161
+
162
+ return tokens
163
+
164
+
165
+ def _reject_unsupported(tokens: list[tuple[str, str]]) -> None:
166
+ """Raise ValidationError if any tokens are unsupported for article queries."""
167
+ unsupported: list[str] = []
168
+
169
+ for field_name, value in tokens:
170
+ if field_name == "#macro":
171
+ unsupported.append(value)
172
+ continue
173
+ if field_name.lower() not in _SUPPORTED_FIELDS:
174
+ unsupported.append(field_name)
175
+
176
+ if unsupported:
177
+ joined = ", ".join(unsupported)
178
+ raise ValidationError(
179
+ f"Cannot search articles with issue-specific fields: {joined}. "
180
+ "Only project and tag are supported."
181
+ )
@@ -0,0 +1 @@
1
+ """Packaged resources for kd (config templates, etc.)."""
@@ -0,0 +1,19 @@
1
+ # Built-in command synonyms for LLM and CLI familiarity.
2
+ #
3
+ # Each entry maps a synonym to the canonical command name.
4
+ # Applied globally to any namespace that has the canonical command
5
+ # but does not already define a command with the synonym name.
6
+ #
7
+ # Keep this set small and curated. See YTCTL-8.
8
+
9
+ - synonym: get
10
+ canonical: show
11
+
12
+ - synonym: ls
13
+ canonical: list
14
+
15
+ - synonym: read
16
+ canonical: show
17
+
18
+ - synonym: rm
19
+ canonical: delete
@@ -0,0 +1,71 @@
1
+ # kd project config
2
+ #
3
+ # Values here override ~/.config/kd/config.yaml for this directory tree.
4
+ # Resolution order: CLI flags > local config > connection > global config.
5
+ #
6
+ # Connection keys:
7
+ # url - YouTrack instance base URL
8
+ # token_cmd - Shell command that prints the API token to stdout
9
+ # token_env - Environment variable name containing the API token
10
+ # default_project - Default project key for commands that accept --project
11
+ # output - Default output format: table, json, yaml, md
12
+ # limit - Default result limit for list commands
13
+ # compatibility - Version targeting (target + mode)
14
+ #
15
+ # At least one of token_cmd or token_env is required per connection.
16
+ # Secrets are never stored as raw values in config.
17
+
18
+ # default_connection: main
19
+
20
+ # connections:
21
+ # main:
22
+ # provider: youtrack
23
+ # url: https://your-instance.youtrack.cloud
24
+ # # token_cmd: gopass show -o path/to/token
25
+ # token_env: KD_TOKEN
26
+ # # default_project: PROJ
27
+ # # output: table
28
+ # # limit: 50
29
+ # # defaults:
30
+ # # issue_list:
31
+ # # query: "for: me #Unresolved"
32
+ # # compatibility:
33
+ # # target: auto
34
+ # # mode: pinned
35
+
36
+ # Global fallbacks (apply when connection doesn't specify):
37
+ # output: table
38
+ # limit: 50
39
+ # debug: false
40
+
41
+ # Per-command defaults (namespace_command as key):
42
+ # defaults:
43
+ # issue_list:
44
+ # limit: 100
45
+ # query: "for: me #Unresolved"
46
+ # issue_show:
47
+ # detail: rich
48
+
49
+ # Per-project overrides:
50
+ # projects:
51
+ # FB:
52
+ # field_aliases:
53
+ # state: Stage
54
+ # type: Kind
55
+ # defaults:
56
+ # issue_list:
57
+ # fields: id,summary,state,priority,assignee
58
+
59
+ # History settings (copy-on-write snapshots before config/primer writes):
60
+ # history:
61
+ # retention: 20 # max snapshots per file (default: 20)
62
+
63
+ # Command aliases (shortcuts that expand to full commands):
64
+ # aliases:
65
+ # my-bugs: "/issue list --query 'for: me Type: Bug #Unresolved'"
66
+ # sprint: "/issue list --query '#Unresolved Board Sprint: {Current sprint}'"
67
+ # standup: "/issue list --query 'for: me updated: Today'"
68
+ #
69
+ # Invoke directly: kd my-bugs
70
+ # Explicit form: kd alias my-bugs
71
+ # With overrides: kd my-bugs --limit 5
@@ -0,0 +1,101 @@
1
+ # Error message mappings for kd
2
+ #
3
+ # Each entry rewrites an upstream API error into a user-friendly message
4
+ # with an actionable suggestion. Rules are matched in order; the first
5
+ # match wins. A rule matches iff every present filter matches:
6
+ #
7
+ # status - Optional HTTP status code. Exact integer equality.
8
+ # path - Optional regex matched against the request URL path.
9
+ # Use named groups like (?P<id>[^/]+) to expose values to
10
+ # the message/suggestion templates as {id}.
11
+ # pattern - Optional regex matched against the extracted response
12
+ # message. Positional groups are exposed as {1}, {2}, ...
13
+ #
14
+ # message - Rewritten user-facing message. Required.
15
+ # suggestion - Optional actionable hint shown below the error.
16
+ #
17
+ # Rule ordering convention:
18
+ # 1. Sub-resource 404 rules first (most specific path segments).
19
+ # 2. Top-level entity 404 rules (issues, articles).
20
+ # 3. Message-based rules (status/path unset) last.
21
+ # Reordering is load-bearing — narrower rules must precede broader ones.
22
+
23
+ # --- Time tracking 403 ---
24
+
25
+ - status: 403
26
+ path: '/issues/[^/]+/timeTracking'
27
+ message: 'Access denied for time tracking operation'
28
+ suggestion: >-
29
+ Time tracking may not be enabled for this project.
30
+ Enable it in YouTrack: project Settings → Time Tracking.
31
+
32
+ # --- Sub-resource 404s (issues) ---
33
+
34
+ - status: 404
35
+ path: '/issues/[^/]+/timeTracking/workItems/(?P<id>[^/]+)$'
36
+ message: 'Work item {id} not found'
37
+ suggestion: 'List work items with: kd /time list <ISSUE>.'
38
+
39
+ - status: 404
40
+ path: '/issues/[^/]+/attachments/(?P<id>[^/]+)(/content)?$'
41
+ message: 'Attachment {id} not found'
42
+ suggestion: 'List attachments with: kd /issue attachment list <ISSUE>.'
43
+
44
+ - status: 404
45
+ path: '/issues/[^/]+/comments/(?P<id>[^/]+)$'
46
+ message: 'Comment {id} not found'
47
+ suggestion: 'Show the parent issue to list its comments.'
48
+
49
+ # --- Sub-resource 404s (articles) ---
50
+
51
+ - status: 404
52
+ path: '/articles/[^/]+/attachments/(?P<id>[^/]+)(/content)?$'
53
+ message: 'Attachment {id} not found'
54
+ suggestion: 'List attachments with: kd /article attachment list <ARTICLE>.'
55
+
56
+ - status: 404
57
+ path: '/articles/[^/]+/comments/(?P<id>[^/]+)$'
58
+ message: 'Comment {id} not found'
59
+ suggestion: 'List comments with: kd /article comment list <ARTICLE>.'
60
+
61
+ # --- Top-level 404s ---
62
+
63
+ - status: 404
64
+ path: '/articles/(?P<id>[^/]+)(/.*)?$'
65
+ message: 'Article {id} not found'
66
+ suggestion: 'Check the article ID with: kd /article list.'
67
+
68
+ - status: 404
69
+ path: '/issues/(?P<id>[^/]+)(/.*)?$'
70
+ message: 'Issue {id} not found'
71
+ suggestion: 'Check the issue ID and project key.'
72
+
73
+ # --- Query parse errors ---
74
+
75
+ - pattern: "(?i)can.t parse search query"
76
+ message: 'Query syntax error'
77
+ suggestion: >-
78
+ Values containing special characters (colons, spaces, braces) must be
79
+ wrapped in braces, e.g. tag: {type:case}. See YouTrack query syntax reference.
80
+
81
+ # --- Message-based rules (any status, any path) ---
82
+
83
+ - pattern: 'The value "(.+)" isn''t used for the project field'
84
+ message: 'Project "{1}" does not exist'
85
+ suggestion: 'Run "kd project list" to see available projects.'
86
+
87
+ - pattern: "Unknown command: (.+)"
88
+ message: 'Unknown YouTrack command: {1}'
89
+ suggestion: 'Check command syntax in the YouTrack documentation.'
90
+
91
+ - pattern: "incompatible-issue-custom-field-name-(.+)"
92
+ message: 'Field "{1}" is not available in this project'
93
+ suggestion: 'Run "kd issue show <ID>" to see which fields this project uses.'
94
+
95
+ - pattern: "Token is incorrect or has expired"
96
+ message: "API token is invalid or expired"
97
+ suggestion: 'Check your token with "kd system ping" or generate a new one in YouTrack → Profile → Authentication.'
98
+
99
+ - pattern: "Banned: Too many failed authentication attempts"
100
+ message: "Too many failed authentication attempts"
101
+ suggestion: "Wait a few minutes and verify your token is correct."