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.
daknode_cli/__init__.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
daknode_cli
|