cueapi 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.
cueapi/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CueAPI CLI — Your Agents' Cue to Act."""
2
+
3
+ __version__ = "0.1.0"
cueapi/auth.py ADDED
@@ -0,0 +1,185 @@
1
+ """Auth commands: login, logout, whoami, key regenerate."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+ import string
6
+ import time
7
+ import webbrowser
8
+ from typing import Optional
9
+
10
+ import click
11
+
12
+ from cueapi.client import CueAPIClient, UnauthClient
13
+ from cueapi.credentials import (
14
+ get_profile_info,
15
+ remove_all_credentials,
16
+ remove_credentials,
17
+ resolve_api_base,
18
+ save_credentials,
19
+ )
20
+ from cueapi.formatting import echo_error, echo_info, echo_success
21
+
22
+
23
+ def _generate_device_code() -> str:
24
+ """Generate a random device code like ABCD-EFGH."""
25
+ chars = string.ascii_uppercase + string.digits
26
+ part1 = "".join(secrets.choice(chars) for _ in range(4))
27
+ part2 = "".join(secrets.choice(chars) for _ in range(4))
28
+ return f"{part1}-{part2}"
29
+
30
+
31
+ def do_login(api_base: Optional[str] = None, profile: str = "default") -> None:
32
+ """Run the device code login flow."""
33
+ base = api_base or resolve_api_base(profile=profile)
34
+ device_code = _generate_device_code()
35
+
36
+ with UnauthClient(api_base=base) as client:
37
+ # Step 1: Create device code
38
+ resp = client.post("/auth/device-code", json={"device_code": device_code})
39
+ if resp.status_code != 201:
40
+ error = resp.json().get("detail", {}).get("error", {})
41
+ echo_error(error.get("message", f"Failed to create device code (HTTP {resp.status_code})"))
42
+ return
43
+
44
+ data = resp.json()
45
+ verification_url = data["verification_url"]
46
+ expires_in = data["expires_in"]
47
+
48
+ # Step 2: Open browser
49
+ click.echo("\nOpening browser to authenticate...")
50
+ try:
51
+ webbrowser.open(verification_url)
52
+ except Exception:
53
+ pass
54
+ click.echo(f"If browser doesn't open, visit: {verification_url}\n")
55
+
56
+ # Step 3: Poll
57
+ click.echo("Waiting for authentication...", nl=False)
58
+ deadline = time.time() + expires_in
59
+ while time.time() < deadline:
60
+ time.sleep(2)
61
+ click.echo(".", nl=False)
62
+
63
+ resp = client.post("/auth/device-code/poll", json={"device_code": device_code})
64
+ if resp.status_code != 200:
65
+ continue
66
+
67
+ poll_data = resp.json()
68
+ status = poll_data.get("status")
69
+
70
+ if status == "approved":
71
+ api_key = poll_data["api_key"]
72
+ email = poll_data["email"]
73
+
74
+ # Save credentials
75
+ save_credentials(
76
+ profile=profile,
77
+ data={
78
+ "api_key": api_key,
79
+ "email": email,
80
+ "api_base": base,
81
+ },
82
+ )
83
+
84
+ click.echo()
85
+ echo_success(f"Authenticated as {email}")
86
+ click.echo(f"API key stored in credentials file.\n")
87
+ click.echo(f"Your API key: {api_key}")
88
+ click.echo("(This is the only time your full key will be shown. Save it if you need it elsewhere.)\n")
89
+ click.echo('Run `cueapi quickstart` to create your first cue.')
90
+ return
91
+
92
+ if status == "expired":
93
+ click.echo()
94
+ echo_error("Device code expired. Run `cueapi login` to try again.")
95
+ return
96
+
97
+ click.echo()
98
+ echo_error("Login timed out. Run `cueapi login` to try again.")
99
+
100
+
101
+ def do_whoami(
102
+ api_key: Optional[str] = None,
103
+ profile: Optional[str] = None,
104
+ ) -> None:
105
+ """Show current user info."""
106
+ profile_name = profile or "default"
107
+ try:
108
+ with CueAPIClient(api_key=api_key, profile=profile) as client:
109
+ resp = client.get("/auth/me")
110
+ if resp.status_code != 200:
111
+ echo_error(f"Failed to get user info (HTTP {resp.status_code})")
112
+ return
113
+
114
+ data = resp.json()
115
+
116
+ # Get local key prefix
117
+ prof_info = get_profile_info(profile=profile)
118
+ key_display = "***"
119
+ if prof_info and "api_key" in prof_info:
120
+ key_display = prof_info["api_key"][:7] + "..." + prof_info["api_key"][-4:]
121
+
122
+ click.echo()
123
+ echo_info("Email:", data["email"])
124
+ echo_info("Plan:", data["plan"].capitalize())
125
+ echo_info("Active cues:", f"{data['active_cues']} / {data['active_cue_limit']}")
126
+ echo_info("Executions:", f"{data['executions_this_month']} / {data['monthly_execution_limit']} this month")
127
+ echo_info("API key:", key_display)
128
+ echo_info("Profile:", profile_name)
129
+ echo_info("API base:", client.api_base)
130
+ click.echo()
131
+ except click.ClickException:
132
+ click.echo("Not logged in. Run `cueapi login` to authenticate.")
133
+
134
+
135
+ def do_logout(profile: str = "default", logout_all: bool = False) -> None:
136
+ """Remove credentials."""
137
+ if logout_all:
138
+ remove_all_credentials()
139
+ click.echo("Removed all credentials.")
140
+ return
141
+
142
+ email = remove_credentials(profile=profile)
143
+ if email:
144
+ click.echo(f"Removed credentials for {email} ({profile} profile).")
145
+ else:
146
+ click.echo(f"No credentials found for profile '{profile}'.")
147
+
148
+
149
+ def do_key_regenerate(
150
+ api_key: Optional[str] = None,
151
+ profile: Optional[str] = None,
152
+ skip_confirm: bool = False,
153
+ ) -> None:
154
+ """Regenerate API key."""
155
+ if not skip_confirm:
156
+ click.echo()
157
+ click.echo(click.style("Warning: ", fg="yellow") + "This will instantly revoke your current API key.")
158
+ click.echo(" All agents using the old key will stop working.\n")
159
+ if not click.confirm("Proceed?"):
160
+ click.echo("Cancelled.")
161
+ return
162
+
163
+ try:
164
+ with CueAPIClient(api_key=api_key, profile=profile) as client:
165
+ resp = client.post("/auth/key/regenerate")
166
+ if resp.status_code != 200:
167
+ echo_error(f"Failed to regenerate key (HTTP {resp.status_code})")
168
+ return
169
+
170
+ data = resp.json()
171
+ new_key = data["api_key"]
172
+
173
+ # Update local credentials
174
+ profile_name = profile or "default"
175
+ prof_info = get_profile_info(profile=profile)
176
+ if prof_info:
177
+ prof_info["api_key"] = new_key
178
+ save_credentials(profile=profile_name, data=prof_info)
179
+
180
+ click.echo()
181
+ click.echo(f"New API key: {new_key}")
182
+ click.echo("(This is the only time this key will be shown.)\n")
183
+ click.echo("Local credentials updated.")
184
+ except click.ClickException as e:
185
+ click.echo(str(e))
cueapi/cli.py ADDED
@@ -0,0 +1,468 @@
1
+ """CueAPI CLI — Click command group and all commands."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import webbrowser
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+ from cueapi import __version__
11
+ from cueapi.auth import do_key_regenerate, do_login, do_logout, do_whoami
12
+ from cueapi.client import CueAPIClient
13
+ from cueapi.credentials import resolve_api_base
14
+ from cueapi.formatting import (
15
+ echo_error,
16
+ echo_info,
17
+ echo_success,
18
+ echo_table,
19
+ format_status,
20
+ )
21
+ from cueapi.quickstart import do_quickstart
22
+
23
+
24
+ @click.group()
25
+ @click.version_option(version=__version__, prog_name="cueapi")
26
+ @click.option("--api-key", envvar="CUEAPI_API_KEY", default=None, help="API key (overrides credentials file)")
27
+ @click.option("--profile", default=None, help="Credentials profile to use")
28
+ @click.pass_context
29
+ def main(ctx: click.Context, api_key: Optional[str], profile: Optional[str]) -> None:
30
+ """CueAPI — Your Agents' Cue to Act."""
31
+ ctx.ensure_object(dict)
32
+ ctx.obj["api_key"] = api_key
33
+ ctx.obj["profile"] = profile
34
+
35
+
36
+ # --- Auth commands ---
37
+
38
+
39
+ @main.command()
40
+ @click.pass_context
41
+ def login(ctx: click.Context) -> None:
42
+ """Authenticate with CueAPI via browser."""
43
+ profile = ctx.obj.get("profile") or "default"
44
+ api_base = resolve_api_base(profile=profile)
45
+ do_login(api_base=api_base, profile=profile)
46
+
47
+
48
+ @main.command()
49
+ @click.pass_context
50
+ def whoami(ctx: click.Context) -> None:
51
+ """Show current user info."""
52
+ do_whoami(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile"))
53
+
54
+
55
+ @main.command()
56
+ @click.option("--all", "logout_all", is_flag=True, help="Remove all profiles")
57
+ @click.pass_context
58
+ def logout(ctx: click.Context, logout_all: bool) -> None:
59
+ """Remove stored credentials."""
60
+ profile = ctx.obj.get("profile") or "default"
61
+ do_logout(profile=profile, logout_all=logout_all)
62
+
63
+
64
+ @main.command()
65
+ @click.pass_context
66
+ def quickstart(ctx: click.Context) -> None:
67
+ """Guided setup: create a test cue, verify delivery, clean up."""
68
+ do_quickstart(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile"))
69
+
70
+
71
+ # --- Cue commands ---
72
+
73
+
74
+ @main.command()
75
+ @click.option("--name", required=True, help="Cue name")
76
+ @click.option("--cron", default=None, help="Cron expression for recurring cue")
77
+ @click.option("--at", "at_time", default=None, help="ISO timestamp for one-time cue")
78
+ @click.option("--url", required=True, help="Callback URL")
79
+ @click.option("--method", default="POST", help="HTTP method (default: POST)")
80
+ @click.option("--timezone", "tz", default="UTC", help="Timezone (default: UTC)")
81
+ @click.option("--payload", default=None, help="JSON payload string")
82
+ @click.option("--description", default=None, help="Cue description")
83
+ @click.pass_context
84
+ def create(
85
+ ctx: click.Context,
86
+ name: str,
87
+ cron: Optional[str],
88
+ at_time: Optional[str],
89
+ url: str,
90
+ method: str,
91
+ tz: str,
92
+ payload: Optional[str],
93
+ description: Optional[str],
94
+ ) -> None:
95
+ """Create a new cue."""
96
+ if cron and at_time:
97
+ raise click.UsageError("Cannot use both --cron and --at. Choose one.")
98
+ if not cron and not at_time:
99
+ raise click.UsageError("Must specify either --cron or --at.")
100
+
101
+ schedule = {"timezone": tz}
102
+ if cron:
103
+ schedule["type"] = "recurring"
104
+ schedule["cron"] = cron
105
+ else:
106
+ schedule["type"] = "once"
107
+ schedule["at"] = at_time
108
+
109
+ body = {
110
+ "name": name,
111
+ "schedule": schedule,
112
+ "callback": {"url": url, "method": method},
113
+ }
114
+
115
+ if payload:
116
+ try:
117
+ body["payload"] = json.loads(payload)
118
+ except json.JSONDecodeError:
119
+ raise click.UsageError("--payload must be valid JSON")
120
+
121
+ if description:
122
+ body["description"] = description
123
+
124
+ try:
125
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
126
+ resp = client.post("/cues", json=body)
127
+ if resp.status_code == 201:
128
+ cue = resp.json()
129
+ click.echo()
130
+ echo_success(f"Created: {cue['id']}")
131
+ echo_info("Status:", cue["status"])
132
+ if cue.get("next_run"):
133
+ echo_info("Next run:", cue["next_run"])
134
+ click.echo()
135
+ elif resp.status_code == 403:
136
+ error = resp.json().get("detail", {}).get("error", {})
137
+ echo_error(error.get("message", "Cue limit exceeded"))
138
+ click.echo("\nRun `cueapi upgrade` to increase your limit.")
139
+ else:
140
+ error = resp.json().get("detail", {}).get("error", {})
141
+ echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
142
+ except click.ClickException as e:
143
+ click.echo(str(e))
144
+
145
+
146
+ @main.command(name="list")
147
+ @click.option("--status", default=None, help="Filter by status (active/paused)")
148
+ @click.option("--limit", default=20, type=int, help="Max results")
149
+ @click.option("--offset", default=0, type=int, help="Offset for pagination")
150
+ @click.pass_context
151
+ def list_cues(ctx: click.Context, status: Optional[str], limit: int, offset: int) -> None:
152
+ """List your cues."""
153
+ try:
154
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
155
+ params = {"limit": limit, "offset": offset}
156
+ if status:
157
+ params["status"] = status
158
+
159
+ resp = client.get("/cues", params=params)
160
+ if resp.status_code != 200:
161
+ echo_error(f"Failed to list cues (HTTP {resp.status_code})")
162
+ return
163
+
164
+ data = resp.json()
165
+ cues = data.get("cues", [])
166
+ total = data.get("total", len(cues))
167
+
168
+ if not cues:
169
+ click.echo("\nNo cues yet. Create your first one:")
170
+ click.echo(' cueapi create --name "my-cue" --cron "0 9 * * *" --url https://my-agent.com/webhook')
171
+ click.echo('\nOr run `cueapi quickstart` for guided setup.\n')
172
+ return
173
+
174
+ click.echo()
175
+ rows = []
176
+ for c in cues:
177
+ next_run = c.get("next_run", "—") or "—"
178
+ if next_run != "—":
179
+ # Truncate to minute precision
180
+ next_run = next_run[:16].replace("T", " ") + " UTC"
181
+ rows.append([c["id"], c["name"], format_status(c["status"]), next_run])
182
+
183
+ echo_table(
184
+ ["ID", "NAME", "STATUS", "NEXT RUN"],
185
+ rows,
186
+ widths=[22, 20, 12, 22],
187
+ )
188
+
189
+ # Summary
190
+ active = sum(1 for c in cues if c["status"] == "active")
191
+ paused = sum(1 for c in cues if c["status"] == "paused")
192
+ parts = []
193
+ if active:
194
+ parts.append(f"{active} active")
195
+ if paused:
196
+ parts.append(f"{paused} paused")
197
+ other = total - active - paused
198
+ if other > 0:
199
+ parts.append(f"{other} other")
200
+ click.echo(f"\n{total} cues ({', '.join(parts)})\n")
201
+
202
+ except click.ClickException as e:
203
+ click.echo(str(e))
204
+
205
+
206
+ @main.command()
207
+ @click.argument("cue_id")
208
+ @click.pass_context
209
+ def get(ctx: click.Context, cue_id: str) -> None:
210
+ """Get detailed info about a cue."""
211
+ try:
212
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
213
+ resp = client.get(f"/cues/{cue_id}")
214
+ if resp.status_code == 404:
215
+ echo_error(f"Cue not found: {cue_id}")
216
+ return
217
+ if resp.status_code != 200:
218
+ echo_error(f"Failed (HTTP {resp.status_code})")
219
+ return
220
+
221
+ c = resp.json()
222
+ click.echo()
223
+ echo_info("Name:", c["name"])
224
+ echo_info("Status:", format_status(c["status"]))
225
+
226
+ sched = c.get("schedule", {})
227
+ if sched.get("cron"):
228
+ echo_info("Schedule:", f"{sched['cron']} ({sched.get('timezone', 'UTC')})")
229
+ elif sched.get("at"):
230
+ echo_info("Schedule:", f"One-time: {sched['at']}")
231
+
232
+ cb = c.get("callback", {})
233
+ echo_info("Callback:", f"{cb.get('method', 'POST')} {cb.get('url', '')}")
234
+
235
+ if c.get("next_run"):
236
+ echo_info("Next run:", c["next_run"])
237
+ if c.get("last_run"):
238
+ echo_info("Last run:", c["last_run"])
239
+
240
+ echo_info("Run count:", str(c.get("run_count", 0)))
241
+ echo_info("Created:", c["created_at"])
242
+
243
+ if c.get("description"):
244
+ echo_info("Description:", c["description"])
245
+
246
+ # Display recent executions
247
+ executions = c.get("executions", [])
248
+ if executions:
249
+ click.echo()
250
+ click.echo("Recent executions:")
251
+ for ex in executions:
252
+ ts = ex.get("scheduled_for", "")[:16].replace("T", " ")
253
+ status = ex.get("status", "")
254
+ if status == "success":
255
+ mark = click.style("success", fg="green")
256
+ detail = f"({ex.get('http_status', '')}, {ex.get('attempts', 0)} attempt{'s' if ex.get('attempts', 0) != 1 else ''})"
257
+ elif status == "failed":
258
+ mark = click.style("failed", fg="red")
259
+ err = ex.get("error_message") or f"HTTP {ex.get('http_status', '?')}"
260
+ detail = f"({err}, {ex.get('attempts', 0)} attempts)"
261
+ else:
262
+ mark = click.style(status, fg="yellow")
263
+ detail = f"({ex.get('attempts', 0)} attempts)"
264
+ click.echo(f" {ts} {mark} {detail}")
265
+
266
+ click.echo()
267
+
268
+ except click.ClickException as e:
269
+ click.echo(str(e))
270
+
271
+
272
+ @main.command()
273
+ @click.argument("cue_id")
274
+ @click.pass_context
275
+ def pause(ctx: click.Context, cue_id: str) -> None:
276
+ """Pause a cue."""
277
+ try:
278
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
279
+ resp = client.patch(f"/cues/{cue_id}", json={"status": "paused"})
280
+ if resp.status_code == 200:
281
+ c = resp.json()
282
+ echo_success(f"Paused: {cue_id} ({c['name']})")
283
+ elif resp.status_code == 404:
284
+ echo_error(f"Cue not found: {cue_id}")
285
+ else:
286
+ error = resp.json().get("detail", {}).get("error", {})
287
+ echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
288
+ except click.ClickException as e:
289
+ click.echo(str(e))
290
+
291
+
292
+ @main.command()
293
+ @click.argument("cue_id")
294
+ @click.pass_context
295
+ def resume(ctx: click.Context, cue_id: str) -> None:
296
+ """Resume a paused cue."""
297
+ try:
298
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
299
+ resp = client.patch(f"/cues/{cue_id}", json={"status": "active"})
300
+ if resp.status_code == 200:
301
+ c = resp.json()
302
+ echo_success(f"Resumed: {cue_id} ({c['name']})")
303
+ if c.get("next_run"):
304
+ echo_info("Next run:", c["next_run"])
305
+ elif resp.status_code == 404:
306
+ echo_error(f"Cue not found: {cue_id}")
307
+ else:
308
+ error = resp.json().get("detail", {}).get("error", {})
309
+ echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
310
+ except click.ClickException as e:
311
+ click.echo(str(e))
312
+
313
+
314
+ @main.command()
315
+ @click.argument("cue_id")
316
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
317
+ @click.pass_context
318
+ def delete(ctx: click.Context, cue_id: str, yes: bool) -> None:
319
+ """Delete a cue."""
320
+ try:
321
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
322
+ # Get cue name for confirmation
323
+ if not yes:
324
+ resp = client.get(f"/cues/{cue_id}")
325
+ if resp.status_code == 404:
326
+ echo_error(f"Cue not found: {cue_id}")
327
+ return
328
+ name = resp.json().get("name", "unknown")
329
+ if not click.confirm(f"Delete {cue_id} ({name})?"):
330
+ click.echo("Cancelled.")
331
+ return
332
+
333
+ resp = client.delete(f"/cues/{cue_id}")
334
+ if resp.status_code == 204:
335
+ click.echo("Deleted.")
336
+ elif resp.status_code == 404:
337
+ echo_error(f"Cue not found: {cue_id}")
338
+ else:
339
+ echo_error(f"Failed (HTTP {resp.status_code})")
340
+ except click.ClickException as e:
341
+ click.echo(str(e))
342
+
343
+
344
+ # --- Billing commands ---
345
+
346
+
347
+ @main.command()
348
+ @click.pass_context
349
+ def upgrade(ctx: click.Context) -> None:
350
+ """Upgrade your plan via Stripe Checkout."""
351
+ try:
352
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
353
+ # Show current plan
354
+ resp = client.get("/usage")
355
+ if resp.status_code == 200:
356
+ data = resp.json()
357
+ plan = data.get("plan", {})
358
+ click.echo(f"\nCurrent plan: {plan.get('name', 'Free').capitalize()}\n")
359
+
360
+ click.echo("Available plans:")
361
+ click.echo(" Pro $9.99/mo 100 cues, 5,000 executions/mo")
362
+ click.echo(" Scale $49/mo 500 cues, 50,000 executions/mo\n")
363
+
364
+ plan_choice = click.prompt("Which plan?", type=click.Choice(["pro", "scale"]))
365
+ interval = click.prompt("Billing interval?", type=click.Choice(["monthly", "annual"]), default="monthly")
366
+
367
+ resp = client.post("/billing/checkout", json={"plan": plan_choice, "interval": interval})
368
+ if resp.status_code == 200:
369
+ url = resp.json().get("checkout_url")
370
+ if url:
371
+ click.echo("\nOpening checkout...")
372
+ try:
373
+ webbrowser.open(url)
374
+ except Exception:
375
+ pass
376
+ click.echo(f"If browser doesn't open, visit: {url}")
377
+ else:
378
+ error = resp.json().get("detail", {}).get("error", {})
379
+ echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
380
+ except click.ClickException as e:
381
+ click.echo(str(e))
382
+
383
+
384
+ @main.command()
385
+ @click.pass_context
386
+ def manage(ctx: click.Context) -> None:
387
+ """Open Stripe billing portal."""
388
+ try:
389
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
390
+ resp = client.post("/billing/portal")
391
+ if resp.status_code == 200:
392
+ url = resp.json().get("portal_url")
393
+ if url:
394
+ click.echo("Opening billing portal...")
395
+ try:
396
+ webbrowser.open(url)
397
+ except Exception:
398
+ pass
399
+ click.echo(f"If browser doesn't open, visit: {url}")
400
+ else:
401
+ error = resp.json().get("detail", {}).get("error", {})
402
+ echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
403
+ except click.ClickException as e:
404
+ click.echo(str(e))
405
+
406
+
407
+ @main.command()
408
+ @click.pass_context
409
+ def usage(ctx: click.Context) -> None:
410
+ """Show current usage stats."""
411
+ try:
412
+ with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
413
+ resp = client.get("/usage")
414
+ if resp.status_code != 200:
415
+ echo_error(f"Failed (HTTP {resp.status_code})")
416
+ return
417
+
418
+ data = resp.json()
419
+ plan = data.get("plan", {})
420
+ cues = data.get("cues", {})
421
+ execs = data.get("executions", {})
422
+ rate = data.get("rate_limit", {})
423
+
424
+ click.echo()
425
+ echo_info("Plan:", plan.get("name", "free").capitalize())
426
+
427
+ active = cues.get("active", 0)
428
+ cue_limit = cues.get("limit", 10)
429
+ echo_info("Active cues:", f"{active} / {cue_limit}")
430
+
431
+ used = execs.get("used", 0)
432
+ exec_limit = execs.get("limit", 300)
433
+ pct = (used / exec_limit * 100) if exec_limit > 0 else 0
434
+ echo_info("Executions:", f"{used:,} / {exec_limit:,} ({pct:.1f}%)")
435
+
436
+ echo_info("Rate limit:", f"{rate.get('limit', 60)} req/min")
437
+ click.echo()
438
+
439
+ except click.ClickException as e:
440
+ click.echo(str(e))
441
+
442
+
443
+ # --- Key management ---
444
+
445
+
446
+ @main.group()
447
+ def key() -> None:
448
+ """API key management."""
449
+ pass
450
+
451
+
452
+ @key.command()
453
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
454
+ @click.pass_context
455
+ def regenerate(ctx: click.Context, yes: bool) -> None:
456
+ """Regenerate your API key (revokes current key)."""
457
+ do_key_regenerate(
458
+ api_key=ctx.obj.get("api_key"),
459
+ profile=ctx.obj.get("profile"),
460
+ skip_confirm=yes,
461
+ )
462
+
463
+
464
+ main.add_command(key)
465
+
466
+
467
+ if __name__ == "__main__":
468
+ main()
cueapi/client.py ADDED
@@ -0,0 +1,77 @@
1
+ """HTTP client wrapper for CueAPI."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, Optional
5
+
6
+ import httpx
7
+
8
+ from cueapi.credentials import resolve_api_base, resolve_api_key
9
+
10
+
11
+ class CueAPIClient:
12
+ """Thin wrapper around httpx for CueAPI requests."""
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: Optional[str] = None,
17
+ api_base: Optional[str] = None,
18
+ profile: Optional[str] = None,
19
+ ):
20
+ self.api_key = api_key or resolve_api_key(profile=profile)
21
+ self.api_base = (api_base or resolve_api_base(profile=profile)).rstrip("/")
22
+ self._client = httpx.Client(
23
+ base_url=self.api_base,
24
+ headers={
25
+ "Authorization": f"Bearer {self.api_key}",
26
+ "Content-Type": "application/json",
27
+ },
28
+ timeout=30.0,
29
+ )
30
+
31
+ def get(self, path: str, **kwargs: Any) -> httpx.Response:
32
+ return self._client.get(path, **kwargs)
33
+
34
+ def post(self, path: str, **kwargs: Any) -> httpx.Response:
35
+ return self._client.post(path, **kwargs)
36
+
37
+ def patch(self, path: str, **kwargs: Any) -> httpx.Response:
38
+ return self._client.patch(path, **kwargs)
39
+
40
+ def delete(self, path: str, **kwargs: Any) -> httpx.Response:
41
+ return self._client.delete(path, **kwargs)
42
+
43
+ def close(self) -> None:
44
+ self._client.close()
45
+
46
+ def __enter__(self) -> "CueAPIClient":
47
+ return self
48
+
49
+ def __exit__(self, *args: Any) -> None:
50
+ self.close()
51
+
52
+
53
+ class UnauthClient:
54
+ """Client for unauthenticated endpoints (login, echo store)."""
55
+
56
+ def __init__(self, api_base: Optional[str] = None):
57
+ self.api_base = (api_base or "https://api.cueapi.ai/v1").rstrip("/")
58
+ self._client = httpx.Client(
59
+ base_url=self.api_base,
60
+ headers={"Content-Type": "application/json"},
61
+ timeout=30.0,
62
+ )
63
+
64
+ def get(self, path: str, **kwargs: Any) -> httpx.Response:
65
+ return self._client.get(path, **kwargs)
66
+
67
+ def post(self, path: str, **kwargs: Any) -> httpx.Response:
68
+ return self._client.post(path, **kwargs)
69
+
70
+ def close(self) -> None:
71
+ self._client.close()
72
+
73
+ def __enter__(self) -> "UnauthClient":
74
+ return self
75
+
76
+ def __exit__(self, *args: Any) -> None:
77
+ self.close()
cueapi/credentials.py ADDED
@@ -0,0 +1,139 @@
1
+ """Credential storage and resolution for CueAPI CLI."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import platform
7
+ import stat
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ import click
12
+
13
+
14
+ def _default_creds_path() -> Path:
15
+ """Get the default credentials file path based on OS."""
16
+ system = platform.system()
17
+ if system == "Windows":
18
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
19
+ else:
20
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
21
+ return base / "cueapi" / "credentials.json"
22
+
23
+
24
+ CREDS_PATH = _default_creds_path()
25
+
26
+
27
+ def load_credentials(creds_file: Optional[Path] = None) -> Dict[str, Any]:
28
+ """Load credentials from file. Returns empty dict if file doesn't exist."""
29
+ path = creds_file or CREDS_PATH
30
+ if not path.exists():
31
+ return {}
32
+ with open(path) as f:
33
+ return json.load(f)
34
+
35
+
36
+ def save_credentials(
37
+ creds_file: Optional[Path] = None,
38
+ profile: str = "default",
39
+ data: Optional[Dict[str, Any]] = None,
40
+ ) -> None:
41
+ """Save credentials for a profile. Creates parent directories if needed."""
42
+ path = creds_file or CREDS_PATH
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+
45
+ creds = load_credentials(path)
46
+ if data:
47
+ creds[profile] = data
48
+
49
+ with open(path, "w") as f:
50
+ json.dump(creds, f, indent=2)
51
+
52
+ # Set file permissions to 600 (owner read/write only)
53
+ try:
54
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
55
+ except OSError:
56
+ pass # Windows may not support chmod
57
+
58
+
59
+ def remove_credentials(
60
+ creds_file: Optional[Path] = None,
61
+ profile: str = "default",
62
+ ) -> Optional[str]:
63
+ """Remove a profile from credentials. Returns email if found."""
64
+ path = creds_file or CREDS_PATH
65
+ creds = load_credentials(path)
66
+ if profile not in creds:
67
+ return None
68
+
69
+ email = creds[profile].get("email", "unknown")
70
+ del creds[profile]
71
+
72
+ with open(path, "w") as f:
73
+ json.dump(creds, f, indent=2)
74
+ try:
75
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
76
+ except OSError:
77
+ pass
78
+
79
+ return email
80
+
81
+
82
+ def remove_all_credentials(creds_file: Optional[Path] = None) -> None:
83
+ """Delete the entire credentials file."""
84
+ path = creds_file or CREDS_PATH
85
+ if path.exists():
86
+ path.unlink()
87
+
88
+
89
+ def resolve_api_key(
90
+ api_key: Optional[str] = None,
91
+ profile: Optional[str] = None,
92
+ creds_file: Optional[Path] = None,
93
+ ) -> str:
94
+ """Resolve API key from env var, flag, or credentials file.
95
+
96
+ Priority: CUEAPI_API_KEY env > --api-key flag > profile from file.
97
+ """
98
+ env_key = os.environ.get("CUEAPI_API_KEY")
99
+ if env_key:
100
+ return env_key
101
+
102
+ if api_key:
103
+ return api_key
104
+
105
+ profile_name = profile or os.environ.get("CUEAPI_PROFILE", "default")
106
+ creds = load_credentials(creds_file)
107
+ if profile_name in creds:
108
+ return creds[profile_name]["api_key"]
109
+
110
+ raise click.ClickException(
111
+ "Not logged in. Run `cueapi login` to authenticate."
112
+ )
113
+
114
+
115
+ def resolve_api_base(
116
+ profile: Optional[str] = None,
117
+ creds_file: Optional[Path] = None,
118
+ ) -> str:
119
+ """Resolve API base URL from credentials or default."""
120
+ env_base = os.environ.get("CUEAPI_API_BASE")
121
+ if env_base:
122
+ return env_base
123
+
124
+ profile_name = profile or os.environ.get("CUEAPI_PROFILE", "default")
125
+ creds = load_credentials(creds_file)
126
+ if profile_name in creds:
127
+ return creds[profile_name].get("api_base", "https://api.cueapi.ai/v1")
128
+
129
+ return "https://api.cueapi.ai/v1"
130
+
131
+
132
+ def get_profile_info(
133
+ profile: Optional[str] = None,
134
+ creds_file: Optional[Path] = None,
135
+ ) -> Optional[Dict[str, Any]]:
136
+ """Get full profile data. Returns None if not found."""
137
+ profile_name = profile or os.environ.get("CUEAPI_PROFILE", "default")
138
+ creds = load_credentials(creds_file)
139
+ return creds.get(profile_name)
cueapi/formatting.py ADDED
@@ -0,0 +1,82 @@
1
+ """Terminal output formatting helpers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ def echo_error(message: str) -> None:
9
+ """Print an error message."""
10
+ import click
11
+ click.echo(click.style(f"Error: {message}", fg="red"), err=True)
12
+
13
+
14
+ def echo_success(message: str) -> None:
15
+ """Print a success message."""
16
+ import click
17
+ click.echo(click.style(message, fg="green"))
18
+
19
+
20
+ def echo_warning(message: str) -> None:
21
+ """Print a warning message."""
22
+ import click
23
+ click.echo(click.style(message, fg="yellow"))
24
+
25
+
26
+ def echo_info(label: str, value: str, label_width: int = 16) -> None:
27
+ """Print a label: value pair."""
28
+ import click
29
+ click.echo(f"{label:<{label_width}} {value}")
30
+
31
+
32
+ def echo_json(data: Any, indent: int = 2) -> None:
33
+ """Pretty-print JSON data."""
34
+ import click
35
+ click.echo(json.dumps(data, indent=indent))
36
+
37
+
38
+ def echo_table(headers: List[str], rows: List[List[str]], widths: Optional[List[int]] = None) -> None:
39
+ """Print a simple table with headers and rows."""
40
+ import click
41
+
42
+ if widths is None:
43
+ widths = []
44
+ for i, h in enumerate(headers):
45
+ col_max = len(h)
46
+ for row in rows:
47
+ if i < len(row):
48
+ col_max = max(col_max, len(str(row[i])))
49
+ widths.append(min(col_max + 2, 40))
50
+
51
+ # Header
52
+ header_line = ""
53
+ for i, h in enumerate(headers):
54
+ header_line += f"{h:<{widths[i]}}"
55
+ click.echo(click.style(header_line, bold=True))
56
+
57
+ # Rows
58
+ for row in rows:
59
+ line = ""
60
+ for i, cell in enumerate(row):
61
+ if i < len(widths):
62
+ line += f"{str(cell):<{widths[i]}}"
63
+ else:
64
+ line += str(cell)
65
+ click.echo(line)
66
+
67
+
68
+ def format_status(status: str) -> str:
69
+ """Format a status string with color."""
70
+ colors = {
71
+ "active": "green",
72
+ "paused": "yellow",
73
+ "completed": "cyan",
74
+ "failed": "red",
75
+ "success": "green",
76
+ "pending": "yellow",
77
+ "delivering": "yellow",
78
+ "retrying": "yellow",
79
+ }
80
+ import click
81
+ color = colors.get(status, "white")
82
+ return click.style(status, fg=color)
cueapi/quickstart.py ADDED
@@ -0,0 +1,113 @@
1
+ """Quickstart command — guided first-cue setup."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+ import time
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Optional
8
+
9
+ import click
10
+
11
+ from cueapi.client import CueAPIClient
12
+ from cueapi.formatting import echo_error, echo_json, echo_success
13
+
14
+
15
+ def do_quickstart(
16
+ api_key: Optional[str] = None,
17
+ profile: Optional[str] = None,
18
+ ) -> None:
19
+ """Run the quickstart flow: create test cue, verify delivery, clean up."""
20
+ try:
21
+ with CueAPIClient(api_key=api_key, profile=profile) as client:
22
+ click.echo("\nSetting up your first cue...\n")
23
+
24
+ # Generate unique echo token
25
+ echo_token = f"qs-{secrets.token_hex(8)}"
26
+
27
+ # Step 1: Create one-time cue scheduled for NOW + 15s
28
+ scheduled_time = datetime.now(timezone.utc) + timedelta(seconds=15)
29
+ scheduled_iso = scheduled_time.strftime("%Y-%m-%dT%H:%M:%SZ")
30
+
31
+ # The callback URL posts to the echo endpoint
32
+ callback_url = f"{client.api_base}/echo/{echo_token}"
33
+
34
+ click.echo("Step 1: Creating a test cue (fires in 15 seconds)...")
35
+ resp = client.post("/cues", json={
36
+ "name": "quickstart-test",
37
+ "description": "Quickstart test cue — safe to delete",
38
+ "schedule": {
39
+ "type": "once",
40
+ "at": scheduled_iso,
41
+ "timezone": "UTC",
42
+ },
43
+ "callback": {
44
+ "url": callback_url,
45
+ "method": "POST",
46
+ },
47
+ "payload": {"message": "Your first cue! CueAPI is working."},
48
+ })
49
+
50
+ if resp.status_code != 201:
51
+ error = resp.json().get("detail", {}).get("error", {})
52
+ echo_error(error.get("message", f"Failed to create cue (HTTP {resp.status_code})"))
53
+ return
54
+
55
+ cue = resp.json()
56
+ cue_id = cue["id"]
57
+ click.echo(f" Created: {cue_id}")
58
+ click.echo(f" Scheduled for: {scheduled_iso}\n")
59
+
60
+ # Step 2: Wait for delivery
61
+ click.echo("Step 2: Waiting for delivery...")
62
+ deadline = time.time() + 60
63
+ delivered = False
64
+ last_countdown = 15
65
+
66
+ while time.time() < deadline:
67
+ remaining = max(0, int(scheduled_time.timestamp() - time.time()))
68
+ if remaining > 0 and remaining < last_countdown:
69
+ click.echo(f" {remaining}s...", nl=False)
70
+ click.echo("\r", nl=False)
71
+ last_countdown = remaining
72
+
73
+ time.sleep(2)
74
+
75
+ # Poll echo endpoint
76
+ resp = client.get(f"/echo/{echo_token}")
77
+ if resp.status_code == 200:
78
+ data = resp.json()
79
+ if data.get("status") == "delivered":
80
+ click.echo(" ") # Clear countdown
81
+ echo_success(" Callback delivered!\n")
82
+ click.echo(" Payload:")
83
+ echo_json(data["payload"])
84
+ click.echo()
85
+ delivered = True
86
+ break
87
+
88
+ if not delivered:
89
+ click.echo()
90
+ echo_error("Timed out waiting for delivery.")
91
+ click.echo("\nTroubleshooting:")
92
+ click.echo(" - Is the poller running? (`python -m worker.poller`)")
93
+ click.echo(" - Is the arq worker running? (`python -m worker.main`)")
94
+ click.echo(f"\n Test cue ID: {cue_id}")
95
+ click.echo(f" Echo token: {echo_token}")
96
+ return
97
+
98
+ # Step 3: Clean up
99
+ click.echo("Step 3: Cleaning up test cue...")
100
+ resp = client.delete(f"/cues/{cue_id}")
101
+ if resp.status_code == 204:
102
+ click.echo(" Deleted.\n")
103
+ else:
104
+ click.echo(f" (cleanup: HTTP {resp.status_code})\n")
105
+
106
+ click.echo("CueAPI is working!\n")
107
+ click.echo("Next steps:")
108
+ click.echo(' cueapi create --name "my-cue" --cron "0 9 * * *" --url https://my-agent.com/webhook')
109
+ click.echo(" Docs: https://docs.cueapi.ai")
110
+ click.echo()
111
+
112
+ except click.ClickException as e:
113
+ click.echo(str(e))
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: cueapi
3
+ Version: 0.1.0
4
+ Summary: CLI for CueAPI — Your Agents' Cue to Act. The scheduling API for AI agents.
5
+ Author-email: "Vector Apps Inc." <hello@cueapi.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://cueapi.ai
8
+ Project-URL: Repository, https://github.com/govindkavaturi-art/cueapi-cli
9
+ Keywords: cueapi,ai-agents,scheduling,webhook,cron,cli
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: click>=8.0
19
+ Requires-Dist: httpx>=0.27
20
+ Dynamic: license-file
21
+
22
+ # CueAPI CLI
23
+
24
+ The official command-line interface for [CueAPI](https://cueapi.ai) — Your Agents' Cue to Act.
25
+
26
+ CueAPI is a scheduling API for AI agents. Agents register cues (scheduled tasks), CueAPI fires webhooks at the right time. No cron jobs. No infrastructure.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install cueapi
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ cueapi login
38
+ cueapi quickstart
39
+ cueapi create --name "morning-check" --cron "0 9 * * *" --url https://my-agent.com/webhook
40
+ ```
41
+
42
+ ## Commands
43
+
44
+ | Command | Description |
45
+ |---|---|
46
+ | `cueapi login` | Authenticate and store API key |
47
+ | `cueapi whoami` | Show current user and plan |
48
+ | `cueapi logout` | Remove local credentials |
49
+ | `cueapi quickstart` | Guided first-cue setup |
50
+ | `cueapi create` | Create a new cue |
51
+ | `cueapi list` | List all cues |
52
+ | `cueapi get <id>` | Get cue details |
53
+ | `cueapi pause <id>` | Pause a cue |
54
+ | `cueapi resume <id>` | Resume a cue |
55
+ | `cueapi delete <id>` | Delete a cue |
56
+ | `cueapi upgrade` | Open billing |
57
+ | `cueapi usage` | Show current usage |
58
+ | `cueapi key regenerate` | Regenerate API key |
59
+
60
+ ## Auth
61
+
62
+ Credentials stored in `~/.config/cueapi/credentials.json`.
63
+
64
+ Override: `export CUEAPI_API_KEY=cue_sk_your_key` or `--api-key` flag.
65
+
66
+ ## Links
67
+
68
+ - [Website](https://cueapi.ai)
69
+ - [API Reference](https://cueapi.ai/api)
70
+ - [Pricing](https://cueapi.ai/pricing)
71
+
72
+ ## License
73
+
74
+ MIT — Built by [Vector Apps Inc.](https://vectorapps.com)
@@ -0,0 +1,13 @@
1
+ cueapi/__init__.py,sha256=IbfT1t7l5aQq62NU5lL42A8t62bUfgWoDSzWBr2Qtdk,69
2
+ cueapi/auth.py,sha256=FP7_vJG5i7uJLjgiUzLdpItgZN2wYRrdxs44fG8SSco,6585
3
+ cueapi/cli.py,sha256=2SN1gTA4vezd8uynOzuTKB-baMc0ebcaAkn0H4O3yd8,17024
4
+ cueapi/client.py,sha256=KI-AH0mcw9-N-mqb25pz3kxnY-YVXsZQQQvETReARuQ,2293
5
+ cueapi/credentials.py,sha256=hEgtAR6o6adWftCfIj2Y3NVlnPHVOB0kmuReKtiCYk8,3893
6
+ cueapi/formatting.py,sha256=FNOL3_AqS1S4YaFnT_bjBeA8AicEfz7zVBcz4XhGimc,2220
7
+ cueapi/quickstart.py,sha256=MP-VLG4MOOCMuwOvGrzLPtj4xk6VqRmS0ybHKWO8IQA,4469
8
+ cueapi-0.1.0.dist-info/licenses/LICENSE,sha256=NTPnT6pMYtxSQeYVmnLrpTzndyDc6bIw5AgaWeTzirc,1073
9
+ cueapi-0.1.0.dist-info/METADATA,sha256=K5q8VKxINDIGfRDRcSh4u5qZBGqcItl-USN_d0e7WyM,2166
10
+ cueapi-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ cueapi-0.1.0.dist-info/entry_points.txt,sha256=2Ev2SY8FlOuiCx7Ytvp3hnxCRJTM8TE8do8cWZAk7dk,43
12
+ cueapi-0.1.0.dist-info/top_level.txt,sha256=lnExE-IhJePLTBluRyU1sNrssIuiCZfL2kvCLbyJMgo,7
13
+ cueapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cueapi = cueapi.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vector Apps Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cueapi