deepflow-openagent-cli 0.1.4__tar.gz → 0.1.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepflow-openagent-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Command-line client for OpenAgent OpenAPI (agents, conversations, SSE chat, tools, channels, knowledge bases).
5
5
  Author: DeepFlow AI
6
6
  Project-URL: Repository, https://github.com/DeepFlowAI/OpenAgent
@@ -66,6 +66,21 @@ deepflow_openagent_cli docs show chat.stream --json
66
66
  deepflow_openagent_cli knowledge list --page 1 --per-page 10
67
67
  deepflow_openagent_cli knowledge documents 1
68
68
  deepflow_openagent_cli knowledge query-document 6670 --slice-id 512208 --line 1720
69
+
70
+ # Knowledge-base file library (requires config scope)
71
+ deepflow_openagent_cli knowledge directories create 1 --name "Policies"
72
+ deepflow_openagent_cli knowledge files create 1 \
73
+ --directory-id 2 --name "Product Manual" --custom-version v1 \
74
+ --original ./manual.pdf --markdown ./manual.md
75
+ deepflow_openagent_cli knowledge files list 1 --markdown-filter has-missing
76
+ deepflow_openagent_cli knowledge versions upload 1 3 \
77
+ --custom-version v2 --original ./manual-v2.pdf
78
+ deepflow_openagent_cli knowledge versions attach-markdown 1 3 4 \
79
+ --markdown ./manual-v2.md
80
+ deepflow_openagent_cli knowledge versions set-current 1 3 4
81
+ deepflow_openagent_cli knowledge versions original 1 3 4 --output ./manual-v2.pdf
82
+ deepflow_openagent_cli knowledge versions markdown 1 3 4 --output ./manual-v2.md
83
+
69
84
  deepflow_openagent_cli chat 1 --message "Hello"
70
85
  deepflow_openagent_cli steps 1 42
71
86
  deepflow_openagent_cli steps get 1 42 100
@@ -45,6 +45,21 @@ deepflow_openagent_cli docs show chat.stream --json
45
45
  deepflow_openagent_cli knowledge list --page 1 --per-page 10
46
46
  deepflow_openagent_cli knowledge documents 1
47
47
  deepflow_openagent_cli knowledge query-document 6670 --slice-id 512208 --line 1720
48
+
49
+ # Knowledge-base file library (requires config scope)
50
+ deepflow_openagent_cli knowledge directories create 1 --name "Policies"
51
+ deepflow_openagent_cli knowledge files create 1 \
52
+ --directory-id 2 --name "Product Manual" --custom-version v1 \
53
+ --original ./manual.pdf --markdown ./manual.md
54
+ deepflow_openagent_cli knowledge files list 1 --markdown-filter has-missing
55
+ deepflow_openagent_cli knowledge versions upload 1 3 \
56
+ --custom-version v2 --original ./manual-v2.pdf
57
+ deepflow_openagent_cli knowledge versions attach-markdown 1 3 4 \
58
+ --markdown ./manual-v2.md
59
+ deepflow_openagent_cli knowledge versions set-current 1 3 4
60
+ deepflow_openagent_cli knowledge versions original 1 3 4 --output ./manual-v2.pdf
61
+ deepflow_openagent_cli knowledge versions markdown 1 3 4 --output ./manual-v2.md
62
+
48
63
  deepflow_openagent_cli chat 1 --message "Hello"
49
64
  deepflow_openagent_cli steps 1 42
