kaiten-cli 0.3.3__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 (99) hide show
  1. kaiten_cli/__init__.py +5 -0
  2. kaiten_cli/__main__.py +9 -0
  3. kaiten_cli/agent_gateway.py +577 -0
  4. kaiten_cli/app.py +1636 -0
  5. kaiten_cli/completion.py +570 -0
  6. kaiten_cli/discovery.py +39 -0
  7. kaiten_cli/errors.py +79 -0
  8. kaiten_cli/i18n.py +92 -0
  9. kaiten_cli/locales/ru.json +2967 -0
  10. kaiten_cli/localized_click.py +160 -0
  11. kaiten_cli/models.py +202 -0
  12. kaiten_cli/profiles.py +316 -0
  13. kaiten_cli/registry/__init__.py +260 -0
  14. kaiten_cli/registry/addons.py +392 -0
  15. kaiten_cli/registry/audit_and_analytics.py +451 -0
  16. kaiten_cli/registry/automation_schema.py +44 -0
  17. kaiten_cli/registry/automation_schemas.json +7417 -0
  18. kaiten_cli/registry/automations.py +342 -0
  19. kaiten_cli/registry/base.py +62 -0
  20. kaiten_cli/registry/blockers.py +320 -0
  21. kaiten_cli/registry/boards.py +520 -0
  22. kaiten_cli/registry/card_relations.py +376 -0
  23. kaiten_cli/registry/card_types.py +363 -0
  24. kaiten_cli/registry/cards.py +1329 -0
  25. kaiten_cli/registry/charts.py +539 -0
  26. kaiten_cli/registry/checklists.py +600 -0
  27. kaiten_cli/registry/columns.py +558 -0
  28. kaiten_cli/registry/comments.py +224 -0
  29. kaiten_cli/registry/custom_directories.py +980 -0
  30. kaiten_cli/registry/custom_properties.py +1460 -0
  31. kaiten_cli/registry/dashboards.py +564 -0
  32. kaiten_cli/registry/documents.py +761 -0
  33. kaiten_cli/registry/external_links.py +128 -0
  34. kaiten_cli/registry/files.py +738 -0
  35. kaiten_cli/registry/github_addon.py +613 -0
  36. kaiten_cli/registry/iterations.py +397 -0
  37. kaiten_cli/registry/lanes.py +205 -0
  38. kaiten_cli/registry/live_contracts.py +361 -0
  39. kaiten_cli/registry/members.py +336 -0
  40. kaiten_cli/registry/module_docs.py +108 -0
  41. kaiten_cli/registry/projects.py +369 -0
  42. kaiten_cli/registry/query.py +129 -0
  43. kaiten_cli/registry/restrictions.py +249 -0
  44. kaiten_cli/registry/roles_and_groups.py +1102 -0
  45. kaiten_cli/registry/scim.py +263 -0
  46. kaiten_cli/registry/service_desk.py +1270 -0
  47. kaiten_cli/registry/snapshot.py +187 -0
  48. kaiten_cli/registry/spaces.py +272 -0
  49. kaiten_cli/registry/subscribers.py +168 -0
  50. kaiten_cli/registry/tags.py +193 -0
  51. kaiten_cli/registry/time_logs.py +353 -0
  52. kaiten_cli/registry/tree.py +352 -0
  53. kaiten_cli/registry/utilities.py +287 -0
  54. kaiten_cli/registry/webhooks.py +265 -0
  55. kaiten_cli/runtime/__init__.py +1 -0
  56. kaiten_cli/runtime/automation_validation.py +115 -0
  57. kaiten_cli/runtime/behaviors.py +787 -0
  58. kaiten_cli/runtime/cache.py +733 -0
  59. kaiten_cli/runtime/client.py +387 -0
  60. kaiten_cli/runtime/endpoints.py +59 -0
  61. kaiten_cli/runtime/executor.py +269 -0
  62. kaiten_cli/runtime/fs_security.py +168 -0
  63. kaiten_cli/runtime/input.py +310 -0
  64. kaiten_cli/runtime/output.py +59 -0
  65. kaiten_cli/runtime/public_validation.py +35 -0
  66. kaiten_cli/runtime/snapshot_build.py +527 -0
  67. kaiten_cli/runtime/snapshot_common.py +363 -0
  68. kaiten_cli/runtime/snapshot_query.py +306 -0
  69. kaiten_cli/runtime/snapshot_store.py +1192 -0
  70. kaiten_cli/runtime/snapshots.py +62 -0
  71. kaiten_cli/runtime/sqlite_errors.py +23 -0
  72. kaiten_cli/runtime/support/__init__.py +1 -0
  73. kaiten_cli/runtime/support/addons.py +953 -0
  74. kaiten_cli/runtime/support/audit.py +70 -0
  75. kaiten_cli/runtime/support/batch.py +234 -0
  76. kaiten_cli/runtime/support/card_move_url.py +213 -0
  77. kaiten_cli/runtime/support/cards.py +174 -0
  78. kaiten_cli/runtime/support/checklists.py +21 -0
  79. kaiten_cli/runtime/support/company_users.py +117 -0
  80. kaiten_cli/runtime/support/dashboards.py +35 -0
  81. kaiten_cli/runtime/support/documents.py +175 -0
  82. kaiten_cli/runtime/support/files.py +561 -0
  83. kaiten_cli/runtime/support/markdown_export.py +525 -0
  84. kaiten_cli/runtime/support/pagination.py +167 -0
  85. kaiten_cli/runtime/support/projects.py +66 -0
  86. kaiten_cli/runtime/support/relations.py +86 -0
  87. kaiten_cli/runtime/support/restrictions.py +62 -0
  88. kaiten_cli/runtime/support/spaces.py +20 -0
  89. kaiten_cli/runtime/support/time_logs.py +61 -0
  90. kaiten_cli/runtime/support/tree.py +140 -0
  91. kaiten_cli/runtime/support/tree_sharing.py +447 -0
  92. kaiten_cli/runtime/trace.py +500 -0
  93. kaiten_cli/runtime/transforms.py +128 -0
  94. kaiten_cli/schema_docs.py +22 -0
  95. kaiten_cli/update_check.py +519 -0
  96. kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
  97. kaiten_cli-0.3.3.dist-info/RECORD +99 -0
  98. kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
  99. kaiten_cli-0.3.3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,525 @@
