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 +3 -0
- cueapi/auth.py +185 -0
- cueapi/cli.py +468 -0
- cueapi/client.py +77 -0
- cueapi/credentials.py +139 -0
- cueapi/formatting.py +82 -0
- cueapi/quickstart.py +113 -0
- cueapi-0.1.0.dist-info/METADATA +74 -0
- cueapi-0.1.0.dist-info/RECORD +13 -0
- cueapi-0.1.0.dist-info/WHEEL +5 -0
- cueapi-0.1.0.dist-info/entry_points.txt +2 -0
- cueapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- cueapi-0.1.0.dist-info/top_level.txt +1 -0
cueapi/__init__.py
ADDED
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,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
|