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.
- kd/__init__.py +45 -0
- kd/api/__init__.py +15 -0
- kd/api/articles.py +232 -0
- kd/api/http.py +234 -0
- kd/api/hub.py +69 -0
- kd/api/saved_queries.py +76 -0
- kd/api/tags.py +107 -0
- kd/api/youtrack.py +414 -0
- kd/article_marker.py +348 -0
- kd/auth.py +96 -0
- kd/cli/__init__.py +5 -0
- kd/cli/core/__init__.py +25 -0
- kd/cli/core/command_base.py +133 -0
- kd/cli/core/command_parser.py +246 -0
- kd/cli/core/namespaces.py +175 -0
- kd/cli/core/option_registry.py +265 -0
- kd/cli/input.py +42 -0
- kd/cli/main.py +612 -0
- kd/cli/namespaces/__init__.py +38 -0
- kd/cli/namespaces/alias/__init__.py +87 -0
- kd/cli/namespaces/article/__init__.py +843 -0
- kd/cli/namespaces/init.py +42 -0
- kd/cli/namespaces/issue/__init__.py +959 -0
- kd/cli/namespaces/primer/__init__.py +337 -0
- kd/cli/namespaces/project/__init__.py +243 -0
- kd/cli/namespaces/query/__init__.py +465 -0
- kd/cli/namespaces/search/__init__.py +82 -0
- kd/cli/namespaces/system/__init__.py +528 -0
- kd/cli/namespaces/tag/__init__.py +337 -0
- kd/cli/namespaces/time/__init__.py +307 -0
- kd/cli/namespaces/user/__init__.py +99 -0
- kd/cli/output.py +287 -0
- kd/cli/tree.py +65 -0
- kd/client.py +1953 -0
- kd/config.py +853 -0
- kd/connection.py +156 -0
- kd/debug.py +107 -0
- kd/exceptions.py +189 -0
- kd/history.py +169 -0
- kd/models/__init__.py +75 -0
- kd/models/article.py +64 -0
- kd/models/assist.py +47 -0
- kd/models/attachment.py +20 -0
- kd/models/comment.py +25 -0
- kd/models/common.py +39 -0
- kd/models/issue.py +67 -0
- kd/models/project.py +60 -0
- kd/models/saved_query.py +54 -0
- kd/models/search.py +20 -0
- kd/models/tag.py +30 -0
- kd/models/user.py +42 -0
- kd/models/work_item.py +59 -0
- kd/primer.py +231 -0
- kd/query_parser.py +181 -0
- kd/resources/__init__.py +1 -0
- kd/resources/command-synonyms.yaml +19 -0
- kd/resources/config-template.yaml +71 -0
- kd/resources/error-messages.yaml +101 -0
- kd_cli-2.0.4.dist-info/METADATA +149 -0
- kd_cli-2.0.4.dist-info/RECORD +64 -0
- kd_cli-2.0.4.dist-info/WHEEL +5 -0
- kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
- kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
- kd_cli-2.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
"""Article namespace: /article
|
|
2
|
+
|
|
3
|
+
Commands: list, show, children, create, update, delete, tree, move
|
|
4
|
+
Sub-namespaces: /article/comment (list, add), /article/attachment (list, add, show, download, delete)
|
|
5
|
+
API layer: YouTrack REST API (/api/articles)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from kd.cli.core.command_base import Command, CommandResult
|
|
14
|
+
from kd.cli.core.namespaces import Namespace
|
|
15
|
+
from kd.cli.core.option_registry import (
|
|
16
|
+
OptionDeclaration,
|
|
17
|
+
OptionLayer,
|
|
18
|
+
ResolvedOptions,
|
|
19
|
+
limit_option,
|
|
20
|
+
project_option,
|
|
21
|
+
)
|
|
22
|
+
from kd.cli.input import resolve_text_input
|
|
23
|
+
from kd.cli.tree import TreeNode
|
|
24
|
+
from kd.exceptions import ValidationError
|
|
25
|
+
from kd.models import Article, Attachment
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parent_label(article: Article) -> str:
|
|
29
|
+
"""Render an article's parent for display, preferring readable id."""
|
|
30
|
+
parent = article.parent_article
|
|
31
|
+
if parent is None:
|
|
32
|
+
return ""
|
|
33
|
+
return parent.id_readable or parent.name or ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _article_to_row(article: Article) -> dict[str, Any]:
|
|
37
|
+
"""Convert an Article to a flat dict for table output."""
|
|
38
|
+
return {
|
|
39
|
+
"id": article.id_readable,
|
|
40
|
+
"summary": article.summary,
|
|
41
|
+
"project": article.project.name if article.project else "",
|
|
42
|
+
"reporter": article.reporter.name if article.reporter else "",
|
|
43
|
+
"parent": _parent_label(article),
|
|
44
|
+
"created": article.created,
|
|
45
|
+
"updated": article.updated,
|
|
46
|
+
"children": article.has_children,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _article_to_detail(article: Article) -> dict[str, Any]:
|
|
51
|
+
"""Convert an Article to a detailed dict for show output."""
|
|
52
|
+
result: dict[str, Any] = {
|
|
53
|
+
"id": article.id_readable,
|
|
54
|
+
"summary": article.summary,
|
|
55
|
+
"project": article.project.name if article.project else "",
|
|
56
|
+
"reporter": article.reporter.name if article.reporter else "",
|
|
57
|
+
"created": article.created,
|
|
58
|
+
"updated": article.updated,
|
|
59
|
+
"has_children": article.has_children,
|
|
60
|
+
"tags": ", ".join(t.name for t in article.tags),
|
|
61
|
+
}
|
|
62
|
+
if article.parent_article:
|
|
63
|
+
result["parent"] = _parent_label(article)
|
|
64
|
+
if article.content is not None:
|
|
65
|
+
result["content"] = article.content
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- Article Commands ---
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ArticleListCommand(Command):
|
|
73
|
+
"""List articles."""
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
super().__init__(
|
|
77
|
+
"list",
|
|
78
|
+
"List articles",
|
|
79
|
+
requires_client=True,
|
|
80
|
+
usage="kd /article list [--project P] [--parent ID] [--limit N]",
|
|
81
|
+
long_description=(
|
|
82
|
+
"Articles do not support search queries. Use --project to filter "
|
|
83
|
+
"by project, or --parent ARTICLE_ID to list direct children of "
|
|
84
|
+
"another article. When --parent is given, --project is ignored "
|
|
85
|
+
"because child listing is already scoped to the parent's project."
|
|
86
|
+
),
|
|
87
|
+
examples=[
|
|
88
|
+
"kd /article list --project FB",
|
|
89
|
+
"kd /article list --project FB --limit 10",
|
|
90
|
+
"kd /article list --parent FB-A-1",
|
|
91
|
+
],
|
|
92
|
+
see_also=["/article show", "/article children"],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _register_options(self) -> None:
|
|
96
|
+
self.register_option(project_option())
|
|
97
|
+
self.register_option(
|
|
98
|
+
OptionDeclaration(
|
|
99
|
+
name="parent",
|
|
100
|
+
layer=OptionLayer.COMMAND,
|
|
101
|
+
help_text="List direct children of this article id",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
self.register_option(limit_option())
|
|
105
|
+
|
|
106
|
+
def execute(
|
|
107
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
108
|
+
) -> CommandResult:
|
|
109
|
+
project = options.command.get("project")
|
|
110
|
+
parent = options.command.get("parent")
|
|
111
|
+
limit = options.command.get("limit", 50)
|
|
112
|
+
if parent:
|
|
113
|
+
articles = client.articles.children(parent, top=limit)
|
|
114
|
+
else:
|
|
115
|
+
articles = client.articles.list(project=project, top=limit)
|
|
116
|
+
return [_article_to_row(a) for a in articles]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ArticleShowCommand(Command):
|
|
120
|
+
"""Show article details."""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
super().__init__(
|
|
124
|
+
"show",
|
|
125
|
+
"Show article details",
|
|
126
|
+
requires_client=True,
|
|
127
|
+
usage="kd /article show ARTICLE_ID",
|
|
128
|
+
examples=["kd /article show FB-A-1"],
|
|
129
|
+
see_also=["/article list", "/article children", "/article pull"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def execute(
|
|
133
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
134
|
+
) -> CommandResult:
|
|
135
|
+
if not args:
|
|
136
|
+
raise ValidationError("Usage: kd /article show ARTICLE_ID")
|
|
137
|
+
article = client.articles.get(args[0])
|
|
138
|
+
return _article_to_detail(article)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ArticlePullCommand(Command):
|
|
142
|
+
"""Write raw article content to stdout."""
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
super().__init__(
|
|
146
|
+
"pull",
|
|
147
|
+
"Write raw article content to stdout",
|
|
148
|
+
requires_client=True,
|
|
149
|
+
usage="kd /article pull ARTICLE_ID",
|
|
150
|
+
long_description=(
|
|
151
|
+
"Writes the article's raw markdown content to stdout with no "
|
|
152
|
+
"framing, headers, or metadata. Use for scripting, backups, "
|
|
153
|
+
"and file→YouTrack migration round-trips. The --output flag "
|
|
154
|
+
"has no effect; pull is intentionally format-agnostic."
|
|
155
|
+
),
|
|
156
|
+
examples=[
|
|
157
|
+
"kd /article pull FB-A-1",
|
|
158
|
+
"kd /article pull FB-A-1 > setup.md",
|
|
159
|
+
],
|
|
160
|
+
see_also=["/article show", "/article list"],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def execute(
|
|
164
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
165
|
+
) -> CommandResult:
|
|
166
|
+
if not args:
|
|
167
|
+
raise ValidationError("Usage: kd /article pull ARTICLE_ID")
|
|
168
|
+
content: str | None = client.articles.pull(args[0])
|
|
169
|
+
if content is None:
|
|
170
|
+
raise ValidationError(f"Article {args[0]} has no content")
|
|
171
|
+
return content
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ArticlePushCommand(Command):
|
|
175
|
+
"""Idempotent upsert of a local markdown file as a YouTrack article."""
|
|
176
|
+
|
|
177
|
+
def __init__(self) -> None:
|
|
178
|
+
super().__init__(
|
|
179
|
+
"push",
|
|
180
|
+
"Idempotently upsert a markdown file as an article",
|
|
181
|
+
requires_client=True,
|
|
182
|
+
usage=(
|
|
183
|
+
"kd /article push FILE --project P --title T "
|
|
184
|
+
"[--slug S] [--parent ID] [--tag T] [--force] [--dry-run]"
|
|
185
|
+
),
|
|
186
|
+
long_description=(
|
|
187
|
+
"Push a local markdown file to YouTrack as a knowledge base "
|
|
188
|
+
"article. The file carries its own identity in an HTML comment "
|
|
189
|
+
"marker block at the top (<!-- kd-meta ... -->). On first push, "
|
|
190
|
+
"a marker is synthesized and the local file is rewritten to "
|
|
191
|
+
"include it. On subsequent pushes, the marker routes the update "
|
|
192
|
+
"to the same article. "
|
|
193
|
+
"IMPORTANT: push rewrites the local file on success to refresh "
|
|
194
|
+
"the marker — treat the file as owned by push after the first "
|
|
195
|
+
"run, like Cargo.lock. "
|
|
196
|
+
"Refuses to overwrite a remotely-edited article unless --force "
|
|
197
|
+
"is passed. --dry-run exits 1 on would-conflict, 0 on would-succeed."
|
|
198
|
+
),
|
|
199
|
+
examples=[
|
|
200
|
+
'kd /article push docs/infra/dns-setup.md --project FB --title "DNS Setup Guide"',
|
|
201
|
+
'kd /article push guide.md --project FB --title "User Guide" --parent FB-A-1',
|
|
202
|
+
'kd /article push guide.md --project FB --title "Guide" --tag "type:guide"',
|
|
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" --dry-run',
|
|
205
|
+
'kd /article push guide.md --project FB --title "Guide" --force',
|
|
206
|
+
],
|
|
207
|
+
see_also=[
|
|
208
|
+
"/article pull",
|
|
209
|
+
"/article create",
|
|
210
|
+
"/article update",
|
|
211
|
+
"/article move",
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _register_options(self) -> None:
|
|
216
|
+
self.register_option(project_option(help_text="Project key for new articles (required)"))
|
|
217
|
+
self.register_option(
|
|
218
|
+
OptionDeclaration(
|
|
219
|
+
name="slug",
|
|
220
|
+
layer=OptionLayer.COMMAND,
|
|
221
|
+
help_text="Override the slug on first push; rejected if it conflicts with the marker",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
self.register_option(
|
|
225
|
+
OptionDeclaration(
|
|
226
|
+
name="parent",
|
|
227
|
+
layer=OptionLayer.COMMAND,
|
|
228
|
+
help_text="Attach a new article under this parent (first create only)",
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
self.register_option(
|
|
232
|
+
OptionDeclaration(
|
|
233
|
+
name="title",
|
|
234
|
+
short_name="s",
|
|
235
|
+
layer=OptionLayer.COMMAND,
|
|
236
|
+
help_text="Article title (required)",
|
|
237
|
+
aliases=["summary"],
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
self.register_option(
|
|
241
|
+
OptionDeclaration(
|
|
242
|
+
name="force",
|
|
243
|
+
layer=OptionLayer.COMMAND,
|
|
244
|
+
type=bool,
|
|
245
|
+
default=False,
|
|
246
|
+
help_text="Bypass the remote-edit conflict check",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
self.register_option(
|
|
250
|
+
OptionDeclaration(
|
|
251
|
+
name="tag",
|
|
252
|
+
short_name="t",
|
|
253
|
+
layer=OptionLayer.COMMAND,
|
|
254
|
+
is_list=True,
|
|
255
|
+
help_text="Tag name (repeatable)",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
self.register_option(
|
|
259
|
+
OptionDeclaration(
|
|
260
|
+
name="dry-run",
|
|
261
|
+
layer=OptionLayer.COMMAND,
|
|
262
|
+
type=bool,
|
|
263
|
+
default=False,
|
|
264
|
+
help_text="Resolve and validate, but do not write anywhere",
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def execute(
|
|
269
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
270
|
+
) -> CommandResult:
|
|
271
|
+
if not args:
|
|
272
|
+
raise ValidationError("Usage: kd /article push FILE --project P")
|
|
273
|
+
file_path = Path(args[0])
|
|
274
|
+
if not file_path.is_file():
|
|
275
|
+
raise ValidationError(f"File not found: {file_path}")
|
|
276
|
+
project = options.command.get("project")
|
|
277
|
+
if not project:
|
|
278
|
+
raise ValidationError("--project is required")
|
|
279
|
+
title = options.command.get("title")
|
|
280
|
+
if not title:
|
|
281
|
+
raise ValidationError("--title is required")
|
|
282
|
+
tags = options.command.get("tag", [])
|
|
283
|
+
result = client.articles.push(
|
|
284
|
+
file_path=file_path,
|
|
285
|
+
project=project,
|
|
286
|
+
summary=title,
|
|
287
|
+
slug=options.command.get("slug"),
|
|
288
|
+
parent=options.command.get("parent"),
|
|
289
|
+
force=bool(options.command.get("force")),
|
|
290
|
+
dry_run=bool(options.command.get("dry-run")),
|
|
291
|
+
tags=tags or None,
|
|
292
|
+
)
|
|
293
|
+
return {
|
|
294
|
+
"ok": True,
|
|
295
|
+
"action": result.action,
|
|
296
|
+
"article": result.article_id,
|
|
297
|
+
"slug": result.slug,
|
|
298
|
+
"path": str(result.path),
|
|
299
|
+
"dry_run": result.dry_run,
|
|
300
|
+
"tags_applied": result.tags_applied,
|
|
301
|
+
"tags_failed": result.tags_failed,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class ArticleChildrenCommand(Command):
|
|
306
|
+
"""List child articles."""
|
|
307
|
+
|
|
308
|
+
def __init__(self) -> None:
|
|
309
|
+
super().__init__(
|
|
310
|
+
"children",
|
|
311
|
+
"List child articles",
|
|
312
|
+
requires_client=True,
|
|
313
|
+
usage="kd /article children ARTICLE_ID [--limit N]",
|
|
314
|
+
examples=["kd /article children FB-A-1"],
|
|
315
|
+
see_also=["/article show", "/article list"],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _register_options(self) -> None:
|
|
319
|
+
self.register_option(limit_option())
|
|
320
|
+
|
|
321
|
+
def execute(
|
|
322
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
323
|
+
) -> CommandResult:
|
|
324
|
+
if not args:
|
|
325
|
+
raise ValidationError("Usage: kd /article children ARTICLE_ID")
|
|
326
|
+
limit = options.command.get("limit", 50)
|
|
327
|
+
children = client.articles.children(args[0], top=limit)
|
|
328
|
+
return [_article_to_row(a) for a in children]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class ArticleCreateCommand(Command):
|
|
332
|
+
"""Create an article."""
|
|
333
|
+
|
|
334
|
+
def __init__(self) -> None:
|
|
335
|
+
super().__init__(
|
|
336
|
+
"create",
|
|
337
|
+
"Create an article",
|
|
338
|
+
requires_client=True,
|
|
339
|
+
usage='kd /article create "Summary" --project P [--content TEXT] [--parent ID] [--tag T]',
|
|
340
|
+
long_description=(
|
|
341
|
+
"--project is the project key (e.g. FB). Summary is the article "
|
|
342
|
+
"title. Pass --parent to attach the new article under an "
|
|
343
|
+
"existing one in a single call, avoiding a create-then-move "
|
|
344
|
+
"two-step in migration scripts. Pass --tag (repeatable) to "
|
|
345
|
+
"apply tags at creation time."
|
|
346
|
+
),
|
|
347
|
+
examples=[
|
|
348
|
+
'kd /article create "Setup Guide" --project FB --content @guide.md',
|
|
349
|
+
'kd /article create "Quick Note" -p FB --content "Remember to update DNS"',
|
|
350
|
+
'echo "# Draft" | kd /article create "Draft" -p FB --content -',
|
|
351
|
+
'kd /article create "Sub Guide" -p FB --parent FB-A-1 --content @sub.md',
|
|
352
|
+
'kd /article create "Guide" -p FB --content @g.md --tag "type:guide" --tag "reviewed"',
|
|
353
|
+
],
|
|
354
|
+
see_also=["/article show", "/article update", "/article move"],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def _register_options(self) -> None:
|
|
358
|
+
self.register_option(project_option(required=True))
|
|
359
|
+
self.register_option(
|
|
360
|
+
OptionDeclaration(
|
|
361
|
+
name="content",
|
|
362
|
+
short_name="c",
|
|
363
|
+
layer=OptionLayer.COMMAND,
|
|
364
|
+
help_text="Article content (Markdown). Use @file.md to read from file, or @- / - for stdin",
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
self.register_option(
|
|
368
|
+
OptionDeclaration(
|
|
369
|
+
name="parent",
|
|
370
|
+
layer=OptionLayer.COMMAND,
|
|
371
|
+
help_text="Attach the new article as a child of this article id",
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
self.register_option(
|
|
375
|
+
OptionDeclaration(
|
|
376
|
+
name="tag",
|
|
377
|
+
short_name="t",
|
|
378
|
+
layer=OptionLayer.COMMAND,
|
|
379
|
+
is_list=True,
|
|
380
|
+
help_text="Tag name (repeatable)",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def execute(
|
|
385
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
386
|
+
) -> CommandResult:
|
|
387
|
+
if not args:
|
|
388
|
+
raise ValidationError('Usage: kd /article create "Summary" --project P')
|
|
389
|
+
project_key = options.command["project"]
|
|
390
|
+
summary = " ".join(args)
|
|
391
|
+
content = resolve_text_input(options.command.get("content"))
|
|
392
|
+
parent = options.command.get("parent")
|
|
393
|
+
tags = options.command.get("tag", [])
|
|
394
|
+
article = client.articles.create(
|
|
395
|
+
project=project_key,
|
|
396
|
+
summary=summary,
|
|
397
|
+
content=content,
|
|
398
|
+
parent=parent,
|
|
399
|
+
tags=tags or None,
|
|
400
|
+
)
|
|
401
|
+
return _article_to_detail(article)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class ArticleUpdateCommand(Command):
|
|
405
|
+
"""Update an article."""
|
|
406
|
+
|
|
407
|
+
def __init__(self) -> None:
|
|
408
|
+
super().__init__(
|
|
409
|
+
"update",
|
|
410
|
+
"Update an article",
|
|
411
|
+
requires_client=True,
|
|
412
|
+
usage="kd /article update ARTICLE_ID [--title T] [--content TEXT]",
|
|
413
|
+
examples=[
|
|
414
|
+
'kd /article update FB-A-1 --title "New Title"',
|
|
415
|
+
"kd /article update FB-A-1 --content @revised.md",
|
|
416
|
+
],
|
|
417
|
+
see_also=["/article show"],
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def _register_options(self) -> None:
|
|
421
|
+
self.register_option(
|
|
422
|
+
OptionDeclaration(
|
|
423
|
+
name="title",
|
|
424
|
+
short_name="s",
|
|
425
|
+
layer=OptionLayer.COMMAND,
|
|
426
|
+
help_text="Article title",
|
|
427
|
+
aliases=["summary"],
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
self.register_option(
|
|
431
|
+
OptionDeclaration(
|
|
432
|
+
name="content",
|
|
433
|
+
short_name="c",
|
|
434
|
+
layer=OptionLayer.COMMAND,
|
|
435
|
+
help_text="New article content (Markdown). Use @file.md to read from file, or @- / - for stdin",
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def execute(
|
|
440
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
441
|
+
) -> CommandResult:
|
|
442
|
+
if not args:
|
|
443
|
+
raise ValidationError("Usage: kd /article update ARTICLE_ID [--title T] ...")
|
|
444
|
+
article_id = args[0]
|
|
445
|
+
title = options.command.get("title")
|
|
446
|
+
content = resolve_text_input(options.command.get("content"))
|
|
447
|
+
if not title and not content:
|
|
448
|
+
raise ValidationError("At least one of --title or --content must be specified")
|
|
449
|
+
article = client.articles.update(article_id, summary=title, content=content)
|
|
450
|
+
return _article_to_detail(article)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class ArticleDeleteCommand(Command):
|
|
454
|
+
"""Delete an article."""
|
|
455
|
+
|
|
456
|
+
def __init__(self) -> None:
|
|
457
|
+
super().__init__(
|
|
458
|
+
"delete",
|
|
459
|
+
"Delete an article",
|
|
460
|
+
requires_client=True,
|
|
461
|
+
usage="kd /article delete ARTICLE_ID",
|
|
462
|
+
examples=["kd /article delete FB-A-1"],
|
|
463
|
+
see_also=["/article list"],
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def execute(
|
|
467
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
468
|
+
) -> CommandResult:
|
|
469
|
+
if not args:
|
|
470
|
+
raise ValidationError("Usage: kd /article delete ARTICLE_ID")
|
|
471
|
+
article_id = args[0]
|
|
472
|
+
client.articles.delete(article_id)
|
|
473
|
+
return {"ok": True, "action": "delete", "article": article_id}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# --- Comment Commands ---
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class ArticleCommentListCommand(Command):
|
|
480
|
+
"""List comments on an article."""
|
|
481
|
+
|
|
482
|
+
def __init__(self) -> None:
|
|
483
|
+
super().__init__(
|
|
484
|
+
"list",
|
|
485
|
+
"List comments on an article",
|
|
486
|
+
requires_client=True,
|
|
487
|
+
usage="kd /article/comment list ARTICLE_ID",
|
|
488
|
+
examples=["kd /article/comment list FB-A-1"],
|
|
489
|
+
see_also=["/article/comment add", "/article show"],
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def execute(
|
|
493
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
494
|
+
) -> CommandResult:
|
|
495
|
+
if not args:
|
|
496
|
+
raise ValidationError("Usage: kd /article/comment list ARTICLE_ID")
|
|
497
|
+
comments = client.articles.list_comments(args[0])
|
|
498
|
+
return [
|
|
499
|
+
{
|
|
500
|
+
"id": c.id,
|
|
501
|
+
"author": c.author.name if c.author else "",
|
|
502
|
+
"text": c.text,
|
|
503
|
+
"pinned": c.pinned,
|
|
504
|
+
"created": c.created,
|
|
505
|
+
}
|
|
506
|
+
for c in comments
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class ArticleCommentAddCommand(Command):
|
|
511
|
+
"""Add a comment to an article."""
|
|
512
|
+
|
|
513
|
+
def __init__(self) -> None:
|
|
514
|
+
super().__init__(
|
|
515
|
+
"add",
|
|
516
|
+
"Add a comment to an article",
|
|
517
|
+
requires_client=True,
|
|
518
|
+
usage='kd /article/comment add ARTICLE_ID "Text"',
|
|
519
|
+
examples=[
|
|
520
|
+
'kd /article/comment add FB-A-1 "Needs review"',
|
|
521
|
+
"kd /article/comment add FB-A-1 @note.md",
|
|
522
|
+
],
|
|
523
|
+
see_also=["/article/comment list"],
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def execute(
|
|
527
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
528
|
+
) -> CommandResult:
|
|
529
|
+
if len(args) < 2:
|
|
530
|
+
raise ValidationError('Usage: kd /article/comment add ARTICLE_ID "Text"')
|
|
531
|
+
article_id = args[0]
|
|
532
|
+
text = resolve_text_input(" ".join(args[1:]))
|
|
533
|
+
comment = client.articles.add_comment(article_id, text)
|
|
534
|
+
return {
|
|
535
|
+
"id": comment.id,
|
|
536
|
+
"author": comment.author.name if comment.author else "",
|
|
537
|
+
"text": comment.text,
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# --- Attachment Commands ---
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _attachment_to_row(a: Attachment) -> dict[str, Any]:
|
|
545
|
+
"""Convert an Attachment to a flat dict for table output."""
|
|
546
|
+
return {
|
|
547
|
+
"id": a.id,
|
|
548
|
+
"name": a.name,
|
|
549
|
+
"size": a.size,
|
|
550
|
+
"mime_type": a.mime_type or "",
|
|
551
|
+
"author": a.author.name if a.author else "",
|
|
552
|
+
"created": a.created,
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class ArticleAttachmentListCommand(Command):
|
|
557
|
+
"""List attachments on an article."""
|
|
558
|
+
|
|
559
|
+
def __init__(self) -> None:
|
|
560
|
+
super().__init__(
|
|
561
|
+
"list",
|
|
562
|
+
"List attachments on an article",
|
|
563
|
+
requires_client=True,
|
|
564
|
+
usage="kd /article/attachment list ARTICLE_ID",
|
|
565
|
+
examples=["kd /article/attachment list FB-A-1"],
|
|
566
|
+
see_also=["/article/attachment add", "/article/attachment download"],
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def execute(
|
|
570
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
571
|
+
) -> CommandResult:
|
|
572
|
+
if not args:
|
|
573
|
+
raise ValidationError("Usage: kd /article/attachment list ARTICLE_ID")
|
|
574
|
+
attachments = client.articles.list_attachments(args[0])
|
|
575
|
+
return [_attachment_to_row(a) for a in attachments]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class ArticleAttachmentAddCommand(Command):
|
|
579
|
+
"""Add attachments to an article."""
|
|
580
|
+
|
|
581
|
+
def __init__(self) -> None:
|
|
582
|
+
super().__init__(
|
|
583
|
+
"add",
|
|
584
|
+
"Add attachments to an article",
|
|
585
|
+
requires_client=True,
|
|
586
|
+
usage="kd /article/attachment add ARTICLE_ID FILE [FILE...]",
|
|
587
|
+
examples=[
|
|
588
|
+
"kd /article/attachment add FB-A-1 diagram.png",
|
|
589
|
+
"kd /article/attachment add FB-A-1 log.txt report.pdf",
|
|
590
|
+
],
|
|
591
|
+
see_also=["/article/attachment list"],
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
def execute(
|
|
595
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
596
|
+
) -> CommandResult:
|
|
597
|
+
if len(args) < 2:
|
|
598
|
+
raise ValidationError("Usage: kd /article/attachment add ARTICLE_ID FILE [FILE...]")
|
|
599
|
+
article_id = args[0]
|
|
600
|
+
file_paths = [Path(p) for p in args[1:]]
|
|
601
|
+
attachments = client.articles.add_attachments(article_id, file_paths)
|
|
602
|
+
return [_attachment_to_row(a) for a in attachments]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class ArticleAttachmentShowCommand(Command):
|
|
606
|
+
"""Show attachment metadata."""
|
|
607
|
+
|
|
608
|
+
def __init__(self) -> None:
|
|
609
|
+
super().__init__(
|
|
610
|
+
"show",
|
|
611
|
+
"Show attachment metadata",
|
|
612
|
+
requires_client=True,
|
|
613
|
+
usage="kd /article/attachment show ARTICLE_ID ATTACHMENT_ID",
|
|
614
|
+
examples=["kd /article/attachment show FB-A-1 att-1"],
|
|
615
|
+
see_also=["/article/attachment list", "/article/attachment download"],
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def execute(
|
|
619
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
620
|
+
) -> CommandResult:
|
|
621
|
+
if len(args) < 2:
|
|
622
|
+
raise ValidationError("Usage: kd /article/attachment show ARTICLE_ID ATTACHMENT_ID")
|
|
623
|
+
a = client.articles.get_attachment(args[0], args[1])
|
|
624
|
+
return {
|
|
625
|
+
"id": a.id,
|
|
626
|
+
"name": a.name,
|
|
627
|
+
"size": a.size,
|
|
628
|
+
"mime_type": a.mime_type or "",
|
|
629
|
+
"extension": a.extension or "",
|
|
630
|
+
"author": a.author.name if a.author else "",
|
|
631
|
+
"created": a.created,
|
|
632
|
+
"url": a.url or "",
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class ArticleAttachmentDownloadCommand(Command):
|
|
637
|
+
"""Download an article attachment."""
|
|
638
|
+
|
|
639
|
+
def __init__(self) -> None:
|
|
640
|
+
super().__init__(
|
|
641
|
+
"download",
|
|
642
|
+
"Download an article attachment",
|
|
643
|
+
requires_client=True,
|
|
644
|
+
usage="kd /article/attachment download ARTICLE_ID ATTACHMENT_ID [--to DIR]",
|
|
645
|
+
examples=[
|
|
646
|
+
"kd /article/attachment download FB-A-1 att-1",
|
|
647
|
+
"kd /article/attachment download FB-A-1 att-1 --to ./docs",
|
|
648
|
+
],
|
|
649
|
+
see_also=["/article/attachment list", "/article/attachment show"],
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def _register_options(self) -> None:
|
|
653
|
+
self.register_option(
|
|
654
|
+
OptionDeclaration(
|
|
655
|
+
name="to",
|
|
656
|
+
layer=OptionLayer.COMMAND,
|
|
657
|
+
help_text="Target directory for download (default: current directory)",
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def execute(
|
|
662
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
663
|
+
) -> CommandResult:
|
|
664
|
+
if len(args) < 2:
|
|
665
|
+
raise ValidationError("Usage: kd /article/attachment download ARTICLE_ID ATTACHMENT_ID")
|
|
666
|
+
target_dir = None
|
|
667
|
+
to_value = options.command.get("to")
|
|
668
|
+
if to_value:
|
|
669
|
+
target_dir = Path(to_value)
|
|
670
|
+
dest = client.articles.download_attachment(args[0], args[1], target_dir=target_dir)
|
|
671
|
+
return {"ok": True, "action": "download", "article": args[0], "path": str(dest)}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class ArticleAttachmentDeleteCommand(Command):
|
|
675
|
+
"""Delete an article attachment."""
|
|
676
|
+
|
|
677
|
+
def __init__(self) -> None:
|
|
678
|
+
super().__init__(
|
|
679
|
+
"delete",
|
|
680
|
+
"Delete an article attachment",
|
|
681
|
+
requires_client=True,
|
|
682
|
+
usage="kd /article/attachment delete ARTICLE_ID ATTACHMENT_ID",
|
|
683
|
+
examples=["kd /article/attachment delete FB-A-1 att-1"],
|
|
684
|
+
see_also=["/article/attachment list"],
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
def execute(
|
|
688
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
689
|
+
) -> CommandResult:
|
|
690
|
+
if len(args) < 2:
|
|
691
|
+
raise ValidationError("Usage: kd /article/attachment delete ARTICLE_ID ATTACHMENT_ID")
|
|
692
|
+
client.articles.delete_attachment(args[0], args[1])
|
|
693
|
+
return {"ok": True, "action": "delete", "article": args[0], "attachment": args[1]}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# --- Move Command ---
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class ArticleMoveCommand(Command):
|
|
700
|
+
"""Move an article to a new parent."""
|
|
701
|
+
|
|
702
|
+
def __init__(self) -> None:
|
|
703
|
+
super().__init__(
|
|
704
|
+
"move",
|
|
705
|
+
"Move an article to a new parent",
|
|
706
|
+
requires_client=True,
|
|
707
|
+
usage="kd /article move ARTICLE_ID --parent PARENT_ID",
|
|
708
|
+
long_description="Use --parent none to move an article to project root.",
|
|
709
|
+
examples=[
|
|
710
|
+
"kd /article move FB-A-3 --parent FB-A-1",
|
|
711
|
+
"kd /article move FB-A-3 --parent none",
|
|
712
|
+
],
|
|
713
|
+
see_also=["/article tree", "/article children"],
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
def _register_options(self) -> None:
|
|
717
|
+
self.register_option(
|
|
718
|
+
OptionDeclaration(
|
|
719
|
+
name="parent",
|
|
720
|
+
layer=OptionLayer.COMMAND,
|
|
721
|
+
help_text="New parent article ID, or 'none' to move to root",
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
def execute(
|
|
726
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
727
|
+
) -> CommandResult:
|
|
728
|
+
if not args:
|
|
729
|
+
raise ValidationError("Usage: kd /article move ARTICLE_ID --parent PARENT_ID")
|
|
730
|
+
parent_value = options.command.get("parent")
|
|
731
|
+
if not parent_value:
|
|
732
|
+
raise ValidationError("--parent is required (use 'none' for root)")
|
|
733
|
+
article_id = args[0]
|
|
734
|
+
parent = None if parent_value.lower() == "none" else parent_value
|
|
735
|
+
article = client.articles.move(article_id, parent=parent)
|
|
736
|
+
return _article_to_detail(article)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# --- Tree Command ---
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _article_to_tree_node(article: Article) -> TreeNode:
|
|
743
|
+
"""Convert an Article to a TreeNode for tree rendering."""
|
|
744
|
+
return TreeNode(
|
|
745
|
+
id=article.id_readable,
|
|
746
|
+
label=f"{article.id_readable}: {article.summary}",
|
|
747
|
+
has_children=article.has_children,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _collect_tree(
|
|
752
|
+
node: TreeNode,
|
|
753
|
+
fetch_children: Callable[[str], list[TreeNode]],
|
|
754
|
+
max_depth: int,
|
|
755
|
+
) -> dict[str, Any]:
|
|
756
|
+
"""Walk the tree and return nested dicts for structured output."""
|
|
757
|
+
result: dict[str, Any] = {"id": node.id, "summary": node.label, "children": []}
|
|
758
|
+
if max_depth > 0 and node.has_children:
|
|
759
|
+
children = fetch_children(node.id)
|
|
760
|
+
result["children"] = [_collect_tree(c, fetch_children, max_depth - 1) for c in children]
|
|
761
|
+
return result
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class ArticleTreeCommand(Command):
|
|
765
|
+
"""Display article subtree with box-drawing characters."""
|
|
766
|
+
|
|
767
|
+
def __init__(self) -> None:
|
|
768
|
+
super().__init__(
|
|
769
|
+
"tree",
|
|
770
|
+
"Display article subtree",
|
|
771
|
+
requires_client=True,
|
|
772
|
+
usage="kd /article tree ARTICLE_ID [--depth N]",
|
|
773
|
+
long_description=(
|
|
774
|
+
"Renders the article hierarchy as an indented tree. "
|
|
775
|
+
"Each level requires an API call to fetch children."
|
|
776
|
+
),
|
|
777
|
+
examples=[
|
|
778
|
+
"kd /article tree FB-A-1",
|
|
779
|
+
"kd /article tree FB-A-1 --depth 3",
|
|
780
|
+
],
|
|
781
|
+
see_also=["/article children", "/article show"],
|
|
782
|
+
)
|
|
783
|
+
self.display_hint = "tree"
|
|
784
|
+
|
|
785
|
+
def _register_options(self) -> None:
|
|
786
|
+
self.register_option(
|
|
787
|
+
OptionDeclaration(
|
|
788
|
+
name="depth",
|
|
789
|
+
short_name="d",
|
|
790
|
+
layer=OptionLayer.COMMAND,
|
|
791
|
+
type=int,
|
|
792
|
+
default=5,
|
|
793
|
+
help_text="Maximum tree depth (default: 5)",
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def execute(
|
|
798
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
799
|
+
) -> CommandResult:
|
|
800
|
+
if not args:
|
|
801
|
+
raise ValidationError("Usage: kd /article tree ARTICLE_ID")
|
|
802
|
+
article = client.articles.get(args[0])
|
|
803
|
+
root = _article_to_tree_node(article)
|
|
804
|
+
depth = options.command.get("depth", 5)
|
|
805
|
+
|
|
806
|
+
def fetch_children(article_id: str) -> list[TreeNode]:
|
|
807
|
+
children = client.articles.children(article_id)
|
|
808
|
+
return [_article_to_tree_node(c) for c in children]
|
|
809
|
+
|
|
810
|
+
return _collect_tree(root, fetch_children, depth)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
# --- Namespace Factory ---
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def create_article_namespace() -> Namespace:
|
|
817
|
+
"""Build the /article namespace."""
|
|
818
|
+
ns = Namespace("article", "Knowledge base articles", path="article")
|
|
819
|
+
ns.register_command("list", ArticleListCommand())
|
|
820
|
+
ns.register_command("show", ArticleShowCommand())
|
|
821
|
+
ns.register_command("pull", ArticlePullCommand())
|
|
822
|
+
ns.register_command("push", ArticlePushCommand())
|
|
823
|
+
ns.register_command("children", ArticleChildrenCommand())
|
|
824
|
+
ns.register_command("create", ArticleCreateCommand())
|
|
825
|
+
ns.register_command("update", ArticleUpdateCommand())
|
|
826
|
+
ns.register_command("delete", ArticleDeleteCommand())
|
|
827
|
+
ns.register_command("tree", ArticleTreeCommand())
|
|
828
|
+
ns.register_command("move", ArticleMoveCommand())
|
|
829
|
+
|
|
830
|
+
comment_ns = Namespace("comment", "Article comments", path="article/comment")
|
|
831
|
+
comment_ns.register_command("list", ArticleCommentListCommand())
|
|
832
|
+
comment_ns.register_command("add", ArticleCommentAddCommand())
|
|
833
|
+
ns.register_sub_namespace(comment_ns)
|
|
834
|
+
|
|
835
|
+
attachment_ns = Namespace("attachment", "Article attachments", path="article/attachment")
|
|
836
|
+
attachment_ns.register_command("list", ArticleAttachmentListCommand())
|
|
837
|
+
attachment_ns.register_command("add", ArticleAttachmentAddCommand())
|
|
838
|
+
attachment_ns.register_command("show", ArticleAttachmentShowCommand())
|
|
839
|
+
attachment_ns.register_command("download", ArticleAttachmentDownloadCommand())
|
|
840
|
+
attachment_ns.register_command("delete", ArticleAttachmentDeleteCommand())
|
|
841
|
+
ns.register_sub_namespace(attachment_ns)
|
|
842
|
+
|
|
843
|
+
return ns
|