kd-cli 2.6.1__tar.gz → 2.6.2__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 (86) hide show
  1. {kd_cli-2.6.1/kd_cli.egg-info → kd_cli-2.6.2}/PKG-INFO +1 -1
  2. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/core/option_registry.py +16 -1
  3. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/main.py +9 -1
  4. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/article/__init__.py +143 -16
  5. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/issue/__init__.py +144 -17
  6. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/project/__init__.py +42 -6
  7. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/query/__init__.py +62 -6
  8. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/tag/__init__.py +55 -12
  9. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/time/__init__.py +55 -3
  10. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/user/__init__.py +24 -2
  11. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/output.py +170 -34
  12. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/CHANGELOG.md +70 -0
  13. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/operating-guide.md +38 -2
  14. {kd_cli-2.6.1 → kd_cli-2.6.2/kd_cli.egg-info}/PKG-INFO +1 -1
  15. {kd_cli-2.6.1 → kd_cli-2.6.2}/pyproject.toml +1 -1
  16. {kd_cli-2.6.1 → kd_cli-2.6.2}/LICENSE +0 -0
  17. {kd_cli-2.6.1 → kd_cli-2.6.2}/MANIFEST.in +0 -0
  18. {kd_cli-2.6.1 → kd_cli-2.6.2}/README.md +0 -0
  19. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/__init__.py +0 -0
  20. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/api/__init__.py +0 -0
  21. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/api/articles.py +0 -0
  22. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/api/http.py +0 -0
  23. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/api/hub.py +0 -0
  24. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/api/saved_queries.py +0 -0
  25. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/api/tags.py +0 -0
  26. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/api/youtrack.py +0 -0
  27. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/article_marker.py +0 -0
  28. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/auth.py +0 -0
  29. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cache.py +0 -0
  30. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/__init__.py +0 -0
  31. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/core/__init__.py +0 -0
  32. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/core/command_base.py +0 -0
  33. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/core/command_parser.py +0 -0
  34. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/core/confirm.py +0 -0
  35. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/core/namespaces.py +0 -0
  36. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/input.py +0 -0
  37. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/__init__.py +0 -0
  38. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/alias/__init__.py +0 -0
  39. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/cache/__init__.py +0 -0
  40. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/init.py +0 -0
  41. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/primer/__init__.py +0 -0
  42. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/release_notes.py +0 -0
  43. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/search/__init__.py +0 -0
  44. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/namespaces/system/__init__.py +0 -0
  45. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/cli/tree.py +0 -0
  46. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/client.py +0 -0
  47. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/config.py +0 -0
  48. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/config_read.py +0 -0
  49. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/config_schema.py +0 -0
  50. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/connection.py +0 -0
  51. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/debug.py +0 -0
  52. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/exceptions.py +0 -0
  53. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/history.py +0 -0
  54. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/locking.py +0 -0
  55. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/__init__.py +0 -0
  56. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/article.py +0 -0
  57. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/assist.py +0 -0
  58. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/attachment.py +0 -0
  59. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/comment.py +0 -0
  60. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/common.py +0 -0
  61. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/issue.py +0 -0
  62. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/project.py +0 -0
  63. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/search.py +0 -0
  65. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/tag.py +0 -0
  66. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/tag_entities.py +0 -0
  67. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/user.py +0 -0
  68. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/models/work_item.py +0 -0
  69. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/operating_guide.py +0 -0
  70. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/primer.py +0 -0
  71. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/query_parser.py +0 -0
  72. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/release_notes.py +0 -0
  73. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/__init__.py +0 -0
  74. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/command-synonyms.yaml +0 -0
  75. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/config-template.yaml +0 -0
  76. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/error-messages.yaml +0 -0
  77. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/primers/__init__.py +0 -0
  78. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/primers/hitl.yaml +0 -0
  79. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/resources/sdlc-doctrine.md +0 -0
  80. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd/sdlc.py +0 -0
  81. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd_cli.egg-info/SOURCES.txt +0 -0
  82. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd_cli.egg-info/dependency_links.txt +0 -0
  83. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd_cli.egg-info/entry_points.txt +0 -0
  84. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd_cli.egg-info/requires.txt +0 -0
  85. {kd_cli-2.6.1 → kd_cli-2.6.2}/kd_cli.egg-info/top_level.txt +0 -0
  86. {kd_cli-2.6.1 → kd_cli-2.6.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.6.1
3
+ Version: 2.6.2
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -246,7 +246,12 @@ def state_option(
246
246
  )
247
247
 
248
248
 
249
- # Standard OUTPUT options available to all commands
249
+ # Standard OUTPUT options available to all commands.
250
+ #
251
+ # ``--quiet`` is registered globally so any command's parser accepts it,
252
+ # but it only affects mutation commands that opt into the write-output
253
+ # contract (``display_hint="mutation"``). On reads and structured output
254
+ # modes (``-o yaml`` / ``-o json``), ``--quiet`` is silently no-op.
250
255
  OUTPUT_OPTIONS: list[OptionDeclaration] = [
251
256
  OptionDeclaration(
252
257
  name="output",
@@ -263,4 +268,14 @@ OUTPUT_OPTIONS: list[OptionDeclaration] = [
263
268
  type=str,
264
269
  help_text="Comma-separated field list",
265
270
  ),
271
+ OptionDeclaration(
272
+ name="quiet",
273
+ layer=OptionLayer.OUTPUT,
274
+ type=bool,
275
+ default=False,
276
+ help_text=(
277
+ "Suppress detail block on mutation commands; print only the "
278
+ "single-line ack. No-op on reads and structured modes (-o yaml/json)."
279
+ ),
280
+ ),
266
281
  ]
@@ -324,8 +324,11 @@ def main(argv: list[str] | None = None) -> None:
324
324
  if result is not None:
325
325
  fmt = parsed.options.output.get("output")
326
326
  fields = parsed.options.output.get("fields")
327
+ quiet = bool(parsed.options.output.get("quiet"))
327
328
  display_hint = getattr(parsed.command, "display_hint", None)
328
- output = formatter.format(result, fmt=fmt, fields=fields, display_hint=display_hint)
329
+ output = formatter.format(
330
+ result, fmt=fmt, fields=fields, display_hint=display_hint, quiet=quiet
331
+ )
329
332
  if output:
330
333
  # String results (e.g. /article pull) own their trailing newline.
331
334
  # Everything else goes through print() which appends one newline.
@@ -335,6 +338,11 @@ def main(argv: list[str] | None = None) -> None:
335
338
  sys.stdout.write("\n")
336
339
  else:
337
340
  print(output)
341
+ # Route warnings extracted in text/md modes to stderr so stdout
342
+ # stays parseable for ``tail -1 | awk`` recipes. Structured modes
343
+ # leave warnings in the payload — last_warnings is empty there.
344
+ if formatter.last_warnings:
345
+ print(formatter.render_warnings(formatter.last_warnings), file=sys.stderr)
338
346
 
339
347
 
340
348
  def _extract_globals(args: list[str]) -> tuple[dict[str, Any], list[str]]:
@@ -213,7 +213,16 @@ class ArticlePushCommand(Command):
213
213
  "created/updated and tags are still applied, but the local file "
214
214
  "is not rewritten. Re-running --no-marker-write pays the "
215
215
  "scan-by-slug cost on every invocation since no upstream-id is "
216
- "cached locally."
216
+ "cached locally. "
217
+ "Returns a parseable single-line ack: the last stdout line "
218
+ "is ``ok: <ID> created [slug=<slug> ...]`` for the create "
219
+ "branch or ``ok: <ID> updated [slug=<slug> ...]`` for the "
220
+ "update branch. Dry-run create has no upstream article yet "
221
+ "and ``id`` is null in the structured envelope; the tail "
222
+ "line carries ``dry_run=true`` and is **not "
223
+ "ID-extractable** via ``tail -1 | awk '{print $2}'``. "
224
+ "Drop --dry-run before relying on tail extraction. Pass "
225
+ "--quiet to suppress the detail block above the ack."
217
226
  ),
218
227
  examples=[
219
228
  'kd /article push docs/infra/dns-setup.md --project FB --title "DNS Setup Guide"',
@@ -233,6 +242,7 @@ class ArticlePushCommand(Command):
233
242
  "/article move",
234
243
  ],
235
244
  )
