paper-plane-x-cli 0.1.0__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.
@@ -0,0 +1,5 @@
1
+ """Paper Plane X CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,89 @@
1
+ """Paper Plane X HTTP CLI for external agent tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import httpx as httpx
10
+ import typer
11
+
12
+ from paper_plane_x_cli.cli.context import context_app
13
+ from paper_plane_x_cli.cli.files import files_app
14
+ from paper_plane_x_cli.cli.librarian import librarian_app
15
+ from paper_plane_x_cli.cli.paper import paper_app
16
+ from paper_plane_x_cli.cli.paper_note import paper_note_app
17
+ from paper_plane_x_cli.cli.pdf import pdf_app
18
+ from paper_plane_x_cli.cli.project import project_app
19
+ from paper_plane_x_cli.cli.skills import skills_app
20
+ from paper_plane_x_cli.cli.utils import DEFAULT_BASE_URL, load_context
21
+
22
+ GLOBAL_CONTEXT_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
23
+ GLOBAL_CONTEXT_PATH = GLOBAL_CONTEXT_DIR / "paper-plane-x" / "context.json"
24
+ LOCAL_CONTEXT_PATH = Path.cwd() / ".paper-plane-x" / "context.json"
25
+
26
+
27
+ def resolve_context(
28
+ base_url: str | None = None,
29
+ project_id: str | None = None,
30
+ ) -> dict[str, str | None]:
31
+ global_ctx = load_context(GLOBAL_CONTEXT_PATH)
32
+ local_ctx = load_context(LOCAL_CONTEXT_PATH)
33
+ merged = {**global_ctx, **local_ctx}
34
+ resolved_base_url = (
35
+ base_url
36
+ or os.environ.get("PPX_BASE_URL")
37
+ or merged.get("base_url")
38
+ or DEFAULT_BASE_URL
39
+ )
40
+ resolved_project_id = (
41
+ project_id or os.environ.get("PPX_PROJECT_ID") or merged.get("project_id")
42
+ )
43
+ return {
44
+ "base_url": resolved_base_url.rstrip("/"),
45
+ "project_id": resolved_project_id,
46
+ }
47
+
48
+
49
+ app = typer.Typer(
50
+ no_args_is_help=True,
51
+ add_completion=False,
52
+ help=(
53
+ "Paper Plane X HTTP CLI. All commands call a running Paper Plane X "
54
+ "FastAPI server and print JSON for external agents."
55
+ ),
56
+ )
57
+
58
+
59
+ @app.callback()
60
+ def callback(
61
+ ctx: typer.Context,
62
+ base_url: Annotated[
63
+ str | None,
64
+ typer.Option(
65
+ "--base-url",
66
+ help="Override API base URL, e.g. http://127.0.0.1:8000/api/v1.",
67
+ ),
68
+ ] = None,
69
+ project_id: Annotated[
70
+ str | None,
71
+ typer.Option("--project-id", help="Override current Paper Plane X project ID."),
72
+ ] = None,
73
+ ) -> None:
74
+ ctx.ensure_object(dict)
75
+ ctx.obj["ctx"] = resolve_context(base_url=base_url, project_id=project_id)
76
+
77
+
78
+ app.add_typer(context_app, name="context")
79
+ app.add_typer(project_app, name="project")
80
+ app.add_typer(pdf_app, name="pdf")
81
+ app.add_typer(librarian_app, name="librarian")
82
+ app.add_typer(files_app, name="files")
83
+ app.add_typer(paper_app, name="paper")
84
+ app.add_typer(paper_note_app, name="paper-note")
85
+ app.add_typer(skills_app, name="skills")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ app()
@@ -0,0 +1,72 @@
1
+ """Context management commands for the Paper Plane X CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from paper_plane_x_cli.cli.utils import (
10
+ GLOBAL_CONTEXT_PATH,
11
+ LOCAL_CONTEXT_PATH,
12
+ load_context,
13
+ print_json,
14
+ save_context,
15
+ )
16
+
17
+ context_app = typer.Typer(
18
+ no_args_is_help=True,
19
+ help="Show or save local CLI context such as API base URL and project ID.",
20
+ )
21
+
22
+
23
+ @context_app.command("show", help="Print resolved CLI context as JSON.")
24
+ def context_show(ctx: typer.Context) -> None:
25
+ global_ctx = load_context(GLOBAL_CONTEXT_PATH)
26
+ local_ctx = load_context(LOCAL_CONTEXT_PATH)
27
+ merged = {**global_ctx, **local_ctx}
28
+ resolved = ctx.obj["ctx"]
29
+ print_json(
30
+ {
31
+ "resolved": resolved,
32
+ "global": global_ctx or None,
33
+ "local": local_ctx or None,
34
+ "merged": merged or None,
35
+ }
36
+ )
37
+
38
+
39
+ @context_app.command(
40
+ "set",
41
+ help=f"Save context to {GLOBAL_CONTEXT_PATH} (default) or {LOCAL_CONTEXT_PATH} (--local).",
42
+ )
43
+ def context_set(
44
+ set_base_url: Annotated[
45
+ str | None,
46
+ typer.Option(
47
+ "--base-url",
48
+ help="API base URL to save, e.g. http://127.0.0.1:8000/api/v1.",
49
+ ),
50
+ ] = None,
51
+ set_project_id: Annotated[
52
+ str | None,
53
+ typer.Option(
54
+ "--project-id", help="Project ID to save for project-scoped commands."
55
+ ),
56
+ ] = None,
57
+ local: Annotated[
58
+ bool,
59
+ typer.Option(
60
+ "--local",
61
+ help="Write to the local context file in the current directory instead of the global one.",
62
+ ),
63
+ ] = False,
64
+ ) -> None:
65
+ path = LOCAL_CONTEXT_PATH if local else GLOBAL_CONTEXT_PATH
66
+ data = load_context(path)
67
+ if set_base_url is not None:
68
+ data["base_url"] = set_base_url.rstrip("/")
69
+ if set_project_id is not None:
70
+ data["project_id"] = set_project_id
71
+ save_context(data, path)
72
+ print_json(data)
@@ -0,0 +1,282 @@
1
+ """Project sandbox file commands for the Paper Plane X CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from paper_plane_x_cli.cli.utils import print_json, request, require_project_id
11
+
12
+ files_app = typer.Typer(
13
+ no_args_is_help=True,
14
+ help="Read, write, upload, patch, and delete project sandbox files.",
15
+ )
16
+
17
+
18
+ @files_app.command("list", help="List files and directories in the project sandbox.")
19
+ def files_list(
20
+ ctx: typer.Context,
21
+ dir: Annotated[str, typer.Option("--dir", help="Sandbox directory to list.")] = "/",
22
+ ) -> None:
23
+ ctx_dict = ctx.obj["ctx"]
24
+ project_id = require_project_id(ctx_dict)
25
+ prefix = f"/projects/{project_id}/files"
26
+ payload = request("GET", prefix, ctx=ctx_dict, params={"dir_path": dir})
27
+ print_json(payload)
28
+
29
+
30
+ @files_app.command("read", help="Read a project sandbox text file.")
31
+ def files_read(
32
+ ctx: typer.Context,
33
+ path: Annotated[
34
+ str,
35
+ typer.Option("--path", help="Sandbox file path, e.g. /notes/idea.md."),
36
+ ],
37
+ ) -> None:
38
+ ctx_dict = ctx.obj["ctx"]
39
+ project_id = require_project_id(ctx_dict)
40
+ prefix = f"/projects/{project_id}/files"
41
+ payload = request(
42
+ "GET", f"{prefix}/content", ctx=ctx_dict, params={"file_path": path}
43
+ )
44
+ print_json(payload)
45
+
46
+
47
+ @files_app.command("lines", help="Read a 1-based inclusive line range from a file.")
48
+ def files_lines(
49
+ ctx: typer.Context,
50
+ path: Annotated[str, typer.Option("--path", help="Sandbox file path.")],
51
+ start_line: Annotated[
52
+ int, typer.Option("--start-line", help="First line, 1-based.")
53
+ ],
54
+ end_line: Annotated[
55
+ int | None,
56
+ typer.Option(
57
+ "--end-line", help="Last line, inclusive. Defaults to start line."
58
+ ),
59
+ ] = None,
60
+ ) -> None:
61
+ ctx_dict = ctx.obj["ctx"]
62
+ project_id = require_project_id(ctx_dict)
63
+ prefix = f"/projects/{project_id}/files"
64
+ payload = request(
65
+ "GET",
66
+ f"{prefix}/lines",
67
+ ctx=ctx_dict,
68
+ params={
69
+ "file_path": path,
70
+ "start_line": start_line,
71
+ "end_line": end_line,
72
+ },
73
+ )
74
+ print_json(payload)
75
+
76
+
77
+ @files_app.command("find", help="Find text occurrences in a project sandbox file.")
78
+ def files_find(
79
+ ctx: typer.Context,
80
+ path: Annotated[str, typer.Option("--path", help="Sandbox file path.")],
81
+ query: Annotated[str, typer.Option("--query", help="Text to search for.")],
82
+ case_sensitive: Annotated[
83
+ bool,
84
+ typer.Option("--case-sensitive", help="Use case-sensitive matching."),
85
+ ] = False,
86
+ max_matches: Annotated[
87
+ int,
88
+ typer.Option("--max-matches", help="Maximum number of matches to return."),
89
+ ] = 20,
90
+ ) -> None:
91
+ ctx_dict = ctx.obj["ctx"]
92
+ project_id = require_project_id(ctx_dict)
93
+ prefix = f"/projects/{project_id}/files"
94
+ payload = request(
95
+ "GET",
96
+ f"{prefix}/find",
97
+ ctx=ctx_dict,
98
+ params={
99
+ "file_path": path,
100
+ "query": query,
101
+ "case_sensitive": case_sensitive,
102
+ "max_matches": max_matches,
103
+ },
104
+ )
105
+ print_json(payload)
106
+
107
+
108
+ @files_app.command("write", help="Write or overwrite a project sandbox text file.")
109
+ def files_write(
110
+ ctx: typer.Context,
111
+ path: Annotated[str, typer.Option("--path", help="Sandbox destination path.")],
112
+ content: Annotated[
113
+ str, typer.Option("--content", help="Full text content to write.")
114
+ ],
115
+ ) -> None:
116
+ ctx_dict = ctx.obj["ctx"]
117
+ project_id = require_project_id(ctx_dict)
118
+ prefix = f"/projects/{project_id}/files"
119
+ payload = request(
120
+ "PUT",
121
+ f"{prefix}/content",
122
+ ctx=ctx_dict,
123
+ json_body={"file_path": path, "content": content},
124
+ )
125
+ print_json(payload)
126
+
127
+
128
+ @files_app.command("upload", help="Upload a local file into the project sandbox.")
129
+ def files_upload(
130
+ ctx: typer.Context,
131
+ source: Annotated[
132
+ Path,
133
+ typer.Option(
134
+ "--source",
135
+ exists=True,
136
+ dir_okay=False,
137
+ help="Local file to upload.",
138
+ ),
139
+ ],
140
+ path: Annotated[
141
+ str | None,
142
+ typer.Option(
143
+ "--path",
144
+ help="Sandbox destination path. Defaults to /<source filename>.",
145
+ ),
146
+ ] = None,
147
+ ) -> None:
148
+ ctx_dict = ctx.obj["ctx"]
149
+ project_id = require_project_id(ctx_dict)
150
+ target_path = path or f"/{source.name}"
151
+ prefix = f"/projects/{project_id}/files"
152
+ with source.open("rb") as file_obj:
153
+ payload = request(
154
+ "POST",
155
+ f"{prefix}/upload",
156
+ ctx=ctx_dict,
157
+ data={"file_path": target_path},
158
+ files={"file": (source.name, file_obj)},
159
+ )
160
+ print_json(payload)
161
+
162
+
163
+ @files_app.command("replace-lines", help="Replace a 1-based inclusive line range.")
164
+ def files_replace_lines(
165
+ ctx: typer.Context,
166
+ path: Annotated[str, typer.Option("--path", help="Sandbox file path.")],
167
+ start_line: Annotated[
168
+ int, typer.Option("--start-line", help="First line to replace.")
169
+ ],
170
+ end_line: Annotated[int, typer.Option("--end-line", help="Last line to replace.")],
171
+ new_text: Annotated[str, typer.Option("--new-text", help="Replacement text.")],
172
+ ) -> None:
173
+ ctx_dict = ctx.obj["ctx"]
174
+ project_id = require_project_id(ctx_dict)
175
+ prefix = f"/projects/{project_id}/files"
176
+ payload = request(
177
+ "PATCH",
178
+ f"{prefix}/lines",
179
+ ctx=ctx_dict,
180
+ json_body={
181
+ "file_path": path,
182
+ "start_line": start_line,
183
+ "end_line": end_line,
184
+ "new_text": new_text,
185
+ },
186
+ )
187
+ print_json(payload)
188
+
189
+
190
+ @files_app.command("replace-text", help="Replace exact text in a project sandbox file.")
191
+ def files_replace_text(
192
+ ctx: typer.Context,
193
+ path: Annotated[str, typer.Option("--path", help="Sandbox file path.")],
194
+ old_text: Annotated[str, typer.Option("--old-text", help="Exact text to replace.")],
195
+ new_text: Annotated[str, typer.Option("--new-text", help="Replacement text.")],
196
+ replace_all: Annotated[
197
+ bool,
198
+ typer.Option("--replace-all", help="Replace all occurrences instead of one."),
199
+ ] = False,
200
+ expected_occurrences: Annotated[
201
+ int,
202
+ typer.Option(
203
+ "--expected-occurrences",
204
+ help="Required occurrence count before replacement proceeds.",
205
+ ),
206
+ ] = 1,
207
+ ) -> None:
208
+ ctx_dict = ctx.obj["ctx"]
209
+ project_id = require_project_id(ctx_dict)
210
+ prefix = f"/projects/{project_id}/files"
211
+ payload = request(
212
+ "PATCH",
213
+ f"{prefix}/text",
214
+ ctx=ctx_dict,
215
+ json_body={
216
+ "file_path": path,
217
+ "old_text": old_text,
218
+ "new_text": new_text,
219
+ "replace_all": replace_all,
220
+ "expected_occurrences": expected_occurrences,
221
+ },
222
+ )
223
+ print_json(payload)
224
+
225
+
226
+ @files_app.command("patch", help="Patch a file relative to an exact anchor text.")
227
+ def files_patch(
228
+ ctx: typer.Context,
229
+ path: Annotated[str, typer.Option("--path", help="Sandbox file path.")],
230
+ action: Annotated[
231
+ str,
232
+ typer.Option(
233
+ "--action",
234
+ help="Patch action: replace, insert_before, insert_after, or delete.",
235
+ ),
236
+ ],
237
+ anchor_text: Annotated[
238
+ str,
239
+ typer.Option("--anchor-text", help="Exact anchor text to locate."),
240
+ ],
241
+ content: Annotated[
242
+ str,
243
+ typer.Option("--content", help="Content for replace/insert actions."),
244
+ ] = "",
245
+ expected_occurrences: Annotated[
246
+ int,
247
+ typer.Option(
248
+ "--expected-occurrences",
249
+ help="Required anchor occurrence count before patch proceeds.",
250
+ ),
251
+ ] = 1,
252
+ ) -> None:
253
+ ctx_dict = ctx.obj["ctx"]
254
+ project_id = require_project_id(ctx_dict)
255
+ prefix = f"/projects/{project_id}/files"
256
+ payload = request(
257
+ "PATCH",
258
+ f"{prefix}/patch",
259
+ ctx=ctx_dict,
260
+ json_body={
261
+ "file_path": path,
262
+ "action": action,
263
+ "anchor_text": anchor_text,
264
+ "content": content,
265
+ "expected_occurrences": expected_occurrences,
266
+ },
267
+ )
268
+ print_json(payload)
269
+
270
+
271
+ @files_app.command("delete", help="Delete a project sandbox file or empty directory.")
272
+ def files_delete(
273
+ ctx: typer.Context,
274
+ path: Annotated[str, typer.Option("--path", help="Sandbox path to delete.")],
275
+ ) -> None:
276
+ ctx_dict = ctx.obj["ctx"]
277
+ project_id = require_project_id(ctx_dict)
278
+ prefix = f"/projects/{project_id}/files"
279
+ payload = request(
280
+ "DELETE", f"{prefix}/content", ctx=ctx_dict, params={"file_path": path}
281
+ )
282
+ print_json(payload)
@@ -0,0 +1,108 @@
1
+ """Librarian search and deep-dive commands for the Paper Plane X CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from paper_plane_x_cli.cli.utils import print_json, request, split_csv
10
+
11
+ librarian_app = typer.Typer(
12
+ no_args_is_help=True,
13
+ help="Search, compare, and deep-dive papers through the Librarian API.",
14
+ )
15
+
16
+
17
+ @librarian_app.command(
18
+ "search",
19
+ help="Search project papers with the Librarian query expression DSL.",
20
+ )
21
+ def librarian_search(
22
+ ctx: typer.Context,
23
+ query_expr: Annotated[
24
+ str,
25
+ typer.Option(
26
+ "--query-expr", help='DSL query, e.g. "(meta.title CONTAINS transformer)".'
27
+ ),
28
+ ],
29
+ limit: Annotated[
30
+ int, typer.Option("--limit", help="Maximum number of papers.")
31
+ ] = 20,
32
+ offset: Annotated[int, typer.Option("--offset", help="Pagination offset.")] = 0,
33
+ ) -> None:
34
+ ctx_dict = ctx.obj["ctx"]
35
+ payload = request(
36
+ "POST",
37
+ "/librarian/search",
38
+ ctx=ctx_dict,
39
+ json_body={
40
+ "project_id": ctx_dict.get("project_id"),
41
+ "query_expr": query_expr,
42
+ "limit": limit,
43
+ "offset": offset,
44
+ },
45
+ )
46
+ print_json(payload)
47
+
48
+
49
+ @librarian_app.command(
50
+ "matrix",
51
+ help="Fetch structured field paths for one or more papers.",
52
+ )
53
+ def librarian_matrix(
54
+ ctx: typer.Context,
55
+ paper_ids: Annotated[
56
+ str,
57
+ typer.Option("--paper-ids", help="Comma-separated paper IDs, e.g. p1,p2."),
58
+ ],
59
+ field_paths: Annotated[
60
+ str,
61
+ typer.Option(
62
+ "--field-paths",
63
+ help="Comma-separated field paths, e.g. meta.title,quick_scan.verdict.",
64
+ ),
65
+ ],
66
+ ) -> None:
67
+ ctx_dict = ctx.obj["ctx"]
68
+ payload = request(
69
+ "POST",
70
+ "/librarian/matrix",
71
+ ctx=ctx_dict,
72
+ json_body={
73
+ "paper_ids": split_csv(paper_ids),
74
+ "field_paths": split_csv(field_paths),
75
+ },
76
+ )
77
+ print_json(payload)
78
+
79
+
80
+ @librarian_app.command(
81
+ "deep-dive",
82
+ help="Ask a focused question about a single paper.",
83
+ )
84
+ def librarian_deep_dive(
85
+ ctx: typer.Context,
86
+ paper_id: Annotated[str, typer.Option("--paper-id", help="Paper ID to inspect.")],
87
+ question: Annotated[
88
+ str,
89
+ typer.Option("--question", help="Focused question for the Deep Diver agent."),
90
+ ],
91
+ timeout: Annotated[
92
+ int,
93
+ typer.Option(
94
+ "--timeout",
95
+ help="Timeout in seconds for the Deep Diver agent to answer the question.",
96
+ min=1,
97
+ ),
98
+ ] = 600,
99
+ ) -> None:
100
+ ctx_dict = ctx.obj["ctx"]
101
+ payload = request(
102
+ "POST",
103
+ "/librarian/deep-dive",
104
+ ctx=ctx_dict,
105
+ json_body={"paper_id": paper_id, "question": question},
106
+ timeout=timeout,
107
+ )
108
+ print_json(payload)
@@ -0,0 +1,58 @@
1
+ """Paper resource commands for the Paper Plane X CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from paper_plane_x_cli.cli.utils import fail, print_json, request_bytes
11
+
12
+ paper_app = typer.Typer(
13
+ no_args_is_help=True,
14
+ help="Access Paper Plane X paper resources.",
15
+ )
16
+
17
+
18
+ @paper_app.command("markdown", help="Download a paper's parsed Markdown text.")
19
+ def paper_markdown(
20
+ ctx: typer.Context,
21
+ paper_id: Annotated[str, typer.Option("--paper-id", help="Paper ID.")],
22
+ save_dir: Annotated[
23
+ Path,
24
+ typer.Option(
25
+ "--save-dir",
26
+ help="Directory where the Markdown file is written.",
27
+ ),
28
+ ],
29
+ output_md_name: Annotated[
30
+ str | None,
31
+ typer.Option(
32
+ "--output-md-name",
33
+ help="Markdown filename. Defaults to <paper-id>.md.",
34
+ ),
35
+ ] = None,
36
+ ) -> None:
37
+ md_name = output_md_name or f"{paper_id}.md"
38
+ name_path = Path(md_name)
39
+ if name_path.name != md_name or name_path.suffix.lower() != ".md":
40
+ fail("--output-md-name must be a single .md filename")
41
+
42
+ content = request_bytes(
43
+ "GET",
44
+ f"/papers/{paper_id}/markdown",
45
+ ctx.obj["ctx"],
46
+ )
47
+
48
+ save_dir.mkdir(parents=True, exist_ok=True)
49
+ md_path = save_dir / md_name
50
+ md_path.write_bytes(content)
51
+
52
+ print_json(
53
+ {
54
+ "paper_id": paper_id,
55
+ "md_path": str(md_path),
56
+ "bytes_written": len(content),
57
+ }
58
+ )
@@ -0,0 +1,45 @@
1
+ """Paper-level agent note commands for the Paper Plane X CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from paper_plane_x_cli.cli.utils import print_json, request
10
+
11
+ paper_note_app = typer.Typer(
12
+ no_args_is_help=True,
13
+ help="Get, write, or delete paper-level agent notes.",
14
+ )
15
+
16
+
17
+ @paper_note_app.command("get", help="Read the agent note for a paper.")
18
+ def paper_note_get(
19
+ ctx: typer.Context,
20
+ paper_id: Annotated[str, typer.Option("--paper-id", help="Paper ID.")],
21
+ ) -> None:
22
+ path = f"/papers/{paper_id}/agent-note"
23
+ payload = request("GET", path, ctx=ctx.obj["ctx"])
24
+ print_json(payload)
25
+
26
+
27
+ @paper_note_app.command("write", help="Write or overwrite the agent note for a paper.")
28
+ def paper_note_write(
29
+ ctx: typer.Context,
30
+ paper_id: Annotated[str, typer.Option("--paper-id", help="Paper ID.")],
31
+ content: Annotated[str, typer.Option("--content", help="Full note content.")],
32
+ ) -> None:
33
+ path = f"/papers/{paper_id}/agent-note"
34
+ payload = request("PUT", path, ctx=ctx.obj["ctx"], json_body={"content": content})
35
+ print_json(payload)
36
+
37
+
38
+ @paper_note_app.command("delete", help="Delete the agent note for a paper.")
39
+ def paper_note_delete(
40
+ ctx: typer.Context,
41
+ paper_id: Annotated[str, typer.Option("--paper-id", help="Paper ID.")],
42
+ ) -> None:
43
+ path = f"/papers/{paper_id}/agent-note"
44
+ payload = request("DELETE", path, ctx=ctx.obj["ctx"])
45
+ print_json(payload)