1
+ """Markdown export helpers for Kaiten cards and documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import urlparse
11
+
12
+ from kaiten_cli.errors import ConfigError, ValidationError
13
+ from kaiten_cli.i18n import tr
14
+ from kaiten_cli.models import DebugReporter, ToolSpec
15
+
16
+ UUID_RE = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
17
+ INTERNAL_FILE_RE = re.compile(
18
+ rf"/(?:files/)?companies/{UUID_RE}/(?P<kind>documents|cards)/(?P<owner>{UUID_RE})/"
19
+ rf"(?P<file>{UUID_RE})(?:\.[^/?#\])]+)?$"
20
+ )
21
+ API_FILE_RE = re.compile(
22
+ rf"^/api(?:/latest)?/(?P<kind>documents|cards)/(?P<owner>[^/]+)/files/(?P<file>{UUID_RE})(?:\.[^/?#\])]+)?$"
23
+ )
24
+ INTERNAL_FILE_TEXT_RE = re.compile(
25
+ rf"(?P<url>(?:https?://[^)\]\s]+)?/(?:files/)?companies/{UUID_RE}/"
26
+ rf"(?P<kind>documents|cards)/(?P<owner>{UUID_RE})/(?P<file>{UUID_RE})(?:\.[^)\]\s]+)?)"
27
+ )
28
+ API_FILE_TEXT_RE = re.compile(
29
+ rf"(?P<url>/api(?:/latest)?/(?P<kind>documents|cards)/(?P<owner>[^/)\]\s]+)/files/"
30
+ rf"(?P<file>{UUID_RE})(?:\.[^)\]\s]+)?)"
31
+ )
32
+
33
+
34
+ def _payload_str(payload: dict[str, Any], key: str) -> str | None:
35
+ value = payload.get(key)
36
+ if value is None:
37
+ return None
38
+ text = str(value).strip()
39
+ return text or None
40
+
41
+
42
+ def _safe_filename(value: str, fallback: str) -> str:
43
+ value = value.strip().lower()
44
+ value = re.sub(r"[^\w\s.-]+", "", value, flags=re.UNICODE)
45
+ value = re.sub(r"[\s/]+", "-", value)
46
+ value = re.sub(r"-+", "-", value).strip("-._")
47
+ return value[:90] or fallback
48
+
49
+
50
+ def _output_path(output: str | None, filename: str) -> Path:
51
+ if output is None:
52
+ return Path.cwd() / filename
53
+ output_path = Path(output).expanduser()
54
+ if output.endswith(("/", os.sep)) or output_path.is_dir():
55
+ return output_path / filename
56
+ return output_path
57
+
58
+
59
+ def _write_text_atomic(path: Path, text: str, *, overwrite: bool) -> None:
60
+ if path.exists() and not overwrite:
61
+ raise ValidationError(
62
+ tr(
63
+ "Output file already exists: {value_0}. Use --overwrite to replace it.",
64
+ value_0=path,
65
+ )
66
+ )
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ part_path = path.with_name(f"{path.name}.part")
69
+ if part_path.exists() and not overwrite:
70
+ raise ValidationError(
71
+ tr(
72
+ "Partial file already exists: {value_0}. Use --overwrite to replace it.",
73
+ value_0=part_path,
74
+ )
75
+ )
76
+ part_path.write_text(text, encoding="utf-8")
77
+ part_path.replace(path)
78
+
79
+
80
+ def _yaml_scalar(value: Any) -> str:
81
+ return json.dumps(value, ensure_ascii=False)
82
+
83
+
84
+ def _frontmatter(entity_type: str, fields: dict[str, Any]) -> str:
85
+ lines = ["---", f"type: {_yaml_scalar(entity_type)}"]
86
+ for key, value in fields.items():
87
+ if value is not None:
88
+ lines.append(f"{key}: {_yaml_scalar(value)}")
89
+ lines.extend(["---", ""])
90
+ return "\n".join(lines)
91
+
92
+
93
+ def _parse_document_data(data: Any) -> dict[str, Any]:
94
+ if isinstance(data, dict):
95
+ return data
96
+ if isinstance(data, str) and data.strip():
97
+ try:
98
+ parsed = json.loads(data)
99
+ except json.JSONDecodeError:
100
+ return {
101
+ "type": "doc",
102
+ "content": [{"type": "paragraph", "content": [{"type": "text", "text": data}]}],
103
+ }
104
+ if isinstance(parsed, dict):
105
+ return parsed
106
+ return {"type": "doc", "content": []}
107
+
108
+
109
+ def _children(node: dict[str, Any]) -> list[dict[str, Any]]:
110
+ content = node.get("content")
111
+ return (
112
+ [child for child in content if isinstance(child, dict)] if isinstance(content, list) else []
113
+ )
114
+
115
+
116
+ def _attrs(node: dict[str, Any]) -> dict[str, Any]:
117
+ attrs = node.get("attrs")
118
+ return attrs if isinstance(attrs, dict) else {}
119
+
120
+
121
+ def _text_content(node: dict[str, Any], *, document_uid: str | None = None) -> str:
122
+ if node.get("type") == "text":
123
+ return str(node.get("text") or "")
124
+ return "".join(_inline(child, document_uid=document_uid) for child in _children(node))
125
+
126
+
127
+ def _escape_table_cell(value: str) -> str:
128
+ return value.replace("|", "\\|").replace("\n", " ").strip()
129
+
130
+
131
+ def _escape_link_text(value: str) -> str:
132
+ return value.replace("[", "\\[").replace("]", "\\]")
133
+
134
+
135
+ def _strip_file_extension(value: Any) -> str:
136
+ text = str(value)
137
+ if re.fullmatch(rf"{UUID_RE}\.[^./\\]+", text):
138
+ return text.rsplit(".", 1)[0]
139
+ return text
140
+
141
+
142
+ def _api_file_link(kind: str, owner: Any, file_id: Any) -> str:
143
+ return f"/api/{kind}/{owner}/files/{_strip_file_extension(file_id)}"
144
+
145
+
146
+ def _normalize_kaiten_file_url(url: str) -> str:
147
+ if not url:
148
+ return url
149
+ parsed = urlparse(url)
150
+ path = parsed.path or url
151
+ if not path.startswith("/"):
152
+ path = f"/{path}"
153
+
154
+ api_match = API_FILE_RE.match(path)
155
+ if api_match is not None:
156
+ return _api_file_link(
157
+ api_match.group("kind"), api_match.group("owner"), api_match.group("file")
158
+ )
159
+
160
+ internal_match = INTERNAL_FILE_RE.search(path)
161
+ if internal_match is not None:
162
+ return _api_file_link(
163
+ internal_match.group("kind"),
164
+ internal_match.group("owner"),
165
+ internal_match.group("file"),
166
+ )
167
+
168
+ return url
169
+
170
+
171
+ def _normalize_file_links_in_markdown(text: str) -> str:
172
+ def repl(match: re.Match[str]) -> str:
173
+ return _api_file_link(match.group("kind"), match.group("owner"), match.group("file"))
174
+
175
+ text = API_FILE_TEXT_RE.sub(repl, text)
176
+ return INTERNAL_FILE_TEXT_RE.sub(repl, text)
177
+
178
+
179
+ def _link_for_file(attrs: dict[str, Any], *, document_uid: str | None) -> str:
180
+ file_id = attrs.get("fileId") or attrs.get("file_id")
181
+ if file_id and document_uid:
182
+ return _api_file_link("documents", document_uid, file_id)
183
+ return _normalize_kaiten_file_url(str(attrs.get("url") or attrs.get("src") or ""))
184
+
185
+
186
+ def _apply_marks(text: str, marks: Any, *, document_uid: str | None) -> str:
187
+ if not isinstance(marks, list):
188
+ return text
189
+ result = text
190
+ link: dict[str, Any] | None = None
191
+ for mark in marks:
192
+ if not isinstance(mark, dict):
193
+ continue
194
+ mark_type = str(mark.get("type") or "")
195
+ attrs = mark.get("attrs") if isinstance(mark.get("attrs"), dict) else {}
196
+ if mark_type in {"bold", "strong"}:
197
+ result = f"**{result}**"
198
+ elif mark_type in {"italic", "em"}:
199
+ result = f"*{result}*"
200
+ elif mark_type in {"strikethrough", "strike"}:
201
+ result = f"~~{result}~~"
202
+ elif mark_type == "code":
203
+ result = f"`{result}`"
204
+ elif mark_type == "underline":
205
+ result = f"++{result}++"
206
+ elif mark_type == "sup":
207
+ result = f"^{result}^"
208
+ elif mark_type == "sub":
209
+ result = f"~{result}~"
210
+ elif mark_type == "link":
211
+ link = attrs
212
+ if link is not None:
213
+ href = link.get("href") or ""
214
+ if link.get("fileId") and link.get("fileEntityType"):
215
+ kind = {"document": "documents", "card": "cards"}.get(str(link["fileEntityType"]))
216
+ if kind == "documents" and document_uid:
217
+ href = _api_file_link(kind, document_uid, link["fileId"])
218
+ elif kind and (link.get("entityId") or link.get("entityUid")):
219
+ href = _api_file_link(
220
+ kind, link.get("entityId") or link.get("entityUid") or "", link["fileId"]
221
+ )
222
+ else:
223
+ href = _normalize_kaiten_file_url(str(href))
224
+ else:
225
+ href = _normalize_kaiten_file_url(str(href))
226
+ title = str(link.get("title") or "")
227
+ escaped_title = title.replace('"', '\\"')
228
+ quoted_title = f' "{escaped_title}"' if title else ""
229
+ result = f"[{_escape_link_text(result)}]({href}{quoted_title})"
230
+ return result
231
+
232
+
233
+ def _inline(node: dict[str, Any], *, document_uid: str | None) -> str:
234
+ node_type = node.get("type")
235
+ if node_type == "text":
236
+ return _apply_marks(
237
+ str(node.get("text") or ""), node.get("marks"), document_uid=document_uid
238
+ )
239
+ if node_type == "hard_break":
240
+ return "\n"
241
+ if node_type == "mention":
242
+ return str(_attrs(node).get("label") or "")
243
+ if node_type == "inline_card_link":
244
+ return str(_attrs(node).get("url") or "")
245
+ if node_type == "image":
246
+ attrs = _attrs(node)
247
+ url = _link_for_file(attrs, document_uid=document_uid)
248
+ alt = str(attrs.get("alt") or attrs.get("title") or attrs.get("name") or tr("image"))
249
+ return f"![{_escape_link_text(alt)}]({url})" if url else alt
250
+ return "".join(_inline(child, document_uid=document_uid) for child in _children(node))
251
+
252
+
253
+ def _render_list(
254
+ node: dict[str, Any],
255
+ *,
256
+ document_uid: str | None,
257
+ ordered: bool = False,
258
+ check_list: bool = False,
259
+ ) -> str:
260
+ lines: list[str] = []
261
+ start = int(_attrs(node).get("order") or 1)
262
+ for index, item in enumerate(_children(node), start=start):
263
+ attrs = _attrs(item)
264
+ if check_list:
265
+ prefix = "- [x] " if attrs.get("checked") else "- [ ] "
266
+ elif ordered:
267
+ prefix = f"{index}. "
268
+ else:
269
+ prefix = "* "
270
+ item_text = _block(item, document_uid=document_uid).strip()
271
+ item_lines = item_text.splitlines() or [""]
272
+ lines.append(f"{prefix}{item_lines[0]}")
273
+ continuation = " " * len(prefix)
274
+ lines.extend(f"{continuation}{line}" for line in item_lines[1:])
275
+ return "\n".join(lines)
276
+
277
+
278
+ def _render_table(node: dict[str, Any], *, document_uid: str | None) -> str:
279
+ rows: list[list[str]] = []
280
+ for row in _children(node):
281
+ if row.get("type") not in {"table_row", "tableRow"}:
282
+ continue
283
+ cells = [
284
+ _escape_table_cell(_text_content(cell, document_uid=document_uid))
285
+ for cell in _children(row)
286
+ ]
287
+ if cells:
288
+ rows.append(cells)
289
+ if not rows:
290
+ return ""
291
+ width = max(len(row) for row in rows)
292
+ rows = [row + [""] * (width - len(row)) for row in rows]
293
+ lines = [
294
+ "| " + " | ".join(rows[0]) + " |",
295
+ "| " + " | ".join("---" for _ in range(width)) + " |",
296
+ ]
297
+ lines.extend("| " + " | ".join(row) + " |" for row in rows[1:])
298
+ return "\n".join(lines)
299
+
300
+
301
+ def _prefix_lines(text: str, prefix: str) -> str:
302
+ return "\n".join(f"{prefix}{line}" if line else prefix.rstrip() for line in text.splitlines())
303
+
304
+
305
+ def _block(node: dict[str, Any], *, document_uid: str | None) -> str:
306
+ node_type = node.get("type")
307
+ attrs = _attrs(node)
308
+
309
+ if node_type == "doc":
310
+ return _blocks(_children(node), document_uid=document_uid)
311
+ if node_type == "paragraph":
312
+ return "".join(
313
+ _inline(child, document_uid=document_uid) for child in _children(node)
314
+ ).strip()
315
+ if node_type in {"heading", "heading1", "heading2", "heading3"}:
316
+ level = int(attrs.get("level") or str(node_type).removeprefix("heading") or 1)
317
+ return f"{'#' * min(max(level, 1), 6)} {_text_content(node, document_uid=document_uid).strip()}".rstrip()
318
+ if node_type == "toggle_heading":
319
+ return f"**{_text_content(node, document_uid=document_uid).strip()}**"
320
+ if node_type in {"bullet_list", "list"}:
321
+ return _render_list(node, document_uid=document_uid)
322
+ if node_type == "ordered_list":
323
+ return _render_list(node, document_uid=document_uid, ordered=True)
324
+ if node_type == "check_list":
325
+ return _render_list(node, document_uid=document_uid, check_list=True)
326
+ if node_type in {
327
+ "list_item",
328
+ "check_list_item",
329
+ "toggle_content",
330
+ "columns",
331
+ "column",
332
+ "toggle",
333
+ }:
334
+ return _blocks(_children(node), document_uid=document_uid)
335
+ if node_type in {"blockquote", "alert"}:
336
+ return _prefix_lines(_blocks(_children(node), document_uid=document_uid), "> ")
337
+ if node_type == "code_block":
338
+ language = attrs.get("language") or attrs.get("params") or ""
339
+ return f"```{language}\n{node.get('text') or _text_content(node, document_uid=document_uid).strip()}\n```"
340
+ if node_type == "horizontal_rule":
341
+ return "---"
342
+ if node_type == "table":
343
+ return _render_table(node, document_uid=document_uid)
344
+ if node_type in {"image", "imageBlock"}:
345
+ rendered = _inline(node, document_uid=document_uid)
346
+ return rendered or _blocks(_children(node), document_uid=document_uid)
347
+ if node_type == "file":
348
+ url = _link_for_file(attrs, document_uid=document_uid)
349
+ label = str(
350
+ attrs.get("name")
351
+ or attrs.get("title")
352
+ or attrs.get("fileName")
353
+ or attrs.get("fileId")
354
+ or tr("file")
355
+ )
356
+ return f"[{_escape_link_text(label)}]({url})" if url else label
357
+ if node_type in {"inline_card_link", "block_card_link"}:
358
+ return str(attrs.get("url") or "")
359
+
360
+ if _children(node):
361
+ return _blocks(_children(node), document_uid=document_uid)
362
+ return ""
363
+
364
+
365
+ def _blocks(nodes: list[dict[str, Any]], *, document_uid: str | None) -> str:
366
+ rendered = [_block(node, document_uid=document_uid).strip() for node in nodes]
367
+ return "\n\n".join(block for block in rendered if block)
368
+
369
+
370
+ def document_to_markdown(document: dict[str, Any]) -> str:
371
+ document_uid = str(document.get("uid") or document.get("id") or "")
372
+ title = str(document.get("title") or document_uid or tr("Document"))
373
+ frontmatter = _frontmatter(
374
+ "document",
375
+ {
376
+ "uid": document.get("uid"),
377
+ "id": document.get("id"),
378
+ "title": title,
379
+ "created": document.get("created"),
380
+ "updated": document.get("updated"),
381
+ "parent_entity_uid": document.get("parent_entity_uid"),
382
+ },
383
+ )
384
+ body = _block(_parse_document_data(document.get("data")), document_uid=document_uid).strip()
385
+ return f"{frontmatter}\n# {title}\n\n{body}\n".rstrip() + "\n"
386
+
387
+
388
+ def _card_file_link(card: dict[str, Any], file_item: dict[str, Any]) -> str | None:
389
+ file_id = file_item.get("id") or file_item.get("uid")
390
+ if file_id is None:
391
+ return None
392
+ card_ref = card.get("uid") or card.get("id")
393
+ if card_ref is None:
394
+ return None
395
+ return _api_file_link("cards", card_ref, file_id)
396
+
397
+
398
+ def card_to_markdown(card: dict[str, Any], files: list[dict[str, Any]]) -> str:
399
+ card_id = card.get("id")
400
+ title = str(card.get("title") or card.get("name") or card_id or tr("Card"))
401
+ frontmatter = _frontmatter(
402
+ "card",
403
+ {
404
+ "id": card_id,
405
+ "uid": card.get("uid"),
406
+ "title": title,
407
+ "created": card.get("created"),
408
+ "updated": card.get("updated"),
409
+ "state": card.get("state"),
410
+ "board_id": card.get("board_id"),
411
+ },
412
+ )
413
+ description = _normalize_file_links_in_markdown(str(card.get("description") or "").strip())
414
+ body_parts = [frontmatter, f"# {title}", description]
415
+ attachment_lines = []
416
+ for item in files:
417
+ url = _card_file_link(card, item)
418
+ if url is None:
419
+ continue
420
+ label = str(item.get("name") or item.get("filename") or item.get("id") or tr("file"))
421
+ attachment_lines.append(f"- [{_escape_link_text(label)}]({url})")
422
+ if attachment_lines:
423
+ body_parts.extend(["## " + tr("Attachments"), "\n".join(attachment_lines)])
424
+ return "\n\n".join(part for part in body_parts if part).rstrip() + "\n"
425
+
426
+
427
+ def validate_card_get_markdown_options(tool: ToolSpec, payload: dict[str, Any]) -> None:
428
+ del tool
429
+ if payload.get("markdown") and payload.get("fields"):
430
+ raise ValidationError(tr("--fields cannot be combined with --markdown."))
431
+
432
+
433
+ def _entity_filename(title: str, identifier: Any, fallback: str) -> str:
434
+ slug = _safe_filename(title, fallback)
435
+ suffix = _safe_filename(str(identifier), fallback)
436
+ return f"{slug}--{suffix}.md"
437
+
438
+
439
+ def _write_markdown_result(
440
+ *,
441
+ text: str,
442
+ output: str | None,
443
+ filename: str,
444
+ overwrite: bool,
445
+ metadata: dict[str, Any],
446
+ ) -> dict[str, Any]:
447
+ path = _output_path(output, filename)
448
+ _write_text_atomic(path, text, overwrite=overwrite)
449
+ return {"path": str(path), "bytes": path.stat().st_size, **metadata}
450
+
451
+
452
+ async def execute_document_get(
453
+ client: Any,
454
+ tool: ToolSpec,
455
+ payload: dict[str, Any],
456
+ path: str,
457
+ query: dict[str, Any] | None,
458
+ body: dict[str, Any] | None,
459
+ timeout: float,
460
+ reporter: DebugReporter | None,
461
+ ) -> dict[str, Any]:
462
+ del tool, body, reporter
463
+ if client is None:
464
+ raise ConfigError(tr("This command requires a Kaiten profile."))
465
+ document = await client.get(path, params=query, timeout=timeout)
466
+ if not isinstance(document, dict):
467
+ raise ValidationError(tr("Kaiten returned an unexpected document payload."))
468
+ if not payload.get("markdown"):
469
+ return document
470
+ document_uid = _payload_str(payload, "document_uid")
471
+ if document_uid is None:
472
+ raise ValidationError(tr("Missing required field: document_uid."))
473
+ text = document_to_markdown(document)
474
+ filename = _entity_filename(str(document.get("title") or "document"), document_uid, "document")
475
+ return _write_markdown_result(
476
+ text=text,
477
+ output=_payload_str(payload, "output"),
478
+ filename=filename,
479
+ overwrite=bool(payload.get("overwrite", False)),
480
+ metadata={
481
+ "entity_type": "document",
482
+ "document_uid": document_uid,
483
+ "title": document.get("title"),
484
+ },
485
+ )
486
+
487
+
488
+ async def execute_card_get(
489
+ client: Any,
490
+ tool: ToolSpec,
491
+ payload: dict[str, Any],
492
+ path: str,
493
+ query: dict[str, Any] | None,
494
+ body: dict[str, Any] | None,
495
+ timeout: float,
496
+ reporter: DebugReporter | None,
497
+ ) -> dict[str, Any]:
498
+ del tool, body, reporter
499
+ if client is None:
500
+ raise ConfigError(tr("This command requires a Kaiten profile."))
501
+ card_id = _payload_str(payload, "card_id")
502
+ if card_id is None:
503
+ raise ValidationError(tr("Missing required field: card_id."))
504
+ card = await client.get(path, params=query, timeout=timeout)
505
+ if not isinstance(card, dict):
506
+ raise ValidationError(tr("Kaiten returned an unexpected card payload."))
507
+ if not payload.get("markdown"):
508
+ return card
509
+ card_ref = card.get("uid") or card.get("id") or card_id
510
+ files_payload = await client.get(f"/cards/{card_ref}/files", timeout=timeout)
511
+ files = files_payload if isinstance(files_payload, list) else []
512
+ text = card_to_markdown(card, files)
513
+ filename = _entity_filename(str(card.get("title") or "card"), card.get("id") or card_id, "card")
514
+ return _write_markdown_result(
515
+ text=text,
516
+ output=_payload_str(payload, "output"),
517
+ filename=filename,
518
+ overwrite=bool(payload.get("overwrite", False)),
519
+ metadata={
520
+ "entity_type": "card",
521
+ "card_id": card_id,
522
+ "title": card.get("title"),
523
+ "attachment_count": len(files),
524
+ },
525
+ )
@@ -0,0 +1,167 @@
1
+ """Shared bounded offset pagination for Kaiten collection endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from kaiten_cli.errors import ConfigError, ValidationError
10
+ from kaiten_cli.i18n import tr
11
+
12
+ API_MAX_PAGE_SIZE = 100
13
+ DEFAULT_COLLECTION_MAX_PAGES = 100
14
+ MAX_COLLECTION_MAX_PAGES = 1000
15
+ LEGACY_PAGINATION_MODE = "legacy_unpaginated"
16
+ LEGACY_OVERSIZED_FIRST_PAGE = "oversized_first_page"
17
+ LEGACY_REPEATED_FIRST_PAGE = "repeated_first_page"
18
+ REPEATED_PAGE_AFTER_PROGRESS = "repeated_page_after_progress"
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class OffsetPageGuard:
23
+ """Classify legacy unpaginated responses and unsafe page repetition."""
24
+
25
+ page_size: int
26
+ pages: list[list[Any]] = field(default_factory=list)
27
+
28
+ def observe(self, page: list[Any], *, page_index: int) -> str | None:
29
+ if page_index == 0 and len(page) > self.page_size:
30
+ return LEGACY_OVERSIZED_FIRST_PAGE
31
+ if page_index == 1 and self.pages and page == self.pages[0]:
32
+ return LEGACY_REPEATED_FIRST_PAGE
33
+ if any(page == previous for previous in self.pages):
34
+ return REPEATED_PAGE_AFTER_PROGRESS
35
+ self.pages.append(page)
36
+ return None
37
+
38
+
39
+ def report_legacy_pagination(
40
+ client,
41
+ *,
42
+ path: str,
43
+ reason: str,
44
+ rows: int,
45
+ reporter=None,
46
+ ) -> None:
47
+ """Expose a legacy-mode decision through verbose output and command traces."""
48
+ message = f"pagination: path={path} mode={LEGACY_PAGINATION_MODE} reason={reason} rows={rows}"
49
+ if reporter is not None:
50
+ reporter(message)
51
+
52
+ context = getattr(client, "execution_context", None)
53
+ stats = getattr(context, "stats", None)
54
+ record = getattr(stats, "record_pagination_compatibility", None)
55
+ if callable(record):
56
+ record(path=path, mode=LEGACY_PAGINATION_MODE, reason=reason, rows=rows)
57
+
58
+
59
+ def validate_page_bounds(
60
+ *,
61
+ page_size: int,
62
+ max_pages: int,
63
+ max_page_size: int = API_MAX_PAGE_SIZE,
64
+ max_allowed_pages: int = MAX_COLLECTION_MAX_PAGES,
65
+ ) -> None:
66
+ """Defensively validate bounds used by internal pagination helpers."""
67
+ if isinstance(page_size, bool) or not isinstance(page_size, int):
68
+ raise ValidationError(tr("Field page_size must be an integer."))
69
+ if not 1 <= page_size <= max_page_size:
70
+ raise ValidationError(
71
+ tr("Field page_size must be between 1 and {value_0}.", value_0=max_page_size)
72
+ )
73
+ if isinstance(max_pages, bool) or not isinstance(max_pages, int):
74
+ raise ValidationError(tr("Field max_pages must be an integer."))
75
+ if not 1 <= max_pages <= max_allowed_pages:
76
+ raise ValidationError(
77
+ tr("Field max_pages must be between 1 and {value_0}.", value_0=max_allowed_pages)
78
+ )
79
+
80
+
81
+ def list_page_items(response: Any, *, path: str) -> list[Any]:
82
+ """Require the list response shape documented for offset-paginated endpoints."""
83
+ if isinstance(response, list):
84
+ return response
85
+ raise ConfigError(
86
+ tr("{value_0} pagination expected a list response from Kaiten.", value_0=path)
87
+ )
88
+
89
+
90
+ async def fetch_all_offset_pages(
91
+ client,
92
+ path: str,
93
+ *,
94
+ timeout: float,
95
+ params: dict[str, Any] | None = None,
96
+ page_size: int = API_MAX_PAGE_SIZE,
97
+ max_pages: int = DEFAULT_COLLECTION_MAX_PAGES,
98
+ extract_items: Callable[[Any], list[Any]] | None = None,
99
+ reporter=None,
100
+ ) -> list[Any]:
101
+ """Fetch a complete collection or fail instead of returning a silent truncation."""
102
+ validate_page_bounds(page_size=page_size, max_pages=max_pages)
103
+ base_params = dict(params or {})
104
+ rows: list[Any] = []
105
+ guard = OffsetPageGuard(page_size=page_size)
106
+
107
+ if reporter is not None:
108
+ reporter(
109
+ tr(
110
+ "pagination: path={value_0} page_size={value_1} max_pages={value_2}",
111
+ value_0=path,
112
+ value_1=page_size,
113
+ value_2=max_pages,
114
+ )
115
+ )
116
+
117
+ for page_index in range(max_pages):
118
+ page_params = {
119
+ **base_params,
120
+ "limit": page_size,
121
+ "offset": page_index * page_size,
122
+ }
123
+ response = await client.get(path, params=page_params, timeout=timeout)
124
+ page = (
125
+ extract_items(response)
126
+ if extract_items is not None
127
+ else list_page_items(response, path=path)
128
+ )
129
+ page_state = guard.observe(page, page_index=page_index)
130
+ if page_state == LEGACY_OVERSIZED_FIRST_PAGE:
131
+ report_legacy_pagination(
132
+ client,
133
+ path=path,
134
+ reason=page_state,
135
+ rows=len(page),
136
+ reporter=reporter,
137
+ )
138
+ return page
139
+ if page_state == LEGACY_REPEATED_FIRST_PAGE:
140
+ report_legacy_pagination(
141
+ client,
142
+ path=path,
143
+ reason=page_state,
144
+ rows=len(rows),
145
+ reporter=reporter,
146
+ )
147
+ return rows
148
+ if page_state == REPEATED_PAGE_AFTER_PROGRESS:
149
+ raise ConfigError(
150
+ tr(
151
+ "{value_0} pagination repeated a previously received page after progress; refusing to return a possibly duplicated or truncated result.",
152
+ value_0=path,
153
+ )
154
+ )
155
+ rows.extend(page)
156
+ if len(page) < page_size:
157
+ return rows
158
+
159
+ raise ConfigError(
160
+ tr(
161
+ "{value_0} pagination reached {value_1} full pages of {value_2} rows; refusing to return a possibly truncated result ({value_3} rows read). Increase max_pages after reviewing the expected collection size.",
162
+ value_0=path,
163
+ value_1=max_pages,
164
+ value_2=page_size,
165
+ value_3=len(rows),
166
+ )
167
+ )