daknode-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 @@
1
+ """DakNode CLI. Spec: `.agent/specs/17_agent_toolkit.md#cli`."""
daknode_cli/main.py ADDED
@@ -0,0 +1,215 @@
1
+ """DakNode CLI — the API from a terminal.
2
+
3
+ Spec: `.agent/specs/17_agent_toolkit.md#cli`.
4
+
5
+ **Built on `argparse` from the standard library, deliberately.** Click and Typer
6
+ are nicer to write against. They are not the right choice one day after
7
+ `mcp==2.2.0` was installed and silently upgraded `pydantic` from the pinned
8
+ `2.10.4` to `2.13.5`, changing the API's published OpenAPI output. Every
9
+ dependency added here is another chance to move a pin production depends on.
10
+ `argparse` costs some verbosity and cannot move a pin at all.
11
+
12
+ Every command maps 1:1 onto a published `operationId` — the same rule the MCP
13
+ server follows, so the two surfaces name the same things the same way.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ _SDK = Path(__file__).resolve().parents[1] / "sdk" / "python"
26
+ if str(_SDK) not in sys.path: # pragma: no cover - import plumbing
27
+ sys.path.insert(0, str(_SDK))
28
+
29
+ DEFAULT_BASE_URL = "https://api.daknode.com"
30
+
31
+ #: Distinguished so a script can tell "the API said no" from "I could not reach
32
+ #: it". Retrying a 422 forever is a different bug from retrying a timeout.
33
+ EXIT_REFUSED = 2
34
+ EXIT_UNREACHABLE = 3
35
+ EXIT_NO_CREDENTIAL = 4
36
+
37
+
38
+ class NoCredential(RuntimeError):
39
+ pass
40
+
41
+
42
+ def _client():
43
+ """Build the SDK client.
44
+
45
+ The key comes from the environment and **never from a flag**: argv is
46
+ visible to every process through `ps`, and shells record it in history.
47
+ """
48
+ key = os.environ.get("DAKNODE_API_KEY", "").strip()
49
+ if not key:
50
+ raise NoCredential(
51
+ "DAKNODE_API_KEY is not set. Export it; do not pass it as a flag."
52
+ )
53
+ from daknode import AuthenticatedClient
54
+
55
+ return AuthenticatedClient(
56
+ base_url=os.environ.get("DAKNODE_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
57
+ token=key,
58
+ )
59
+
60
+
61
+ def _emit(value: Any, fmt: str) -> None:
62
+ if fmt == "table" and isinstance(value, dict) and "items" in value:
63
+ rows = value.get("items") or []
64
+ if not rows:
65
+ print("(none)")
66
+ return
67
+ cols = list(rows[0].keys())[:5]
68
+ print(" ".join(c.upper() for c in cols))
69
+ for r in rows:
70
+ print(" ".join(str(r.get(c, ""))[:38] for c in cols))
71
+ return
72
+ print(json.dumps(value, indent=2, default=str))
73
+
74
+
75
+ def _unwrap(response) -> tuple[Any, int]:
76
+ parsed = getattr(response, "parsed", None)
77
+ status = getattr(response, "status_code", 0)
78
+ if parsed is not None:
79
+ body = parsed.to_dict() if hasattr(parsed, "to_dict") else parsed
80
+ return body, 0
81
+ raw = (getattr(response, "content", b"") or b"").decode("utf-8", "replace")
82
+ try:
83
+ return json.loads(raw), EXIT_REFUSED
84
+ except ValueError:
85
+ return {"status": status, "error": raw[:500]}, EXIT_REFUSED
86
+
87
+
88
+ # --- commands, each 1:1 with a published operation -------------------------
89
+
90
+ def cmd_create_inbox(args) -> tuple[Any, int]:
91
+ from daknode.api.inboxes import create_inbox
92
+ from daknode.models import InboxCreateRequest
93
+
94
+ with _client() as c:
95
+ return _unwrap(create_inbox.sync_detailed(client=c, body=InboxCreateRequest()))
96
+
97
+
98
+ def cmd_list_threads(args) -> tuple[Any, int]:
99
+ from daknode.api.messages import list_threads
100
+ from daknode.types import UNSET
101
+
102
+ with _client() as c:
103
+ return _unwrap(list_threads.sync_detailed(
104
+ inbox_id=args.inbox_id, client=c, limit=args.limit,
105
+ cursor=args.cursor or UNSET,
106
+ ))
107
+
108
+
109
+ def cmd_list_messages(args) -> tuple[Any, int]:
110
+ from daknode.api.messages import list_messages
111
+ from daknode.types import UNSET
112
+
113
+ with _client() as c:
114
+ return _unwrap(list_messages.sync_detailed(
115
+ inbox_id=args.inbox_id, client=c, limit=args.limit,
116
+ cursor=args.cursor or UNSET,
117
+ ))
118
+
119
+
120
+ def cmd_get_thread(args) -> tuple[Any, int]:
121
+ from daknode.api.messages import get_thread
122
+
123
+ with _client() as c:
124
+ return _unwrap(get_thread.sync_detailed(thread_id=args.thread_id, client=c))
125
+
126
+
127
+ def cmd_get_message(args) -> tuple[Any, int]:
128
+ from daknode.api.messages import get_message
129
+
130
+ with _client() as c:
131
+ return _unwrap(get_message.sync_detailed(message_id=args.message_id, client=c))
132
+
133
+
134
+ def cmd_search(args) -> tuple[Any, int]:
135
+ from daknode.api.search import search
136
+ from daknode.models import SearchRequest
137
+
138
+ with _client() as c:
139
+ return _unwrap(search.sync_detailed(
140
+ client=c, body=SearchRequest(query=args.query, top_k=args.top_k)
141
+ ))
142
+
143
+
144
+ def cmd_send(args) -> tuple[Any, int]:
145
+ from daknode.api.send import send
146
+ from daknode.models import SendRequest
147
+ from daknode.types import UNSET
148
+
149
+ body = SendRequest(
150
+ inbox_id=args.inbox_id, to=args.to, subject=args.subject,
151
+ body_text=args.body, thread_id=args.thread_id or UNSET,
152
+ )
153
+ if args.dry_run:
154
+ # A command that emails a real person deserves a way to be inspected
155
+ # first. Nothing is sent, and the exit code stays 0.
156
+ return {"dry_run": True, "operation": "send", "body": body.to_dict()}, 0
157
+ with _client() as c:
158
+ return _unwrap(send.sync_detailed(client=c, body=body))
159
+
160
+
161
+ def build_parser() -> argparse.ArgumentParser:
162
+ p = argparse.ArgumentParser(prog="daknode", description="DakNode from a terminal.")
163
+ p.add_argument("--format", choices=("json", "table"), default="json")
164
+ sub = p.add_subparsers(dest="resource", required=True)
165
+
166
+ inboxes = sub.add_parser("inboxes").add_subparsers(dest="command", required=True)
167
+ inboxes.add_parser("create").set_defaults(func=cmd_create_inbox, op="create_inbox")
168
+
169
+ threads = sub.add_parser("threads").add_subparsers(dest="command", required=True)
170
+ tl = threads.add_parser("list"); tl.add_argument("--inbox-id", required=True)
171
+ tl.add_argument("--limit", type=int, default=25); tl.add_argument("--cursor")
172
+ tl.set_defaults(func=cmd_list_threads, op="list_threads")
173
+ tg = threads.add_parser("get"); tg.add_argument("thread_id")
174
+ tg.set_defaults(func=cmd_get_thread, op="get_thread")
175
+
176
+ messages = sub.add_parser("messages").add_subparsers(dest="command", required=True)
177
+ ml = messages.add_parser("list"); ml.add_argument("--inbox-id", required=True)
178
+ ml.add_argument("--limit", type=int, default=25); ml.add_argument("--cursor")
179
+ ml.set_defaults(func=cmd_list_messages, op="list_messages")
180
+ mg = messages.add_parser("get"); mg.add_argument("message_id")
181
+ mg.set_defaults(func=cmd_get_message, op="get_message")
182
+
183
+ se = sub.add_parser("search"); se.add_argument("--query", required=True)
184
+ se.add_argument("--top-k", type=int, default=10)
185
+ se.set_defaults(func=cmd_search, op="search", command="search")
186
+
187
+ sn = sub.add_parser("send")
188
+ for flag in ("--inbox-id", "--to", "--subject", "--body"):
189
+ sn.add_argument(flag, required=True)
190
+ sn.add_argument("--thread-id", help="reply into this conversation")
191
+ sn.add_argument("--dry-run", action="store_true",
192
+ help="print the request and exit without sending")
193
+ sn.set_defaults(func=cmd_send, op="send", command="send")
194
+ return p
195
+
196
+
197
+ def main(argv: list[str] | None = None) -> int:
198
+ args = build_parser().parse_args(argv)
199
+ try:
200
+ value, code = args.func(args)
201
+ except NoCredential as exc:
202
+ print(f"daknode: {exc}", file=sys.stderr)
203
+ return EXIT_NO_CREDENTIAL
204
+ except Exception as exc: # noqa: BLE001 - a transport failure, not a refusal
205
+ print(f"daknode: could not reach the API: {exc}", file=sys.stderr)
206
+ return EXIT_UNREACHABLE
207
+ if code:
208
+ print(json.dumps(value, indent=2, default=str), file=sys.stderr)
209
+ return code
210
+ _emit(value, args.format)
211
+ return 0
212
+
213
+
214
+ if __name__ == "__main__": # pragma: no cover
215
+ raise SystemExit(main())
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: daknode-cli
3
+ Version: 0.1.0
4
+ Summary: DakNode's email API from a terminal. JSON to stdout by default.
5
+ Project-URL: Homepage, https://daknode.com
6
+ Project-URL: Documentation, https://daknode.com/developers
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: daknode<0.2,>=0.1
10
+
11
+ # DakNode CLI
12
+
13
+ The API from a terminal. JSON to stdout by default, because the caller is as
14
+ likely to be a script as a person.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pipx install daknode-cli # or: pip install daknode-cli
20
+ ```
21
+
22
+ Get an API key with the two-request signup at <https://daknode.com/developers>.
23
+
24
+ ```bash
25
+ export DAKNODE_API_KEY=…
26
+
27
+ daknode inboxes create
28
+ daknode threads list --inbox-id … --limit 10
29
+ daknode threads get <thread_id>
30
+ daknode messages list --inbox-id …
31
+ daknode messages get <message_id>
32
+ daknode search --query "invoice from stripe"
33
+ daknode send --inbox-id … --to … --subject … --body … [--thread-id …]
34
+ ```
35
+
36
+ From a repository checkout, `python -m cli.main` runs the same commands.
37
+
38
+ `--format table` for reading. `--dry-run` on `send` prints the request and
39
+ exits without sending it — a command that emails a real person deserves a way
40
+ to be inspected first.
41
+
42
+ ## Exit codes
43
+
44
+ | | |
45
+ | --- | --- |
46
+ | `0` | success |
47
+ | `2` | the API refused the request |
48
+ | `3` | the API could not be reached |
49
+ | `4` | `DAKNODE_API_KEY` is not set |
50
+
51
+ `2` and `3` are separate on purpose: a script retrying a network failure must
52
+ not retry a `422` forever.
53
+
54
+ ## Two deliberate constraints
55
+
56
+ **No credential flag.** The key comes from the environment only. argv is visible
57
+ to every process through `ps`, and shells record it in history. A test walks the
58
+ parser and fails if any flag name contains `key` or `token`.
59
+
60
+ **No third-party dependency.** `argparse` from the standard library, plus the
61
+ first-party generated SDK. Click and Typer are nicer to write against — and one
62
+ day before this was written, installing `mcp` silently upgraded a pinned
63
+ `pydantic` and changed the API's published OpenAPI output. Every dependency here
64
+ is another chance to move a pin production depends on. A test enumerates the
65
+ imports and fails on anything new.
66
+
67
+ **`send --thread-id` replies into a conversation**; without it the message opens
68
+ a new one in the recipient's client.
@@ -0,0 +1,7 @@
1
+ daknode_cli/__init__.py,sha256=Fnp2o_AebIG0V_t6uXBfnGMmb6zzw3yHDNJHJYgQ8No,65
2
+ daknode_cli/main.py,sha256=IZ1QOECO9sWwo90VLoF-8COtLEHzwztSPs66OD3inS4,7798
3
+ daknode_cli-0.1.0.dist-info/METADATA,sha256=kMJ-ltWdH0rRU7efhBU0nu6xoys9QSOCkIu3Rrnp2tQ,2289
4
+ daknode_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ daknode_cli-0.1.0.dist-info/entry_points.txt,sha256=9SXrMEBMLMzb-GEVGEaugzSdlrGSOgTZtWHrj_L4-Es,50
6
+ daknode_cli-0.1.0.dist-info/top_level.txt,sha256=Lo1MbBFksaGTXfnRY4_vDZgvd4nqNbZSZ8Cp5fUUuPc,12
7
+ daknode_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ daknode = daknode_cli.main:main
@@ -0,0 +1 @@
1
+ daknode_cli