kelam 0.1.0__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.
kelam-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: kelam
3
+ Version: 0.1.0
4
+ Summary: Build and run voice AI agents from the terminal — the Kelam CLI.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: kelam-core>=0.1.0
8
+ Requires-Dist: httpx<1,>=0.27
9
+ Requires-Dist: typer<1,>=0.12
10
+
11
+ # kelam
12
+
13
+ Build and run voice AI agents from the terminal — the **Kelam CLI**.
14
+
15
+ A thin, dependency-light client (httpx + typer + the shared `kelam-core` contract) for the
16
+ Kelam voice-agent control plane. Create agents, deploy them, place real phone calls, talk to
17
+ them in the browser, send texts, and export call data — all from `kelam`.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ curl -fsSL https://kelam.sh | sh # one-command installer
23
+ # or:
24
+ uv tool install kelam
25
+ pipx install kelam
26
+ ```
27
+
28
+ ## Configure
29
+
30
+ ```sh
31
+ export KELAM_API_URL=https://<your-kelam-host>
32
+ export KELAM_PASSWORD=<password> # only if the server uses shared-password auth
33
+ kelam list
34
+ ```
35
+
36
+ ## The loop
37
+
38
+ ```sh
39
+ kelam create my-bot # scaffold + provision a phone number
40
+ kelam deploy <agent_id> # assemble + cache the runtime
41
+ kelam call <agent_id> +1206... # place a real outbound call
42
+ kelam web <agent_id> # or talk in the browser, no phone number
43
+ kelam export --since 7d # call logs + derived metrics
44
+ ```
45
+
46
+ The platform (API + voice worker) lives in the separate `kelam-backend` package; this package
47
+ is just the operator CLI.
kelam-0.1.0/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # kelam
2
+
3
+ Build and run voice AI agents from the terminal — the **Kelam CLI**.
4
+
5
+ A thin, dependency-light client (httpx + typer + the shared `kelam-core` contract) for the
6
+ Kelam voice-agent control plane. Create agents, deploy them, place real phone calls, talk to
7
+ them in the browser, send texts, and export call data — all from `kelam`.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ curl -fsSL https://kelam.sh | sh # one-command installer
13
+ # or:
14
+ uv tool install kelam
15
+ pipx install kelam
16
+ ```
17
+
18
+ ## Configure
19
+
20
+ ```sh
21
+ export KELAM_API_URL=https://<your-kelam-host>
22
+ export KELAM_PASSWORD=<password> # only if the server uses shared-password auth
23
+ kelam list
24
+ ```
25
+
26
+ ## The loop
27
+
28
+ ```sh
29
+ kelam create my-bot # scaffold + provision a phone number
30
+ kelam deploy <agent_id> # assemble + cache the runtime
31
+ kelam call <agent_id> +1206... # place a real outbound call
32
+ kelam web <agent_id> # or talk in the browser, no phone number
33
+ kelam export --since 7d # call logs + derived metrics
34
+ ```
35
+
36
+ The platform (API + voice worker) lives in the separate `kelam-backend` package; this package
37
+ is just the operator CLI.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: kelam
3
+ Version: 0.1.0
4
+ Summary: Build and run voice AI agents from the terminal — the Kelam CLI.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: kelam-core>=0.1.0
8
+ Requires-Dist: httpx<1,>=0.27
9
+ Requires-Dist: typer<1,>=0.12
10
+
11
+ # kelam
12
+
13
+ Build and run voice AI agents from the terminal — the **Kelam CLI**.
14
+
15
+ A thin, dependency-light client (httpx + typer + the shared `kelam-core` contract) for the
16
+ Kelam voice-agent control plane. Create agents, deploy them, place real phone calls, talk to
17
+ them in the browser, send texts, and export call data — all from `kelam`.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ curl -fsSL https://kelam.sh | sh # one-command installer
23
+ # or:
24
+ uv tool install kelam
25
+ pipx install kelam
26
+ ```
27
+
28
+ ## Configure
29
+
30
+ ```sh
31
+ export KELAM_API_URL=https://<your-kelam-host>
32
+ export KELAM_PASSWORD=<password> # only if the server uses shared-password auth
33
+ kelam list
34
+ ```
35
+
36
+ ## The loop
37
+
38
+ ```sh
39
+ kelam create my-bot # scaffold + provision a phone number
40
+ kelam deploy <agent_id> # assemble + cache the runtime
41
+ kelam call <agent_id> +1206... # place a real outbound call
42
+ kelam web <agent_id> # or talk in the browser, no phone number
43
+ kelam export --since 7d # call logs + derived metrics
44
+ ```
45
+
46
+ The platform (API + voice worker) lives in the separate `kelam-backend` package; this package
47
+ is just the operator CLI.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ kelam.egg-info/PKG-INFO
4
+ kelam.egg-info/SOURCES.txt
5
+ kelam.egg-info/dependency_links.txt
6
+ kelam.egg-info/entry_points.txt
7
+ kelam.egg-info/requires.txt
8
+ kelam.egg-info/top_level.txt
9
+ kelam_cli/__init__.py
10
+ kelam_cli/main.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kelam = kelam_cli.main:app
@@ -0,0 +1,3 @@
1
+ kelam-core>=0.1.0
2
+ httpx<1,>=0.27
3
+ typer<1,>=0.12
@@ -0,0 +1 @@
1
+ kelam_cli
@@ -0,0 +1,6 @@
1
+ """kelam — the command-line client for the Kelam voice-agent platform.
2
+
3
+ A thin HTTP client over the control plane (depends only on kelam-core + httpx + typer), so
4
+ `uv tool install kelam` / `pipx install kelam` stays small and fast. The platform itself
5
+ (API + worker) lives in the separate kelam-backend package.
6
+ """
@@ -0,0 +1,557 @@
1
+ """The `kelam` CLI — a thin HTTP client over the control plane, operated by the
2
+ engineer (or by Claude). Core logic is plain functions taking an httpx.Client so
3
+ the whole CLI<->API<->AWS path is integration-testable; Typer commands wrap them.
4
+
5
+ Config via env: KELAM_API_URL (default http://localhost:8000), KELAM_WORKSPACE (default "default").
6
+ The git-style loop: create -> pull -> (edit/Claude) -> verify -> push -> deploy -> call.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import io
12
+ import json
13
+ import os
14
+ import re
15
+ import webbrowser
16
+ from datetime import datetime, timedelta, timezone
17
+ from pathlib import Path
18
+ from urllib.parse import urlencode
19
+
20
+ import httpx
21
+ import typer
22
+
23
+ from kelam_core.metrics import CSV_FIELDS, flatten_call, summarize
24
+ from kelam_core.translation import folder_to_records, records_to_folder
25
+
26
+
27
+ def api_base() -> str:
28
+ """The control-plane base URL (KELAM_API_URL, default localhost), no trailing slash.
29
+ The single place the default lives — make_client and the URL builders all read it."""
30
+ return os.environ.get("KELAM_API_URL", "http://localhost:8000").rstrip("/")
31
+
32
+
33
+ def make_client() -> httpx.Client:
34
+ ws = os.environ.get("KELAM_WORKSPACE", "default")
35
+ headers = {"X-Workspace": ws}
36
+ password = os.environ.get("KELAM_PASSWORD")
37
+ if password:
38
+ headers["X-Kelam-Password"] = password
39
+ return httpx.Client(base_url=api_base(), headers=headers, timeout=120.0)
40
+
41
+
42
+ # ---- core operations (testable; take an httpx.Client) ----
43
+
44
+ def cli_create(client: httpx.Client, name: str, description: str = "",
45
+ inbound: bool = True, outbound: bool = True) -> dict:
46
+ r = client.post("/agents", json={"name": name, "description": description,
47
+ "inbound": inbound, "outbound": outbound})
48
+ r.raise_for_status()
49
+ return r.json()
50
+
51
+
52
+ def cli_pull(client: httpx.Client, agent_id: str, dest) -> dict:
53
+ r = client.get(f"/agents/{agent_id}")
54
+ r.raise_for_status()
55
+ data = r.json()
56
+ dest = Path(dest)
57
+ records_to_folder(data["records"], dest)
58
+ (dest / ".agentmeta.json").write_text(
59
+ json.dumps({"agent_id": agent_id, **data["meta"]}, indent=2), encoding="utf-8"
60
+ )
61
+ return data
62
+
63
+
64
+ def cli_push(client: httpx.Client, agent_id: str, src) -> dict:
65
+ r = client.post(f"/agents/{agent_id}/push", json={"records": folder_to_records(src)})
66
+ r.raise_for_status()
67
+ return r.json()
68
+
69
+
70
+ def cli_verify(client: httpx.Client, agent_id: str, src) -> list[str]:
71
+ r = client.post(f"/agents/{agent_id}/verify", json={"records": folder_to_records(src)})
72
+ r.raise_for_status()
73
+ return r.json()["issues"]
74
+
75
+
76
+ def cli_deploy(client: httpx.Client, agent_id: str) -> dict:
77
+ r = client.post(f"/agents/{agent_id}/deploy")
78
+ r.raise_for_status()
79
+ return r.json()
80
+
81
+
82
+ def cli_list(client: httpx.Client) -> list[dict]:
83
+ r = client.get("/agents")
84
+ r.raise_for_status()
85
+ return r.json()
86
+
87
+
88
+ def cli_secret_set(client: httpx.Client, name: str, value: str) -> dict:
89
+ r = client.post("/secrets", json={"name": name, "value": value})
90
+ r.raise_for_status()
91
+ return r.json()
92
+
93
+
94
+ def cli_delete(client: httpx.Client, agent_id: str) -> dict:
95
+ r = client.delete(f"/agents/{agent_id}")
96
+ r.raise_for_status()
97
+ return r.json()
98
+
99
+
100
+ def cli_call(client: httpx.Client, agent_id: str, to_number: str,
101
+ prompt: str | None = None) -> dict:
102
+ body = {"agent_id": agent_id, "to_number": to_number}
103
+ if prompt:
104
+ body["prompt"] = prompt
105
+ r = client.post("/calls", json=body)
106
+ r.raise_for_status()
107
+ return r.json()
108
+
109
+
110
+ def web_call_url(agent_id: str, prompt: str | None = None) -> str:
111
+ """The browser test-page URL for an agent: <KELAM_API_URL>/web-call?agent_id=...
112
+ The page (served by the API) dispatches the agent and connects over WebRTC on Start.
113
+
114
+ The per-call prompt and the shared password (KELAM_PASSWORD, if set) travel in the
115
+ URL FRAGMENT: the browser never sends it to the server, so a 32 KiB prompt can't
116
+ blow the HTTP request-line limit and the password never lands in access logs."""
117
+ ws = os.environ.get("KELAM_WORKSPACE", "default")
118
+ url = f"{api_base()}/web-call?{urlencode({'agent_id': agent_id, 'workspace': ws})}"
119
+ frag = {}
120
+ if prompt:
121
+ frag["prompt"] = prompt
122
+ password = os.environ.get("KELAM_PASSWORD")
123
+ if password:
124
+ frag["key"] = password
125
+ if frag:
126
+ url += "#" + urlencode(frag)
127
+ return url
128
+
129
+
130
+ def resolve_instructions(prompt: str | None, file: str | None) -> str | None:
131
+ """Combine --prompt text and the contents of --file into the per-call instructions.
132
+ The file is read here (on the operator's machine); only the resulting text crosses the API."""
133
+ parts = []
134
+ if prompt:
135
+ parts.append(prompt)
136
+ if file:
137
+ parts.append(Path(file).read_text(encoding="utf-8"))
138
+ combined = "\n\n".join(p.strip() for p in parts if p and p.strip())
139
+ return combined or None
140
+
141
+
142
+ def cli_get_call(client: httpx.Client, call_id: str) -> dict:
143
+ r = client.get(f"/calls/{call_id}")
144
+ r.raise_for_status()
145
+ return r.json()
146
+
147
+
148
+ def cli_list_calls(client: httpx.Client, agent_id: str,
149
+ limit: int = 50, cursor: str | None = None) -> dict:
150
+ """One page: {"calls": [...], "next_cursor": str|null}."""
151
+ params: dict = {"agent_id": agent_id, "limit": limit}
152
+ if cursor:
153
+ params["cursor"] = cursor
154
+ r = client.get("/calls", params=params)
155
+ r.raise_for_status()
156
+ return r.json()
157
+
158
+
159
+ def parse_since(text: str) -> str:
160
+ """ "30m" / "24h" / "7d" / "2w" shorthand -> ISO-8601 UTC; ISO dates pass through.
161
+ Raises BadParameter on anything else so the error surfaces before the HTTP call."""
162
+ m = re.fullmatch(r"(\d+)([mhdw])", text.strip())
163
+ if m:
164
+ n, unit = int(m.group(1)), m.group(2)
165
+ delta = {"m": timedelta(minutes=n), "h": timedelta(hours=n),
166
+ "d": timedelta(days=n), "w": timedelta(weeks=n)}[unit]
167
+ return (datetime.now(timezone.utc) - delta).isoformat()
168
+ try:
169
+ datetime.fromisoformat(text)
170
+ except ValueError:
171
+ raise typer.BadParameter(
172
+ f"--since must be like 30m/24h/7d/2w or ISO-8601, got {text!r}")
173
+ return text
174
+
175
+
176
+ def cli_export(client: httpx.Client, agent_id: str | None = None, since: str | None = None,
177
+ status: str | None = None, limit: int | None = None,
178
+ page_size: int = 200) -> list[dict]:
179
+ """Walk the paginated export until exhausted (or `limit` calls collected). Each
180
+ page is one bounded server-side query; the loop key is next_cursor, never item
181
+ count — filtered pages can run short and still have more behind them."""
182
+ calls: list[dict] = []
183
+ cursor: str | None = None
184
+ while True:
185
+ params = {k: v for k, v in {
186
+ "agent_id": agent_id, "since": since, "status": status,
187
+ "limit": min(page_size, limit - len(calls)) if limit else page_size,
188
+ "cursor": cursor,
189
+ }.items() if v is not None}
190
+ r = client.get("/calls/export", params=params)
191
+ r.raise_for_status()
192
+ body = r.json()
193
+ calls.extend(body["calls"])
194
+ cursor = body.get("next_cursor")
195
+ if not cursor or (limit and len(calls) >= limit):
196
+ return calls[:limit] if limit else calls
197
+
198
+
199
+ def render_export(calls: list[dict], fmt: str) -> str:
200
+ """Enriched calls -> jsonl (one call per line), json (array), or csv (flat metric
201
+ rows, no transcript text). jsonl/csv are what the kelam-viz skill consumes."""
202
+ if fmt == "jsonl":
203
+ return "\n".join(json.dumps(c) for c in calls) + ("\n" if calls else "")
204
+ if fmt == "json":
205
+ return json.dumps(calls, indent=2) + "\n"
206
+ if fmt == "csv":
207
+ buf = io.StringIO()
208
+ writer = csv.DictWriter(buf, fieldnames=CSV_FIELDS)
209
+ writer.writeheader()
210
+ for call in calls:
211
+ writer.writerow(flatten_call(call))
212
+ return buf.getvalue()
213
+ raise typer.BadParameter(f"--format must be jsonl, json, or csv, got {fmt!r}")
214
+
215
+
216
+ def cli_text(client: httpx.Client, agent_id: str, to_number: str, body: str = "",
217
+ media_urls: list[str] | None = None, channel: str = "sms") -> dict:
218
+ r = client.post("/messages", json={"agent_id": agent_id, "to_number": to_number,
219
+ "body": body, "media_urls": media_urls or [],
220
+ "channel": channel})
221
+ r.raise_for_status()
222
+ return r.json()
223
+
224
+
225
+ def cli_list_texts(client: httpx.Client, agent_id: str | None = None, peer: str | None = None,
226
+ since: str | None = None, limit: int = 50,
227
+ cursor: str | None = None) -> dict:
228
+ """One page: {"messages": [...], "next_cursor": str|null}."""
229
+ params = {k: v for k, v in {"agent_id": agent_id, "peer": peer, "since": since,
230
+ "limit": limit, "cursor": cursor}.items() if v is not None}
231
+ r = client.get("/messages", params=params)
232
+ r.raise_for_status()
233
+ return r.json()
234
+
235
+
236
+ def cli_context(client: httpx.Client, agent_id: str, peer: str) -> dict:
237
+ """The compose-a-reply bundle: message thread + call transcripts with the peer."""
238
+ r = client.get("/messages/context", params={"agent_id": agent_id, "peer": peer})
239
+ r.raise_for_status()
240
+ return r.json()
241
+
242
+
243
+ def cli_peers(client: httpx.Client, agent_id: str, limit: int = 20) -> dict:
244
+ """Recently active numbers across calls + texts: {"peers": [...], "truncated": bool}."""
245
+ r = client.get(f"/agents/{agent_id}/peers", params={"limit": limit})
246
+ r.raise_for_status()
247
+ return r.json()
248
+
249
+
250
+ def stream_call_events(client: httpx.Client, call_id: str):
251
+ """Yield the call's live events from the SSE endpoint until the stream closes.
252
+ Long-lived read: no read timeout (the server sends keepalives and closes on `ended`)."""
253
+ timeout = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)
254
+ with client.stream("GET", f"/calls/{call_id}/stream", timeout=timeout) as r:
255
+ r.raise_for_status()
256
+ for line in r.iter_lines():
257
+ if line.startswith("data: "):
258
+ yield json.loads(line[len("data: "):])
259
+
260
+
261
+ def follow_call(client: httpx.Client, call_id: str, echo=print) -> dict | None:
262
+ """Print events as JSONL until the call ends; return the final CallLog (None on timeout)."""
263
+ for event in stream_call_events(client, call_id):
264
+ echo(json.dumps(event))
265
+ if event.get("type") == "ended":
266
+ return event.get("call")
267
+ if event.get("type") == "timeout":
268
+ return None
269
+ return None
270
+
271
+
272
+ # ---- Typer commands ----
273
+
274
+ app = typer.Typer(help="Kelam — build and run voice AI agents from the terminal.")
275
+
276
+
277
+ @app.command()
278
+ def create(
279
+ name: str,
280
+ description: str = "",
281
+ no_number: bool = typer.Option(
282
+ False, "--no-number",
283
+ help="web-only agent: skip provisioning a phone number (talk to it with `kelam web`)",
284
+ ),
285
+ ):
286
+ """Create an agent and pull it into ./<name>/.
287
+
288
+ By default auto-provisions a phone number. With --no-number the agent has no inbound or
289
+ outbound telephony and costs nothing — reach it over the browser with `kelam web <id>`."""
290
+ with make_client() as c:
291
+ d = cli_create(c, name, description,
292
+ inbound=not no_number, outbound=not no_number)
293
+ cli_pull(c, d["agent_id"], Path(name))
294
+ typer.echo(json.dumps(d))
295
+
296
+
297
+ @app.command()
298
+ def pull(agent_id: str, dest: str = ""):
299
+ """Pull an agent's files into a local folder."""
300
+ with make_client() as c:
301
+ cli_pull(c, agent_id, Path(dest or agent_id))
302
+ typer.echo(json.dumps({"pulled": agent_id, "dest": dest or agent_id}))
303
+
304
+
305
+ @app.command()
306
+ def verify(agent_id: str, src: str = "."):
307
+ """Lint a local agent folder (config, tools, secret references)."""
308
+ with make_client() as c:
309
+ issues = cli_verify(c, agent_id, src)
310
+ typer.echo(json.dumps({"issues": issues}))
311
+ if issues:
312
+ raise typer.Exit(1)
313
+
314
+
315
+ @app.command()
316
+ def push(agent_id: str, src: str = "."):
317
+ """Push a local agent folder (creates a new version)."""
318
+ with make_client() as c:
319
+ typer.echo(json.dumps(cli_push(c, agent_id, src)))
320
+
321
+
322
+ @app.command()
323
+ def deploy(agent_id: str):
324
+ """Deploy: assemble + cache the runtime, ready for calls."""
325
+ with make_client() as c:
326
+ typer.echo(json.dumps(cli_deploy(c, agent_id)))
327
+
328
+
329
+ @app.command("list")
330
+ def list_cmd():
331
+ """List agents in the workspace."""
332
+ with make_client() as c:
333
+ typer.echo(json.dumps(cli_list(c)))
334
+
335
+
336
+ @app.command("secret-set")
337
+ def secret_set(
338
+ name: str,
339
+ env_var: str = typer.Option(
340
+ None, "--env-var", help="read the value from this env var instead of a hidden prompt"
341
+ ),
342
+ ):
343
+ """Set a workspace secret (stored encrypted in SSM). The value is read from --env-var or a
344
+ hidden prompt — never passed on the command line (keeps it out of shell history/transcripts)."""
345
+ if env_var:
346
+ if env_var not in os.environ:
347
+ raise typer.BadParameter(f"env var {env_var!r} is not set")
348
+ value = os.environ[env_var]
349
+ else:
350
+ value = typer.prompt("Secret value", hide_input=True)
351
+ with make_client() as c:
352
+ cli_secret_set(c, name, value)
353
+ typer.echo(json.dumps({"ok": True}))
354
+
355
+
356
+ @app.command()
357
+ def call(
358
+ agent_id: str,
359
+ to_number: str,
360
+ prompt: str = typer.Option(
361
+ None, "--prompt", "-p", help="per-call instructions, seeded as the agent's first message"
362
+ ),
363
+ file: str = typer.Option(
364
+ None, "--file", "-f", help="read per-call instructions from this file (combined with --prompt)"
365
+ ),
366
+ wait: bool = typer.Option(
367
+ False, "--wait", "-w", help="stream the call live and exit with the final transcript"
368
+ ),
369
+ ):
370
+ """Place an outbound call from the agent to a phone number (fire-and-forget).
371
+
372
+ Optional --prompt/--file tailor THIS call without redeploying — e.g.
373
+ `kelam call <id> +1206... -p "extract their email, budget, and timeline"`.
374
+ With --wait, stream the call's events live (JSONL) and exit when it ends,
375
+ printing the final CallLog — the place->follow->transcript loop in one command."""
376
+ if file and not Path(file).is_file():
377
+ raise typer.BadParameter(f"file not found: {file}")
378
+ instructions = resolve_instructions(prompt, file)
379
+ with make_client() as c:
380
+ placed = cli_call(c, agent_id, to_number, prompt=instructions)
381
+ typer.echo(json.dumps(placed))
382
+ if not wait:
383
+ return
384
+ final = follow_call(c, placed["call_id"], echo=typer.echo)
385
+ if final is None:
386
+ raise typer.Exit(1) # stream timed out without a terminal status
387
+ typer.echo(json.dumps(final))
388
+ if final.get("status") != "completed":
389
+ raise typer.Exit(1)
390
+
391
+
392
+ @app.command()
393
+ def web(
394
+ agent_id: str,
395
+ prompt: str = typer.Option(
396
+ None, "--prompt", "-p", help="per-call instructions, seeded as the agent's first message"
397
+ ),
398
+ file: str = typer.Option(
399
+ None, "--file", "-f", help="read per-call instructions from this file (combined with --prompt)"
400
+ ),
401
+ no_open: bool = typer.Option(
402
+ False, "--no-open", help="just print the URL; don't open a browser"
403
+ ),
404
+ ):
405
+ """Talk to a deployed agent in your browser over WebRTC — no phone number required.
406
+
407
+ Opens the test page; click Start to dispatch the agent, connect your mic, and watch
408
+ the live transcript. The call is logged like any other (`kelam transcript <call_id>`).
409
+ Optional --prompt/--file tailor THIS call without redeploying. The page is served by
410
+ the API, so point KELAM_API_URL at the box you want (defaults to localhost:8000)."""
411
+ if file and not Path(file).is_file():
412
+ raise typer.BadParameter(f"file not found: {file}")
413
+ url = web_call_url(agent_id, resolve_instructions(prompt, file))
414
+ typer.echo(json.dumps({"agent_id": agent_id, "url": url}))
415
+ if not no_open:
416
+ webbrowser.open(url)
417
+
418
+
419
+ @app.command()
420
+ def transcript(
421
+ target: str = typer.Argument(..., metavar="CALL_ID|AGENT_ID",
422
+ help="a call_id (one call) — or an agent_id, with a phone number"),
423
+ peer: str = typer.Argument(
424
+ None, metavar="[PEER]",
425
+ help="a peer from `kelam peers` (usually a phone number): return the full "
426
+ "voice+text history with it instead"),
427
+ ):
428
+ """Fetch a transcript.
429
+
430
+ One arg (a call_id) -> that call's log: status + transcript + recording_url,
431
+ plus a short-lived presigned recording_playback_url (click-to-listen, no manual
432
+ aws s3 presign). Two args (an agent_id and a phone number) -> the whole
433
+ conversation with that number: the text thread plus call logs/transcripts, as
434
+ {messages, calls}. Read it, then reply with `kelam text`."""
435
+ with make_client() as c:
436
+ if peer is None:
437
+ typer.echo(json.dumps(cli_get_call(c, target)))
438
+ return
439
+ # Full-length numbers are normalized to E.164 (a bare 12065550123 gains its +);
440
+ # everything else `kelam peers` can yield — short codes, alphanumeric sender ids,
441
+ # anonymous caller ids — passes through exactly as stored, never rejected.
442
+ peer = peer.strip()
443
+ if re.fullmatch(r"\+?[1-9]\d{7,14}", peer):
444
+ peer = "+" + peer.lstrip("+")
445
+ typer.echo(json.dumps(cli_context(c, target, peer)))
446
+
447
+
448
+ @app.command()
449
+ def calls(
450
+ agent_id: str,
451
+ limit: int = typer.Option(50, "--limit", "-n", help="page size"),
452
+ cursor: str = typer.Option(None, "--cursor", help="resume from a previous page's next_cursor"),
453
+ ):
454
+ """List an agent's recent calls (newest first), one page at a time.
455
+ Output is {"calls": [...], "next_cursor": ...}; pass --cursor to continue."""
456
+ with make_client() as c:
457
+ typer.echo(json.dumps(cli_list_calls(c, agent_id, limit=limit, cursor=cursor)))
458
+
459
+
460
+ @app.command()
461
+ def export(
462
+ agent_id: str = typer.Option(None, "--agent", "-a", help="only this agent's calls (default: whole workspace)"),
463
+ since: str = typer.Option(None, "--since", help='only calls started after this: "30m", "24h", "7d", "2w", or ISO-8601'),
464
+ status: str = typer.Option(None, "--status", help="filter by status: completed | failed | busy | no_answer | in_progress"),
465
+ limit: int = typer.Option(None, "--limit", "-n", help="newest N calls after filtering"),
466
+ fmt: str = typer.Option("jsonl", "--format", help="jsonl (default) | json | csv (flat metrics, no transcript)"),
467
+ out: str = typer.Option(None, "--out", "-o", help="write to this file instead of stdout"),
468
+ ):
469
+ """Export call logs — transcripts plus derived metrics (turns, words, talk ratio,
470
+ durations) — for analysis or visualization. Newest first."""
471
+ with make_client() as c:
472
+ calls = cli_export(c, agent_id=agent_id, since=parse_since(since) if since else None,
473
+ status=status, limit=limit)
474
+ rendered = render_export(calls, fmt)
475
+ if out:
476
+ Path(out).write_text(rendered, encoding="utf-8")
477
+ typer.echo(json.dumps({"exported": len(calls), "format": fmt, "out": out}))
478
+ else:
479
+ typer.echo(rendered, nl=False)
480
+
481
+
482
+ @app.command()
483
+ def stats(
484
+ agent_id: str = typer.Option(None, "--agent", "-a", help="only this agent's calls (default: whole workspace)"),
485
+ since: str = typer.Option(None, "--since", help='only calls started after this: "30m", "24h", "7d", "2w", or ISO-8601'),
486
+ ):
487
+ """Aggregate call stats: counts by status/direction/agent, duration percentiles, turn totals."""
488
+ with make_client() as c:
489
+ calls = cli_export(c, agent_id=agent_id, since=parse_since(since) if since else None)
490
+ typer.echo(json.dumps(summarize(calls), indent=2))
491
+
492
+
493
+ @app.command()
494
+ def text(
495
+ agent_id: str,
496
+ to_number: str,
497
+ body: str = typer.Argument("", help="the message text (optional when --media is given)"),
498
+ media: list[str] = typer.Option(
499
+ None, "--media", "-m", help="media URL to attach (repeatable) — images etc."
500
+ ),
501
+ channel: str = typer.Option(
502
+ "sms", "--channel", "-c", help="delivery channel: sms (default) or whatsapp"
503
+ ),
504
+ ):
505
+ """Send a message (SMS, MMS with --media, or --channel whatsapp) from the agent's number.
506
+
507
+ e.g. `kelam text <id> +12065550123 "running 5 minutes late"`. The message is
508
+ stored on the (agent, number) thread; replies land there too (the number's SMS
509
+ webhook is wired to this server automatically at provision when KELAM_PUBLIC_URL
510
+ is set)."""
511
+ if not body and not media:
512
+ raise typer.BadParameter("provide a message body and/or --media")
513
+ with make_client() as c:
514
+ typer.echo(json.dumps(cli_text(c, agent_id, to_number, body=body,
515
+ media_urls=list(media) if media else None,
516
+ channel=channel)))
517
+
518
+
519
+ @app.command()
520
+ def texts(
521
+ agent_id: str,
522
+ peer: str = typer.Option(None, "--peer", help="only the thread with this number"),
523
+ since: str = typer.Option(None, "--since", help='only messages after this: "30m", "24h", "7d", "2w", or ISO-8601'),
524
+ limit: int = typer.Option(50, "--limit", "-n", help="page size"),
525
+ cursor: str = typer.Option(None, "--cursor", help="resume from a previous page's next_cursor"),
526
+ ):
527
+ """List an agent's messages (newest first), one page at a time.
528
+ Output is {"messages": [...], "next_cursor": ...}; pass --cursor to continue."""
529
+ with make_client() as c:
530
+ typer.echo(json.dumps(cli_list_texts(
531
+ c, agent_id, peer=peer, since=parse_since(since) if since else None,
532
+ limit=limit, cursor=cursor)))
533
+
534
+
535
+ @app.command()
536
+ def peers(
537
+ agent_id: str,
538
+ limit: int = typer.Option(20, "--limit", "-n", help="how many distinct numbers to return"),
539
+ ):
540
+ """Recently active numbers across this agent's calls AND texts, newest contact first.
541
+
542
+ The starting point for following up: pick a number, then `kelam transcript <id> <number>`
543
+ for the full voice+text history. Each entry has last_contact, last_direction, and the
544
+ channels (call/text) it used. Output: {"peers": [...], "truncated": bool}."""
545
+ with make_client() as c:
546
+ typer.echo(json.dumps(cli_peers(c, agent_id, limit=limit)))
547
+
548
+
549
+ @app.command()
550
+ def delete(agent_id: str):
551
+ """Delete an agent."""
552
+ with make_client() as c:
553
+ typer.echo(json.dumps(cli_delete(c, agent_id)))
554
+
555
+
556
+ if __name__ == "__main__":
557
+ app()
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "kelam"
3
+ version = "0.1.0"
4
+ description = "Build and run voice AI agents from the terminal — the Kelam CLI."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ # Capped at the next major per the repo dep policy (issue #40); 0.x libs capped at <1.
8
+ dependencies = [
9
+ "kelam-core>=0.1.0",
10
+ "httpx>=0.27,<1",
11
+ "typer>=0.12,<1",
12
+ ]
13
+
14
+ [project.scripts]
15
+ kelam = "kelam_cli.main:app"
16
+
17
+ [build-system]
18
+ requires = ["setuptools>=68"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["kelam_cli*"]
kelam-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+