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
@@ -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