245
+ self.display_hint = "mutation"
236
246
 
237
247
  def _register_options(self) -> None:
238
248
  self.register_option(project_option(help_text="Project key for new articles (required)"))
@@ -345,10 +355,27 @@ class ArticlePushCommand(Command):
345
355
  f"Wrote kd-meta marker to {result.path} (one-time; commit alongside content).",
346
356
  file=sys.stderr,
347
357
  )
358
+ # Map SDK PushResult.action ("create"/"update") to the contract's
359
+ # past-tense vocabulary at the CLI boundary. The SDK shape stays
360
+ # imperative; the contract lives here.
361
+ contract_action = {
362
+ "create": "created",
363
+ "update": "updated",
364
+ }.get(result.action, result.action)
365
+ # Dry-run create has no upstream article yet. The SDK returns
366
+ # ``article_id=""`` (empty string) on that path; the contract
367
+ # normalizes that to ``None`` so the tail-line formatter renders
368
+ # the ``<none>`` placeholder and structured consumers can detect
369
+ # the carve-out via ``id is None``. Production creates and
370
+ # production updates carry the article ID as expected, and
371
+ # dry-run updates carry the existing article ID.
372
+ envelope_id: str | None = result.article_id
373
+ if not envelope_id:
374
+ envelope_id = None
348
375
  return {
349
376
  "ok": True,
350
- "action": result.action,
351
- "article": result.article_id,
377
+ "action": contract_action,
378
+ "id": envelope_id,
352
379
  "slug": result.slug,
353
380
  "path": str(result.path),
354
381
  "dry_run": result.dry_run,
@@ -403,9 +430,14 @@ class ArticleCreateCommand(Command):
403
430
  "two-step in migration scripts. Pass --tag (repeatable) to "
404
431
  "apply tags at creation time: every --tag must exist, the "
405
432
  "article is refused before creation if any tag is missing "
406
- "(no orphan article on unknown tags), and the structured "
407
- "response reflects post-tag state so it matches a "
408
- "subsequent /article show on the returned id."
433
+ "(no orphan article on unknown tags). "
434
+ "Returns a parseable single-line ack. The last stdout line is "
435
+ "``ok: <ID> created [parent=<P>] [tags=<a>,<b>]`` so "
436
+ "``id=$(kd /article create ... --quiet | tail -1 | "
437
+ "awk '{print $2}')`` extracts the new article ID. Pass "
438
+ "--quiet to suppress the detail block; structured output "
439
+ "(-o yaml/json) returns the full envelope including the "
440
+ "submitted summary and project."
409
441
  ),