50
65
  deepflow_openagent_cli steps get 1 42 100
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepflow-openagent-cli"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "Command-line client for OpenAgent OpenAPI (agents, conversations, SSE chat, tools, channels, knowledge bases)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """DeepFlow OpenAgent CLI — published PyPI package."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.5"
@@ -9,14 +9,18 @@ from __future__ import annotations
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import mimetypes
12
13
  import os
13
14
  import sys
15
+ import tempfile
16
+ from collections.abc import Iterator
14
17
  from dataclasses import asdict, dataclass, field
15
18
  from pathlib import Path
16
19
  from typing import Any
17
20
  from urllib.error import HTTPError, URLError
18
21
  from urllib.parse import urlencode
19
22
  from urllib.request import Request, urlopen
23
+ from uuid import uuid4
20
24
 
21
25
 
22
26
  DEFAULT_HOST = "http://localhost:5001"
@@ -53,6 +57,72 @@ class EndpointDoc:
53
57
  example: str = ""
54
58
 
55
59
 
60
+ class MultipartBody:
61
+ """Replayable multipart body that streams file content in bounded chunks."""
62
+
63
+ chunk_size = 1024 * 1024
64
+
65
+ def __init__(
66
+ self,
67
+ fields: dict[str, str | int | None],
68
+ files: dict[str, Path | None],
69
+ ) -> None:
70
+ self.boundary = f"openagent-{uuid4().hex}"
71
+ self._parts: list[bytes | Path] = []
72
+ boundary = self.boundary.encode("ascii")
73
+ for name, value in fields.items():
74
+ if value is None:
75
+ continue
76
+ self._parts.extend(
77
+ [
78
+ b"--" + boundary + b"\r\n",
79
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(
80
+ "utf-8"
81
+ ),
82
+ str(value).encode("utf-8"),
83
+ b"\r\n",
84
+ ]
85
+ )
86
+ for name, path in files.items():
87
+ if path is None:
88
+ continue
89
+ resolved = path.expanduser()
90
+ if not resolved.is_file():
91
+ raise CliError(f"Upload file does not exist: {path}")
92
+ content_type = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream"
93
+ escaped_name = resolved.name.replace('"', "")
94
+ self._parts.extend(
95
+ [
96
+ b"--" + boundary + b"\r\n",
97
+ (
98
+ f'Content-Disposition: form-data; name="{name}"; '
99
+ f'filename="{escaped_name}"\r\n'
100
+ f"Content-Type: {content_type}\r\n\r\n"
101
+ ).encode("utf-8"),
102
+ resolved,
103
+ b"\r\n",
104
+ ]
105
+ )
106
+ self._parts.append(b"--" + boundary + b"--\r\n")
107
+ self.content_length = sum(
108
+ part.stat().st_size if isinstance(part, Path) else len(part)
109
+ for part in self._parts
110
+ )
111
+
112
+ @property
113
+ def content_type(self) -> str:
114
+ return f"multipart/form-data; boundary={self.boundary}"
115
+
116
+ def __iter__(self) -> Iterator[bytes]:
117
+ for part in self._parts:
118
+ if isinstance(part, Path):
119
+ with part.open("rb") as file_object:
120
+ while chunk := file_object.read(self.chunk_size):
121
+ yield chunk
122
+ else:
123
+ yield part
124
+
125
+
56
126
  def _param(name: str, type_: str, required: bool, description: str) -> ParamDoc:
57
127
  return ParamDoc(name=name, type=type_, required=required, description=description)
58
128
 
@@ -121,6 +191,326 @@ ENDPOINTS: tuple[EndpointDoc, ...] = (
121
191
  errors=["401 missing or invalid API key", "403 scope missing", "404 knowledge base not found", "422 validation error"],
122
192
  example="deepflow_openagent_cli knowledge documents 1 --page 1 --per-page 20",
123
193
  ),
194
+ EndpointDoc(
195
+ key="knowledge.file_directories.list",
196
+ method="GET",
197
+ path="/api/v1/knowledge-bases/{kb_id}/file-directories",
198
+ scope="config",
199
+ summary="List the file-library directory tree.",
200
+ path_params=[_param("kb_id", "integer", True, "Knowledge base ID.")],
201
+ response="Directory list and total file count.",
202
+ errors=["401 invalid API key", "403 config scope missing", "404 knowledge base not found"],
203
+ example="deepflow_openagent_cli knowledge directories list 1",
204
+ ),
205
+ EndpointDoc(
206
+ key="knowledge.file_directories.create",
207
+ method="POST",
208
+ path="/api/v1/knowledge-bases/{kb_id}/file-directories",
209
+ scope="config",
210
+ summary="Create a file-library directory.",
211
+ path_params=[_param("kb_id", "integer", True, "Knowledge base ID.")],
212
+ body_fields=[
213
+ _param("name", "string", True, "Directory name, 1-50 chars."),
214
+ _param("parent_id", "integer", False, "Parent directory ID."),
215
+ ],
216
+ response="Created directory.",
217
+ errors=["400 validation error", "401 invalid API key", "403 config scope missing", "404 knowledge base not found"],
218
+ example="deepflow_openagent_cli knowledge directories create 1 --name Policies",
219
+ ),
220
+ EndpointDoc(
221
+ key="knowledge.file_directories.update",
222
+ method="PUT",
223
+ path="/api/v1/knowledge-bases/{kb_id}/file-directories/{directory_id}",
224
+ scope="config",
225
+ summary="Rename, move, or reorder a file-library directory.",
226
+ path_params=[
227
+ _param("kb_id", "integer", True, "Knowledge base ID."),
228
+ _param("directory_id", "integer", True, "Directory ID."),
229
+ ],
230
+ body_fields=[
231
+ _param("name", "string", False, "Directory name, 1-50 chars."),
232
+ _param("parent_id", "integer|null", False, "Parent directory ID."),
233
+ _param("sort_order", "integer", False, "Non-negative sort order."),
234
+ ],
235
+ response="Updated directory.",
236
+ errors=["400 validation error", "401 invalid API key", "403 config scope missing", "404 directory not found"],
237
+ example="deepflow_openagent_cli knowledge directories update 1 2 --name Regulations",
238
+ ),
239
+ EndpointDoc(
240
+ key="knowledge.file_directories.delete",
241
+ method="DELETE",
242
+ path="/api/v1/knowledge-bases/{kb_id}/file-directories/{directory_id}",
243
+ scope="config",
244
+ summary="Delete an empty file-library directory.",
245
+ path_params=[
246
+ _param("kb_id", "integer", True, "Knowledge base ID."),
247
+ _param("directory_id", "integer", True, "Directory ID."),
248
+ ],
249
+ response="Deletion confirmation.",
250
+ errors=["400 directory not empty", "401 invalid API key", "403 config scope missing", "404 directory not found"],
251
+ example="deepflow_openagent_cli knowledge directories delete 1 2",
252
+ ),
253
+ EndpointDoc(
254
+ key="knowledge.files.list",
255
+ method="GET",
256
+ path="/api/v1/knowledge-bases/{kb_id}/files",
257
+ scope="config",
258
+ summary="List and filter file-library file objects.",
259
+ path_params=[_param("kb_id", "integer", True, "Knowledge base ID.")],
260
+ query_params=[
261
+ _param("directory_id", "integer", False, "Directory ID."),
262
+ _param("search", "string", False, "Name, description, or version search."),
263
+ _param("markdown", "string", False, "all, missing-current, has-missing, or complete."),
264
+ _param("page", "integer", False, "Page number."),
265
+ _param("per_page", "integer", False, "Items per page, 1-50."),
266
+ ],
267
+ response="Paginated file objects with current version and version counts.",
268
+ errors=["401 invalid API key", "403 config scope missing", "404 knowledge base not found", "422 validation error"],
269
+ example="deepflow_openagent_cli knowledge files list 1 --markdown-filter has-missing",
270
+ ),
271
+ EndpointDoc(
272
+ key="knowledge.files.create",
273
+ method="POST",
274
+ path="/api/v1/knowledge-bases/{kb_id}/files",
275
+ scope="config",
276
+ summary="Create a file object and its first version using multipart upload.",
277
+ path_params=[_param("kb_id", "integer", True, "Knowledge base ID.")],
278
+ body_fields=[
279
+ _param("directory_id", "integer", True, "Directory ID."),
280
+ _param("name", "string", True, "File object name, 1-200 chars."),
281
+ _param("custom_version", "string", True, "Business version, 1-100 chars."),
282
+ _param("description", "string", False, "Description, max 500 chars."),
283
+ _param("note", "string", False, "Version note, max 200 chars."),
284
+ _param("original", "file", True, "Original file."),
285
+ _param("markdown", "file", False, "Optional Markdown file."),
286
+ ],
287
+ response="Created file detail.",
288
+ errors=["400 validation or duplicate error", "401 invalid API key", "403 config scope missing", "404 directory not found", "413 file too large"],
289
+ example="deepflow_openagent_cli knowledge files create 1 --directory-id 2 --name Manual --custom-version v1 --original manual.pdf",
290
+ ),
291
+ EndpointDoc(
292
+ key="knowledge.files.get",
293
+ method="GET",
294
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}",
295
+ scope="config",
296
+ summary="Get a file object and filtered version history.",
297
+ path_params=[
298
+ _param("kb_id", "integer", True, "Knowledge base ID."),
299
+ _param("file_id", "integer", True, "File object ID."),
300
+ ],
301
+ query_params=[_param("markdown", "string", False, "Version Markdown filter.")],
302
+ response="File detail and version history.",
303
+ errors=["401 invalid API key", "403 config scope missing", "404 file not found"],
304
+ example="deepflow_openagent_cli knowledge files get 1 3 --markdown-filter missing",
305
+ ),
306
+ EndpointDoc(
307
+ key="knowledge.files.update",
308
+ method="PUT",
309
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}",
310
+ scope="config",
311
+ summary="Rename, describe, or move a file object.",
312
+ path_params=[
313
+ _param("kb_id", "integer", True, "Knowledge base ID."),
314
+ _param("file_id", "integer", True, "File object ID."),
315
+ ],
316
+ body_fields=[
317
+ _param("name", "string", False, "File object name."),
318
+ _param("description", "string|null", False, "Description."),
319
+ _param("directory_id", "integer", False, "Target directory ID."),
320
+ ],
321
+ response="Updated file detail.",
322
+ errors=["400 validation or duplicate error", "401 invalid API key", "403 config scope missing", "404 file not found"],
323
+ example="deepflow_openagent_cli knowledge files update 1 3 --name NewManual",
324
+ ),
325
+ EndpointDoc(
326
+ key="knowledge.files.delete",
327
+ method="DELETE",
328
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}",
329
+ scope="config",
330
+ summary="Delete a file object and all versions.",
331
+ path_params=[
332
+ _param("kb_id", "integer", True, "Knowledge base ID."),
333
+ _param("file_id", "integer", True, "File object ID."),
334
+ ],
335
+ response="Deletion confirmation.",
336
+ errors=["401 invalid API key", "403 config scope missing", "404 file not found"],
337
+ example="deepflow_openagent_cli knowledge files delete 1 3",
338
+ ),
339
+ EndpointDoc(
340
+ key="knowledge.file_versions.create",
341
+ method="POST",
342
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions",
343
+ scope="config",
344
+ summary="Upload a new version using multipart upload.",
345
+ path_params=[
346
+ _param("kb_id", "integer", True, "Knowledge base ID."),
347
+ _param("file_id", "integer", True, "File object ID."),
348
+ ],
349
+ body_fields=[
350
+ _param("custom_version", "string", True, "Business version."),
351
+ _param("note", "string", False, "Version note."),
352
+ _param("original", "file", True, "Original file."),
353
+ _param("markdown", "file", False, "Optional Markdown file."),
354
+ ],
355
+ response="Created version detail.",
356
+ errors=["400 validation or duplicate error", "401 invalid API key", "403 config scope missing", "404 file not found", "413 file too large"],
357
+ example="deepflow_openagent_cli knowledge versions upload 1 3 --custom-version v2 --original manual-v2.pdf",
358
+ ),
359
+ EndpointDoc(
360
+ key="knowledge.file_versions.get",
361
+ method="GET",
362
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}",
363
+ scope="config",
364
+ summary="Get one file version.",
365
+ path_params=[
366
+ _param("kb_id", "integer", True, "Knowledge base ID."),
367
+ _param("file_id", "integer", True, "File object ID."),
368
+ _param("version_id", "integer", True, "Version ID."),
369
+ ],
370
+ response="Version detail.",
371
+ errors=["401 invalid API key", "403 config scope missing", "404 version not found"],
372
+ example="deepflow_openagent_cli knowledge versions get 1 3 4",
373
+ ),
374
+ EndpointDoc(
375
+ key="knowledge.file_versions.update",
376
+ method="PUT",
377
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}",
378
+ scope="config",
379
+ summary="Update a custom version label or note.",
380
+ path_params=[
381
+ _param("kb_id", "integer", True, "Knowledge base ID."),
382
+ _param("file_id", "integer", True, "File object ID."),
383
+ _param("version_id", "integer", True, "Version ID."),
384
+ ],
385
+ body_fields=[
386
+ _param("custom_version", "string", False, "Business version."),
387
+ _param("note", "string|null", False, "Version note."),
388
+ ],
389
+ response="Updated version detail.",
390
+ errors=["400 validation or duplicate error", "401 invalid API key", "403 config scope missing", "404 version not found"],
391
+ example="deepflow_openagent_cli knowledge versions update 1 3 4 --custom-version v2.1",
392
+ ),
393
+ EndpointDoc(
394
+ key="knowledge.file_versions.delete",
395
+ method="DELETE",
396
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}",
397
+ scope="config",
398
+ summary="Delete a non-current file version.",
399
+ path_params=[
400
+ _param("kb_id", "integer", True, "Knowledge base ID."),
401
+ _param("file_id", "integer", True, "File object ID."),
402
+ _param("version_id", "integer", True, "Version ID."),
403
+ ],
404
+ response="Deletion confirmation.",
405
+ errors=["401 invalid API key", "403 config scope missing", "404 version not found", "409 current version conflict"],
406
+ example="deepflow_openagent_cli knowledge versions delete 1 3 4",
407
+ ),
408
+ EndpointDoc(
409
+ key="knowledge.file_versions.original",
410
+ method="GET",
411
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}/original",
412
+ scope="config",
413
+ summary="Download the original file for a version.",
414
+ path_params=[
415
+ _param("kb_id", "integer", True, "Knowledge base ID."),
416
+ _param("file_id", "integer", True, "File object ID."),
417
+ _param("version_id", "integer", True, "Version ID."),
418
+ ],
419
+ response="Original file bytes.",
420
+ errors=["401 invalid API key", "403 config scope missing", "404 version not found"],
421
+ example="deepflow_openagent_cli knowledge versions original 1 3 4 --output manual.pdf",
422
+ ),
423
+ EndpointDoc(
424
+ key="knowledge.file_versions.attach_markdown",
425
+ method="POST",
426
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}/markdown",
427
+ scope="config",
428
+ summary="Attach or replace a version Markdown file.",
429
+ path_params=[
430
+ _param("kb_id", "integer", True, "Knowledge base ID."),
431
+ _param("file_id", "integer", True, "File object ID."),
432
+ _param("version_id", "integer", True, "Version ID."),
433
+ ],
434
+ body_fields=[_param("markdown", "file", True, "Markdown file.")],
435
+ response="Updated version detail with processing status.",
436
+ errors=["400 invalid Markdown", "401 invalid API key", "403 config scope missing", "404 version not found", "413 file too large"],
437
+ example="deepflow_openagent_cli knowledge versions attach-markdown 1 3 4 --markdown manual.md",
438
+ ),
439
+ EndpointDoc(
440
+ key="knowledge.file_versions.markdown",
441
+ method="GET",
442
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}/markdown",
443
+ scope="config",
444
+ summary="Download the bound Markdown file.",
445
+ path_params=[
446
+ _param("kb_id", "integer", True, "Knowledge base ID."),
447
+ _param("file_id", "integer", True, "File object ID."),
448
+ _param("version_id", "integer", True, "Version ID."),
449
+ ],
450
+ response="Markdown file bytes.",
451
+ errors=["401 invalid API key", "403 config scope missing", "404 Markdown not found"],
452
+ example="deepflow_openagent_cli knowledge versions markdown 1 3 4 --output manual.md",
453
+ ),
454
+ EndpointDoc(
455
+ key="knowledge.file_versions.remove_markdown",
456
+ method="DELETE",
457
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}/markdown",
458
+ scope="config",
459
+ summary="Remove the bound Markdown and parsed content.",
460
+ path_params=[
461
+ _param("kb_id", "integer", True, "Knowledge base ID."),
462
+ _param("file_id", "integer", True, "File object ID."),
463
+ _param("version_id", "integer", True, "Version ID."),
464
+ ],
465
+ response="Updated version detail.",
466
+ errors=["401 invalid API key", "403 config scope missing", "404 version not found"],
467
+ example="deepflow_openagent_cli knowledge versions remove-markdown 1 3 4",
468
+ ),
469
+ EndpointDoc(
470
+ key="knowledge.file_versions.reprocess",
471
+ method="POST",
472
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}/reprocess",
473
+ scope="config",
474
+ summary="Reprocess the bound Markdown.",
475
+ path_params=[
476
+ _param("kb_id", "integer", True, "Knowledge base ID."),
477
+ _param("file_id", "integer", True, "File object ID."),
478
+ _param("version_id", "integer", True, "Version ID."),
479
+ ],
480
+ response="Updated version detail with processing status.",
481
+ errors=["400 Markdown missing", "401 invalid API key", "403 config scope missing", "404 version not found"],
482
+ example="deepflow_openagent_cli knowledge versions reprocess 1 3 4",
483
+ ),
484
+ EndpointDoc(
485
+ key="knowledge.file_versions.current",
486
+ method="POST",
487
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}/current",
488
+ scope="config",
489
+ summary="Set a version as the current searchable version.",
490
+ path_params=[
491
+ _param("kb_id", "integer", True, "Knowledge base ID."),
492
+ _param("file_id", "integer", True, "File object ID."),
493
+ _param("version_id", "integer", True, "Version ID."),
494
+ ],
495
+ response="Updated current version detail.",
496
+ errors=["401 invalid API key", "403 config scope missing", "404 version not found"],
497
+ example="deepflow_openagent_cli knowledge versions set-current 1 3 4",
498
+ ),
499
+ EndpointDoc(
500
+ key="knowledge.file_versions.content",
501
+ method="GET",
502
+ path="/api/v1/knowledge-bases/{kb_id}/files/{file_id}/versions/{version_id}/content",
503
+ scope="config",
504
+ summary="Get parsed Markdown, metadata, TOC, and slices.",
505
+ path_params=[
506
+ _param("kb_id", "integer", True, "Knowledge base ID."),
507
+ _param("file_id", "integer", True, "File object ID."),
508
+ _param("version_id", "integer", True, "Version ID."),
509
+ ],
510
+ response="Processed document content and slices.",
511
+ errors=["400 processing not ready", "401 invalid API key", "403 config scope missing", "404 version not found"],
512
+ example="deepflow_openagent_cli knowledge versions content 1 3 4",
513
+ ),
124
514
  EndpointDoc(
125
515
  key="documents.query",
126
516
  method="POST",
@@ -347,10 +737,11 @@ ENDPOINTS: tuple[EndpointDoc, ...] = (
347
737
  method="PUT",
348
738
  path="/api/v1/agents/{agent_id}/engine-config",
349
739
  scope="config",
350
- summary="Partially update system_prompt, model, selected_tool_ids, context, or pre_recall.",
740
+ summary="Partially update system_prompt, runtime_context, model, selected_tool_ids, context, or pre_recall.",
351
741
  path_params=[_param("agent_id", "integer", True, "Agent ID.")],
352
742
  body_fields=[
353
743
  _param("system_prompt", "string", False, "System prompt, max 30000 chars."),
744
+ _param("runtime_context", "string", False, "Per-LLM-call runtime context template, max 30000 chars."),
354
745
  _param("model", "object", False, "Model config."),
355
746
  _param("selected_tool_ids", "array[integer]", False, "Enabled tool IDs."),
356
747
  _param("context", "object", False, "Context policy."),
@@ -358,7 +749,7 @@ ENDPOINTS: tuple[EndpointDoc, ...] = (
358
749
  ],
359
750
  response="AgentResponse with updated engine_config.",
360
751
  errors=["401 missing or invalid API key", "403 scope missing", "404 agent not found", "422 validation error"],
361
- example="deepflow_openagent_cli agents engine-config 1 --json '{\"system_prompt\":\"You are helpful.\"}'",
752
+ example="deepflow_openagent_cli agents engine-config 1 --json '{\"runtime_context\":\"Date: {{current_date}}\"}'",
362
753
  ),
363
754
  EndpointDoc(
364
755
  key="tools.list",
@@ -619,6 +1010,82 @@ class OpenApiClient:
619
1010
  except URLError as exc:
620
1011
  raise CliError(f"Failed to connect to {url}: {exc.reason}") from exc
621
1012
 
1013
+ def request_multipart(
1014
+ self,
1015
+ endpoint_key: str,
1016
+ path_params: dict[str, Any],
1017
+ fields: dict[str, str | int | None],
1018
+ files: dict[str, Path | None],
1019
+ ) -> bytes:
1020
+ endpoint = get_endpoint(endpoint_key)
1021
+ if endpoint.scope and not self.api_key:
1022
+ raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
1023
+
1024
+ url = self._build_url(endpoint, path_params, {})
1025
+ body = MultipartBody(fields, files)
1026
+ headers = {
1027
+ "Accept": "application/json",
1028
+ "Content-Type": body.content_type,
1029
+ "Content-Length": str(body.content_length),
1030
+ }
1031
+ if self.api_key:
1032
+ headers["Authorization"] = f"Bearer {self.api_key}"
1033
+ request = Request(url, data=body, headers=headers, method=endpoint.method)
1034
+ try:
1035
+ with urlopen(request) as response: # noqa: S310 - user-provided API host.
1036
+ return response.read()
1037
+ except HTTPError as exc:
1038
+ payload = exc.read().decode("utf-8", errors="replace")
1039
+ raise CliError(f"HTTP {exc.code} for {endpoint_key}: {payload}") from exc
1040
+ except URLError as exc:
1041
+ raise CliError(f"Failed to connect to {url}: {exc.reason}") from exc
1042
+
1043
+ def download(
1044
+ self,
1045
+ endpoint_key: str,
1046
+ output_path: Path,
1047
+ path_params: dict[str, Any],
1048
+ query: dict[str, Any] | None = None,
1049
+ ) -> int:
1050
+ endpoint = get_endpoint(endpoint_key)
1051
+ if endpoint.scope and not self.api_key:
1052
+ raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
1053
+ output_path = output_path.expanduser()
1054
+ if not output_path.parent.is_dir():
1055
+ raise CliError(f"Output directory does not exist: {output_path.parent}")
1056
+ if output_path.is_dir():
1057
+ raise CliError(f"Output path is a directory: {output_path}")
1058
+
1059
+ url = self._build_url(endpoint, path_params, query or {})
1060
+ headers = {"Accept": "application/octet-stream"}
1061
+ if self.api_key:
1062
+ headers["Authorization"] = f"Bearer {self.api_key}"
1063
+ request = Request(url, headers=headers, method=endpoint.method)
1064
+ temporary_name: str | None = None
1065
+ try:
1066
+ with urlopen(request) as response: # noqa: S310 - user-provided API host.
1067
+ with tempfile.NamedTemporaryFile(
1068
+ mode="wb",
1069
+ dir=output_path.parent,
1070
+ prefix=f".{output_path.name}.",
1071
+ delete=False,
1072
+ ) as temporary:
1073
+ temporary_name = temporary.name
1074
+ size = 0
1075
+ while chunk := response.read(1024 * 1024):
1076
+ temporary.write(chunk)
1077
+ size += len(chunk)
1078
+ os.replace(temporary_name, output_path)
1079
+ return size
1080
+ except HTTPError as exc:
1081
+ payload = exc.read().decode("utf-8", errors="replace")
1082
+ raise CliError(f"HTTP {exc.code} for {endpoint_key}: {payload}") from exc
1083
+ except URLError as exc:
1084
+ raise CliError(f"Failed to connect to {url}: {exc.reason}") from exc
1085
+ finally:
1086
+ if temporary_name and os.path.exists(temporary_name):
1087
+ os.unlink(temporary_name)
1088
+
622
1089
  def _build_url(self, endpoint: EndpointDoc, path_params: dict[str, Any], query: dict[str, Any]) -> str:
623
1090
  path = endpoint.path
624
1091
  for param in endpoint.path_params:
@@ -765,6 +1232,51 @@ def run_request(
765
1232
  return 0
766
1233
 
767
1234
 
1235
+ def run_multipart_request(
1236
+ args: argparse.Namespace,
1237
+ endpoint_key: str,
1238
+ path_params: dict[str, Any],
1239
+ fields: dict[str, str | int | None],
1240
+ files: dict[str, Path | None],
1241
+ ) -> int:
1242
+ data = client_from_args(args).request_multipart(
1243
+ endpoint_key,
1244
+ path_params,
1245
+ fields,
1246
+ files,
1247
+ )
1248
+ print_output(data, pretty=args.pretty, raw=args.raw)
1249
+ return 0
1250
+
1251
+
1252
+ def run_download(
1253
+ args: argparse.Namespace,
1254
+ endpoint_key: str,
1255
+ path_params: dict[str, Any],
1256
+ ) -> int:
1257
+ output = Path(args.output).expanduser()
1258
+ size = client_from_args(args).download(
1259
+ endpoint_key,
1260
+ output,
1261
+ path_params,
1262
+ query={"download": True},
1263
+ )
1264
+ payload = json.dumps(
1265
+ {"output": str(output.absolute()), "size": size},
1266
+ ensure_ascii=False,
1267
+ ).encode("utf-8")
1268
+ print_output(payload, pretty=args.pretty, raw=args.raw)
1269
+ return 0
1270
+
1271
+
1272
+ def _file_version_path(args: argparse.Namespace) -> dict[str, int]:
1273
+ return {
1274
+ "kb_id": args.kb_id,
1275
+ "file_id": args.file_id,
1276
+ "version_id": args.version_id,
1277
+ }
1278
+
1279
+
768
1280
  def handle_knowledge(args: argparse.Namespace) -> int:
769
1281
  if args.knowledge_action == "list":
770
1282
  return run_request(args, "knowledge.list", query={"page": args.page, "per_page": args.per_page})
@@ -788,6 +1300,124 @@ def handle_knowledge(args: argparse.Namespace) -> int:
788
1300
  "line": args.line,
789
1301
  },
790
1302
  )
1303
+ if args.knowledge_action == "directories":
1304
+ path = {"kb_id": args.kb_id}
1305
+ if args.directory_action == "list":
1306
+ return run_request(args, "knowledge.file_directories.list", path)
1307
+ if args.directory_action == "create":
1308
+ body = merge_payload(
1309
+ args,
1310
+ {"name": args.name, "parent_id": args.parent_id},
1311
+ )
1312
+ return run_request(args, "knowledge.file_directories.create", path, body=body)
1313
+ path["directory_id"] = args.directory_id
1314
+ if args.directory_action == "update":
1315
+ body = merge_payload(
1316
+ args,
1317
+ {
1318
+ "name": args.name,
1319
+ "parent_id": args.parent_id,
1320
+ "sort_order": args.sort_order,
1321
+ },
1322
+ )
1323
+ return run_request(args, "knowledge.file_directories.update", path, body=body)
1324
+ return run_request(args, "knowledge.file_directories.delete", path)
1325
+ if args.knowledge_action == "files":
1326
+ path = {"kb_id": args.kb_id}
1327
+ if args.file_action == "list":
1328
+ return run_request(
1329
+ args,
1330
+ "knowledge.files.list",
1331
+ path,
1332
+ query={
1333
+ "directory_id": args.directory_id,
1334
+ "search": args.search,
1335
+ "markdown": args.markdown_filter,
1336
+ "page": args.page,
1337
+ "per_page": args.per_page,
1338
+ },
1339
+ )
1340
+ if args.file_action == "create":
1341
+ return run_multipart_request(
1342
+ args,
1343
+ "knowledge.files.create",
1344
+ path,
1345
+ fields={
1346
+ "directory_id": args.directory_id,
1347
+ "name": args.name,
1348
+ "custom_version": args.custom_version,
1349
+ "description": args.description,
1350
+ "note": args.note,
1351
+ },
1352
+ files={
1353
+ "original": Path(args.original),
1354
+ "markdown": Path(args.markdown) if args.markdown else None,
1355
+ },
1356
+ )
1357
+ path["file_id"] = args.file_id
1358
+ if args.file_action == "get":
1359
+ return run_request(
1360
+ args,
1361
+ "knowledge.files.get",
1362
+ path,
1363
+ query={"markdown": args.markdown_filter},
1364
+ )
1365
+ if args.file_action == "update":
1366
+ body = merge_payload(
1367
+ args,
1368
+ {
1369
+ "name": args.name,
1370
+ "description": args.description,
1371
+ "directory_id": args.directory_id,
1372
+ },
1373
+ )
1374
+ return run_request(args, "knowledge.files.update", path, body=body)
1375
+ return run_request(args, "knowledge.files.delete", path)
1376
+ if args.knowledge_action == "versions":
1377
+ if args.version_action == "upload":
1378
+ return run_multipart_request(
1379
+ args,
1380
+ "knowledge.file_versions.create",
1381
+ {"kb_id": args.kb_id, "file_id": args.file_id},
1382
+ fields={
1383
+ "custom_version": args.custom_version,
1384
+ "note": args.note,
1385
+ },
1386
+ files={
1387
+ "original": Path(args.original),
1388
+ "markdown": Path(args.markdown) if args.markdown else None,
1389
+ },
1390
+ )
1391
+ path = _file_version_path(args)
1392
+ if args.version_action == "get":
1393
+ return run_request(args, "knowledge.file_versions.get", path)
1394
+ if args.version_action == "update":
1395
+ body = merge_payload(
1396
+ args,
1397
+ {"custom_version": args.custom_version, "note": args.note},
1398
+ )
1399
+ return run_request(args, "knowledge.file_versions.update", path, body=body)
1400
+ if args.version_action == "delete":
1401
+ return run_request(args, "knowledge.file_versions.delete", path)
1402
+ if args.version_action == "original":
1403
+ return run_download(args, "knowledge.file_versions.original", path)
1404
+ if args.version_action == "attach-markdown":
1405
+ return run_multipart_request(
1406
+ args,
1407
+ "knowledge.file_versions.attach_markdown",
1408
+ path,
1409
+ fields={},
1410
+ files={"markdown": Path(args.markdown)},
1411
+ )
1412
+ if args.version_action == "markdown":
1413
+ return run_download(args, "knowledge.file_versions.markdown", path)
1414
+ endpoint = {
1415
+ "remove-markdown": "knowledge.file_versions.remove_markdown",
1416
+ "reprocess": "knowledge.file_versions.reprocess",
1417
+ "set-current": "knowledge.file_versions.current",
1418
+ "content": "knowledge.file_versions.content",
1419
+ }[args.version_action]
1420
+ return run_request(args, endpoint, path)
791
1421
  return run_request(args, "knowledge.markdown", {"kb_id": args.kb_id, "doc_id": args.doc_id})
792
1422
 
793
1423
 
@@ -1010,6 +1640,113 @@ def build_parser() -> argparse.ArgumentParser:
1010
1640
  knowledge_markdown = knowledge_sub.add_parser("markdown")
1011
1641
  knowledge_markdown.add_argument("kb_id", type=int)
1012
1642
  knowledge_markdown.add_argument("doc_id", type=int)
1643
+
1644
+ knowledge_directories = knowledge_sub.add_parser("directories")
1645
+ directory_sub = knowledge_directories.add_subparsers(
1646
+ dest="directory_action", required=True
1647
+ )
1648
+ directory_list = directory_sub.add_parser("list")
1649
+ directory_list.add_argument("kb_id", type=int)
1650
+ directory_create = directory_sub.add_parser("create")
1651
+ directory_create.add_argument("kb_id", type=int)
1652
+ directory_create.add_argument("--name", required=True)
1653
+ directory_create.add_argument("--parent-id", type=int)
1654
+ add_json_args(directory_create)
1655
+ directory_update = directory_sub.add_parser("update")
1656
+ directory_update.add_argument("kb_id", type=int)
1657
+ directory_update.add_argument("directory_id", type=int)
1658
+ directory_update.add_argument("--name")
1659
+ directory_update.add_argument("--parent-id", type=int)
1660
+ directory_update.add_argument("--sort-order", type=int)
1661
+ add_json_args(directory_update)
1662
+ directory_delete = directory_sub.add_parser("delete")
1663
+ directory_delete.add_argument("kb_id", type=int)
1664
+ directory_delete.add_argument("directory_id", type=int)
1665
+
1666
+ knowledge_files = knowledge_sub.add_parser("files")
1667
+ file_sub = knowledge_files.add_subparsers(dest="file_action", required=True)
1668
+ file_list = file_sub.add_parser("list")
1669
+ file_list.add_argument("kb_id", type=int)
1670
+ file_list.add_argument("--directory-id", type=int)
1671
+ file_list.add_argument("--search")
1672
+ file_list.add_argument(
1673
+ "--markdown-filter",
1674
+ choices=["all", "missing-current", "has-missing", "complete"],
1675
+ )
1676
+ file_list.add_argument("--page", type=int)
1677
+ file_list.add_argument("--per-page", type=int)
1678
+ file_create = file_sub.add_parser("create")
1679
+ file_create.add_argument("kb_id", type=int)
1680
+ file_create.add_argument("--directory-id", type=int, required=True)
1681
+ file_create.add_argument("--name", required=True)
1682
+ file_create.add_argument("--custom-version", required=True)
1683
+ file_create.add_argument("--description")
1684
+ file_create.add_argument("--note")
1685
+ file_create.add_argument("--original", required=True)
1686
+ file_create.add_argument("--markdown")
1687
+ file_get = file_sub.add_parser("get")
1688
+ file_get.add_argument("kb_id", type=int)
1689
+ file_get.add_argument("file_id", type=int)
1690
+ file_get.add_argument(
1691
+ "--markdown-filter",
1692
+ choices=["all", "missing", "bound", "processing", "ready", "failed"],
1693
+ )
1694
+ file_update = file_sub.add_parser("update")
1695
+ file_update.add_argument("kb_id", type=int)
1696
+ file_update.add_argument("file_id", type=int)
1697
+ file_update.add_argument("--name")
1698
+ file_update.add_argument("--description")
1699
+ file_update.add_argument("--directory-id", type=int)
1700
+ add_json_args(file_update)
1701
+ file_delete = file_sub.add_parser("delete")
1702
+ file_delete.add_argument("kb_id", type=int)
1703
+ file_delete.add_argument("file_id", type=int)
1704
+
1705
+ knowledge_versions = knowledge_sub.add_parser("versions")
1706
+ version_sub = knowledge_versions.add_subparsers(
1707
+ dest="version_action", required=True
1708
+ )
1709
+ version_upload = version_sub.add_parser("upload")
1710
+ version_upload.add_argument("kb_id", type=int)
1711
+ version_upload.add_argument("file_id", type=int)
1712
+ version_upload.add_argument("--custom-version", required=True)
1713
+ version_upload.add_argument("--note")
1714
+ version_upload.add_argument("--original", required=True)
1715
+ version_upload.add_argument("--markdown")
1716
+ for action in [
1717
+ "get",
1718
+ "delete",
1719
+ "remove-markdown",
1720
+ "reprocess",
1721
+ "set-current",
1722
+ "content",
1723
+ ]:
1724
+ version_parser = version_sub.add_parser(action)
1725
+ version_parser.add_argument("kb_id", type=int)
1726
+ version_parser.add_argument("file_id", type=int)
1727
+ version_parser.add_argument("version_id", type=int)
1728
+ version_update = version_sub.add_parser("update")
1729
+ version_update.add_argument("kb_id", type=int)
1730
+ version_update.add_argument("file_id", type=int)
1731
+ version_update.add_argument("version_id", type=int)
1732
+ version_update.add_argument("--custom-version")
1733
+ version_update.add_argument("--note")
1734
+ add_json_args(version_update)
1735
+ version_original = version_sub.add_parser("original")
1736
+ version_original.add_argument("kb_id", type=int)
1737
+ version_original.add_argument("file_id", type=int)
1738
+ version_original.add_argument("version_id", type=int)
1739
+ version_original.add_argument("--output", required=True)
1740
+ version_attach = version_sub.add_parser("attach-markdown")
1741
+ version_attach.add_argument("kb_id", type=int)
1742
+ version_attach.add_argument("file_id", type=int)
1743
+ version_attach.add_argument("version_id", type=int)
1744
+ version_attach.add_argument("--markdown", required=True)
1745
+ version_markdown = version_sub.add_parser("markdown")
1746
+ version_markdown.add_argument("kb_id", type=int)
1747
+ version_markdown.add_argument("file_id", type=int)
1748
+ version_markdown.add_argument("version_id", type=int)
1749
+ version_markdown.add_argument("--output", required=True)
1013
1750
  knowledge.set_defaults(handler=handle_knowledge)
1014
1751
 
1015
1752
  conversations = subparsers.add_parser("conversations", help="Conversation endpoints.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepflow-openagent-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Command-line client for OpenAgent OpenAPI (agents, conversations, SSE chat, tools, channels, knowledge bases).
5
5
  Author: DeepFlow AI
6
6
  Project-URL: Repository, https://github.com/DeepFlowAI/OpenAgent
@@ -66,6 +66,21 @@ deepflow_openagent_cli docs show chat.stream --json
66
66
  deepflow_openagent_cli knowledge list --page 1 --per-page 10
67
67
  deepflow_openagent_cli knowledge documents 1
68
68
  deepflow_openagent_cli knowledge query-document 6670 --slice-id 512208 --line 1720
69
+
70
+ # Knowledge-base file library (requires config scope)
71
+ deepflow_openagent_cli knowledge directories create 1 --name "Policies"
72
+ deepflow_openagent_cli knowledge files create 1 \
73
+ --directory-id 2 --name "Product Manual" --custom-version v1 \
74
+ --original ./manual.pdf --markdown ./manual.md
75
+ deepflow_openagent_cli knowledge files list 1 --markdown-filter has-missing
76
+ deepflow_openagent_cli knowledge versions upload 1 3 \
77
+ --custom-version v2 --original ./manual-v2.pdf
78
+ deepflow_openagent_cli knowledge versions attach-markdown 1 3 4 \
79
+ --markdown ./manual-v2.md
80
+ deepflow_openagent_cli knowledge versions set-current 1 3 4
81
+ deepflow_openagent_cli knowledge versions original 1 3 4 --output ./manual-v2.pdf
82
+ deepflow_openagent_cli knowledge versions markdown 1 3 4 --output ./manual-v2.md
83
+
69
84
  deepflow_openagent_cli chat 1 --message "Hello"
70
85
  deepflow_openagent_cli steps 1 42
71
86
  deepflow_openagent_cli steps get 1 42 100
@@ -8,6 +8,7 @@ from deepflow_openagent_cli import __version__
8
8
  from deepflow_openagent_cli.cli import (
9
9
  CliError,
10
10
  ENDPOINTS,
11
+ MultipartBody,
11
12
  OpenApiClient,
12
13
  build_parser,
13
14
  client_from_args,
@@ -30,12 +31,26 @@ def test_catalog_has_stable_unique_keys() -> None:
30
31
  assert len(keys) == len(set(keys))
31
32
  assert "knowledge.list" in keys
32
33
  assert "knowledge.documents" in keys
34
+ assert "knowledge.files.create" in keys
35
+ assert "knowledge.file_versions.attach_markdown" in keys
33
36
  assert "documents.query" in keys
34
37
  assert "chat.stream" in keys
35
38
  assert "steps.get" in keys
36
39
  assert "channels.secret_key" in keys
37
40
 
38
41
 
42
+ def test_agent_engine_config_docs_include_runtime_context() -> None:
43
+ endpoint = get_endpoint("agents.engine_config")
44
+
45
+ runtime_context = next(
46
+ field for field in endpoint.body_fields if field.name == "runtime_context"
47
+ )
48
+ assert runtime_context.type == "string"
49
+ assert runtime_context.required is False
50
+ assert "runtime_context" in endpoint.summary
51
+ assert "runtime_context" in endpoint.example
52
+
53
+
39
54
  def test_get_endpoint_unknown_key_raises_clear_error() -> None:
40
55
  with pytest.raises(CliError, match="Unknown endpoint"):
41
56
  get_endpoint("missing.endpoint")
@@ -59,6 +74,29 @@ def test_build_url_formats_path_and_query() -> None:
59
74
  assert url == "http://localhost:5001/api/v1/knowledge-bases/1/documents?page=2&per_page=20"
60
75
 
61
76
 
77
+ def test_multipart_body_streams_fields_and_files(tmp_path: Path) -> None:
78
+ original = tmp_path / "manual.pdf"
79
+ original.write_bytes(b"pdf-content")
80
+ body = MultipartBody(
81
+ {"name": "Manual", "directory_id": 2, "description": None},
82
+ {"original": original, "markdown": None},
83
+ )
84
+
85
+ payload = b"".join(body)
86
+
87
+ assert len(payload) == body.content_length
88
+ assert b'name="name"' in payload
89
+ assert b"Manual" in payload
90
+ assert b'name="original"; filename="manual.pdf"' in payload
91
+ assert b"pdf-content" in payload
92
+ assert body.content_type.startswith("multipart/form-data; boundary=openagent-")
93
+
94
+
95
+ def test_multipart_body_rejects_missing_file(tmp_path: Path) -> None:
96
+ with pytest.raises(CliError, match="Upload file does not exist"):
97
+ MultipartBody({}, {"original": tmp_path / "missing.pdf"})
98
+
99
+
62
100
  def test_client_from_args_reads_openagent_env(monkeypatch: pytest.MonkeyPatch) -> None:
63
101
  monkeypatch.setenv("OPENAGENT_HOST", "https://api.example.com")
64
102
  monkeypatch.setenv("OPENAGENT_API_KEY", "sk-openagent")
@@ -135,6 +173,97 @@ def test_query_document_command_posts_body(
135
173
  ]
136
174
 
137
175
 
176
+ def test_create_knowledge_file_posts_multipart(
177
+ monkeypatch: pytest.MonkeyPatch,
178
+ capsys: pytest.CaptureFixture[str],
179
+ tmp_path: Path,
180
+ ) -> None:
181
+ original = tmp_path / "manual.pdf"
182
+ markdown = tmp_path / "manual.md"
183
+ original.write_bytes(b"pdf")
184
+ markdown.write_text("# Manual", encoding="utf-8")
185
+ calls = []
186
+
187
+ def fake_request_multipart(self, endpoint_key, path_params, fields, files):
188
+ calls.append((endpoint_key, path_params, fields, files))
189
+ return b'{"id": 3}'
190
+
191
+ monkeypatch.setattr(OpenApiClient, "request_multipart", fake_request_multipart)
192
+
193
+ exit_code = main(
194
+ [
195
+ "--api-key",
196
+ "sk-test",
197
+ "knowledge",
198
+ "files",
199
+ "create",
200
+ "1",
201
+ "--directory-id",
202
+ "2",
203
+ "--name",
204
+ "Manual",
205
+ "--custom-version",
206
+ "v1",
207
+ "--original",
208
+ str(original),
209
+ "--markdown",
210
+ str(markdown),
211
+ ]
212
+ )
213
+
214
+ captured = capsys.readouterr()
215
+ assert exit_code == 0
216
+ assert json.loads(captured.out) == {"id": 3}
217
+ assert calls == [
218
+ (
219
+ "knowledge.files.create",
220
+ {"kb_id": 1},
221
+ {
222
+ "directory_id": 2,
223
+ "name": "Manual",
224
+ "custom_version": "v1",
225
+ "description": None,
226
+ "note": None,
227
+ },
228
+ {"original": original, "markdown": markdown},
229
+ )
230
+ ]
231
+
232
+
233
+ def test_download_version_original_writes_output_atomically(
234
+ monkeypatch: pytest.MonkeyPatch,
235
+ tmp_path: Path,
236
+ ) -> None:
237
+ class FakeResponse:
238
+ def __init__(self) -> None:
239
+ self.chunks = [b"first", b"second", b""]
240
+
241
+ def __enter__(self):
242
+ return self
243
+
244
+ def __exit__(self, *_args):
245
+ return False
246
+
247
+ def read(self, _size: int) -> bytes:
248
+ return self.chunks.pop(0)
249
+
250
+ monkeypatch.setattr(
251
+ "deepflow_openagent_cli.cli.urlopen",
252
+ lambda _request: FakeResponse(),
253
+ )
254
+ output = tmp_path / "manual.pdf"
255
+
256
+ size = OpenApiClient("http://localhost:5001", "sk-test").download(
257
+ "knowledge.file_versions.original",
258
+ output,
259
+ {"kb_id": 1, "file_id": 2, "version_id": 3},
260
+ )
261
+
262
+ assert size == 11
263
+ assert output.read_bytes() == b"firstsecond"
264
+ assert list(tmp_path.iterdir()) == [output]
265
+
266
+
138
267
  def test_steps_list_keeps_legacy_short_form(
139
268
  monkeypatch: pytest.MonkeyPatch,
140
269
  capsys: pytest.CaptureFixture[str],