410
442
  examples=[
411
443
  'kd /article create "Setup Guide" --project FB --content @guide.md',
@@ -416,6 +448,7 @@ class ArticleCreateCommand(Command):
416
448
  ],
417
449
  see_also=["/article show", "/article update", "/article move"],
418
450
  )
451
+ self.display_hint = "mutation"
419
452
 
420
453
  def _register_options(self) -> None:
421
454
  self.register_option(project_option(required=True))
@@ -461,7 +494,19 @@ class ArticleCreateCommand(Command):
461
494
  parent=parent,
462
495
  tags=tags or None,
463
496
  )
464
- return _article_to_detail(article)
497
+ envelope: dict[str, Any] = {
498
+ "ok": True,
499
+ "action": "created",
500
+ "id": article.id_readable,
501
+ "summary": article.summary,
502
+ "project": project_key,
503
+ }
504
+ if parent:
505
+ envelope["parent"] = parent
506
+ applied = [t.name for t in (article.tags or [])]
507
+ if applied:
508
+ envelope["tags"] = applied
509
+ return envelope
465
510
 
466
511
 
467
512
  class ArticleUpdateCommand(Command):
@@ -484,7 +529,16 @@ class ArticleUpdateCommand(Command):
484
529
  "POST. The marker stays at body position 0 regardless of splice "
485
530
  "direction. --content, --append, and --prepend are mutually "
486
531
  "exclusive; --title (alias --summary) stays orthogonal and may "
487
- "be combined with any one of them."
532
+ "be combined with any one of them. "
533
+ "Returns a parseable single-line ack: the last stdout line is "
534
+ "``ok: <ID> updated [<key>=<value> ...]`` listing each field "
535
+ "the operator just changed (title/summary). The structured "
536
+ "envelope also carries ``changed: [...]`` listing every "
537
+ "mutated key — including ``content`` for body-only updates, "
538
+ "which carries no tail-line token. Article body, full custom "
539
+ "field dumps, comments, and tags are dropped — run "
540
+ "/article show for the canonical post-write read. Pass "
541
+ "--quiet to suppress the detail block."
488
542
  ),
489
543
  examples=[
490
544
  'kd /article update FB-A-1 --title "New Title"',
@@ -495,6 +549,7 @@ class ArticleUpdateCommand(Command):
495
549
  ],
496
550
  see_also=["/article show", "/article pull", "/article push"],
497
551
  )
552
+ self.display_hint = "mutation"
498
553
 
499
554
  def _register_options(self) -> None:
500
555
  self.register_option(
@@ -561,7 +616,23 @@ class ArticleUpdateCommand(Command):
561
616
  append=append,
562
617
  prepend=prepend,
563
618
  )
564
- return _article_to_detail(article)
619
+ envelope: dict[str, Any] = {
620
+ "ok": True,
621
+ "action": "updated",
622
+ "id": article.id_readable,
623
+ }
624
+ changed: list[str] = []
625
+ if title:
626
+ envelope["summary"] = title
627
+ changed.append("summary")
628
+ # Body mutations (content / append / prepend) all surface as
629
+ # ``changed: ["content"]`` so structured consumers see a single
630
+ # signal regardless of which body op was used. The body itself is
631
+ # intentionally dropped — /article show is the post-write read.
632
+ if content is not None or append is not None or prepend is not None:
633
+ changed.append("content")
634
+ envelope["changed"] = changed
635
+ return envelope
565
636
 
566
637
 
567
638
  class ArticleDeleteCommand(Command):
@@ -573,9 +644,15 @@ class ArticleDeleteCommand(Command):
573
644
  "Delete an article",
574
645
  requires_client=True,
575
646
  usage="kd /article delete ARTICLE_ID",
647
+ long_description=(
648
+ "Returns a parseable single-line ack: the last stdout line is "
649
+ "``ok: <ID> deleted``. Pass --quiet to suppress the detail "
650
+ "block above the ack."
651
+ ),
576
652
  examples=["kd /article delete FB-A-1"],
577
653
  see_also=["/article list"],
578
654
  )
655
+ self.display_hint = "mutation"
579
656
 
580
657
  def execute(
581
658
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -584,7 +661,7 @@ class ArticleDeleteCommand(Command):
584
661
  raise ValidationError("Usage: kd /article delete ARTICLE_ID")
585
662
  article_id = args[0]
586
663
  client.articles.delete(article_id)
587
- return {"ok": True, "action": "delete", "article": article_id}
664
+ return {"ok": True, "action": "deleted", "id": article_id}
588
665
 
589
666
 
590
667
  # --- Comment Commands ---
@@ -630,12 +707,20 @@ class ArticleCommentAddCommand(Command):
630
707
  "Add a comment to an article",
631
708
  requires_client=True,
632
709
  usage='kd /article/comment add ARTICLE_ID "Text"',
710
+ long_description=(
711
+ "Returns a parseable single-line ack. The tail line is "
712
+ "``ok: <ARTICLE_ID> added comment_id=<id>``: the parent "
713
+ "article ID is the primary tail-line ID (``$2``), and the "
714
+ "new comment's internal ID is in ``comment_id=<id>``. "
715
+ "Pass --quiet to suppress the detail block."
716
+ ),
633
717
  examples=[
634
718
  'kd /article/comment add FB-A-1 "Needs review"',
635
719
  "kd /article/comment add FB-A-1 @note.md",
636
720
  ],
637
721
  see_also=["/article/comment list"],
638
722
  )
723
+ self.display_hint = "mutation"
639
724
 
640
725
  def execute(
641
726
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -646,9 +731,11 @@ class ArticleCommentAddCommand(Command):
646
731
  text = resolve_text_input(" ".join(args[1:]))
647
732
  comment = client.articles.add_comment(article_id, text)
648
733
  return {
649
- "id": comment.id,
734
+ "ok": True,
735
+ "action": "added",
736
+ "id": article_id,
737
+ "comment_id": comment.id,
650
738
  "author": comment.author.name if comment.author else "",
651
- "text": comment.text,
652
739
  }
653
740
 
654
741
 
@@ -698,12 +785,23 @@ class ArticleAttachmentAddCommand(Command):
698
785
  "Add attachments to an article",
699
786
  requires_client=True,
700
787
  usage="kd /article/attachment add ARTICLE_ID FILE [FILE...]",
788
+ long_description=(
789
+ "Returns a parseable single-line ack: the last stdout line is "
790
+ "``ok: <ID> added attachments=<name1>,<name2>,...``. The "
791
+ "structured envelope carries ``attachments: [<name>, ...]`` "
792
+ "(scalar names — the source of the tail-line token) and "
793
+ "``applied: [{name, size, id}, ...]`` (per-file detail). "
794
+ "The shape is consistent for both single-file and "
795
+ "multi-file calls. Pass --quiet to suppress the detail "
796
+ "block above the ack."
797
+ ),
701
798
  examples=[
702
799
  "kd /article/attachment add FB-A-1 diagram.png",
703
800
  "kd /article/attachment add FB-A-1 log.txt report.pdf",
704
801
  ],
705
802
  see_also=["/article/attachment list"],
706
803
  )
804
+ self.display_hint = "mutation"
707
805
 
708
806
  def execute(
709
807
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -713,7 +811,14 @@ class ArticleAttachmentAddCommand(Command):
713
811
  article_id = args[0]
714
812
  file_paths = [Path(p) for p in args[1:]]
715
813
  attachments = client.articles.add_attachments(article_id, file_paths)
716
- return [_attachment_to_row(a) for a in attachments]
814
+ applied = [{"name": a.name, "size": a.size, "id": a.id} for a in attachments]
815
+ return {
816
+ "ok": True,
817
+ "action": "added",
818
+ "id": article_id,
819
+ "attachments": [a.name for a in attachments],
820
+ "applied": applied,
821
+ }
717
822
 
718
823
 
719
824
  class ArticleAttachmentShowCommand(Command):
@@ -794,9 +899,18 @@ class ArticleAttachmentDeleteCommand(Command):
794
899
  "Delete an article attachment",
795
900
  requires_client=True,
796
901
  usage="kd /article/attachment delete ARTICLE_ID ATTACHMENT_ID",
902
+ long_description=(
903
+ "Returns a parseable single-line ack: the last stdout line is "
904
+ "``ok: <ARTICLE_ID> deleted attachment=<ATTACHMENT_ID>``. "
905
+ "The parent article ID is the primary tail-line ID (``$2``); "
906
+ "the removed attachment's internal ID is in "
907
+ "``attachment=<id>``. Pass --quiet to suppress the detail "
908
+ "block above the ack."
909
+ ),
797
910
  examples=["kd /article/attachment delete FB-A-1 att-1"],
798
911
  see_also=["/article/attachment list"],
799
912
  )
913
+ self.display_hint = "mutation"
800
914
 
801
915
  def execute(
802
916
  self, args: list[str], options: ResolvedOptions, client: Any = None
@@ -804,7 +918,7 @@ class ArticleAttachmentDeleteCommand(Command):
804
918
  if len(args) < 2:
805
919
  raise ValidationError("Usage: kd /article/attachment delete ARTICLE_ID ATTACHMENT_ID")
806
920
  client.articles.delete_attachment(args[0], args[1])
807
- return {"ok": True, "action": "delete", "article": args[0], "attachment": args[1]}
921
+ return {"ok": True, "action": "deleted", "id": args[0], "attachment": args[1]}
808
922
 
809
923
 
810
924
  # --- Move Command ---
@@ -819,13 +933,21 @@ class ArticleMoveCommand(Command):
819
933
  "Move an article to a new parent",
820
934
  requires_client=True,
821
935
  usage="kd /article move ARTICLE_ID --parent PARENT_ID",
822
- long_description="Use --parent none to move an article to project root.",
936
+ long_description=(
937
+ "Use --parent none to move an article to project root. "
938
+ "Returns a parseable single-line ack: the last stdout line "
939
+ "is ``ok: <ID> moved parent=<PARENT_ID>`` (or "
940
+ "``ok: <ID> moved parent=root`` when reparented to project "
941
+ "root). Pass --quiet to suppress the detail block above "
942
+ "the ack."
943
+ ),
823
944
  examples=[
824
945
  "kd /article move FB-A-3 --parent FB-A-1",
825
946
  "kd /article move FB-A-3 --parent none",
826
947
  ],
827
948
  see_also=["/article tree", "/article children"],
828
949
  )
950
+ self.display_hint = "mutation"
829
951
 
830
952
  def _register_options(self) -> None:
831
953
  self.register_option(
@@ -847,7 +969,12 @@ class ArticleMoveCommand(Command):
847
969
  article_id = args[0]
848
970
  parent = None if parent_value.lower() == "none" else parent_value
849
971
  article = client.articles.move(article_id, parent=parent)
850
- return _article_to_detail(article)
972
+ return {
973
+ "ok": True,
974
+ "action": "moved",
975
+ "id": article.id_readable,
976
+ "parent": _parent_label(article) if article.parent_article else "root",
977
+ }
851
978
 
852
979
 
853
980
  # --- Tree Command ---