semanticapi-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- semanticapi_cli/__init__.py +12 -0
- semanticapi_cli/__main__.py +9 -0
- semanticapi_cli/api.py +129 -0
- semanticapi_cli/cli.py +476 -0
- semanticapi_cli/config.py +101 -0
- semanticapi_cli/output.py +355 -0
- semanticapi_cli-0.1.0.dist-info/METADATA +115 -0
- semanticapi_cli-0.1.0.dist-info/RECORD +12 -0
- semanticapi_cli-0.1.0.dist-info/WHEEL +5 -0
- semanticapi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- semanticapi_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- semanticapi_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semantic API CLI
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
A command-line interface for Semantic API - discover and query APIs with natural language.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python -m semanticapi_cli query "send an SMS"
|
|
9
|
+
semanticapi query "send an SMS" --key sapi_your_key
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
semanticapi_cli/api.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API client for Semantic API CLI.
|
|
3
|
+
|
|
4
|
+
Uses stdlib urllib only - no external dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from typing import Optional, Dict, Any, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(Exception):
|
|
15
|
+
"""Error from Semantic API."""
|
|
16
|
+
def __init__(self, message: str, status_code: int = 0, details: Optional[Dict] = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.details = details or {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIClient:
|
|
23
|
+
"""HTTP client for Semantic API."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None):
|
|
26
|
+
self.base_url = base_url.rstrip("/")
|
|
27
|
+
self.api_key = api_key
|
|
28
|
+
|
|
29
|
+
def _request(
|
|
30
|
+
self,
|
|
31
|
+
method: str,
|
|
32
|
+
path: str,
|
|
33
|
+
data: Optional[Dict] = None,
|
|
34
|
+
timeout: int = 60,
|
|
35
|
+
) -> Tuple[int, Dict[str, Any]]:
|
|
36
|
+
"""
|
|
37
|
+
Make HTTP request to API.
|
|
38
|
+
|
|
39
|
+
Returns (status_code, response_json).
|
|
40
|
+
Raises APIError on failure.
|
|
41
|
+
"""
|
|
42
|
+
url = f"{self.base_url}{path}"
|
|
43
|
+
|
|
44
|
+
headers = {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"Accept": "application/json",
|
|
47
|
+
"User-Agent": "semanticapi-cli/0.1.0",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if self.api_key:
|
|
51
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
52
|
+
|
|
53
|
+
body = json.dumps(data).encode("utf-8") if data else None
|
|
54
|
+
|
|
55
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
59
|
+
response_body = response.read().decode("utf-8")
|
|
60
|
+
try:
|
|
61
|
+
return response.status, json.loads(response_body)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return response.status, {"raw": response_body}
|
|
64
|
+
|
|
65
|
+
except urllib.error.HTTPError as e:
|
|
66
|
+
body = e.read().decode("utf-8") if e.fp else ""
|
|
67
|
+
try:
|
|
68
|
+
error_data = json.loads(body)
|
|
69
|
+
detail = error_data.get("detail", str(e))
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
detail = body or str(e)
|
|
72
|
+
|
|
73
|
+
raise APIError(detail, status_code=e.code, details={"body": body})
|
|
74
|
+
|
|
75
|
+
except urllib.error.URLError as e:
|
|
76
|
+
raise APIError(f"Connection failed: {e.reason}")
|
|
77
|
+
except TimeoutError:
|
|
78
|
+
raise APIError("Request timed out")
|
|
79
|
+
|
|
80
|
+
def query(self, query: str, auto_discover: bool = True) -> Dict[str, Any]:
|
|
81
|
+
"""Execute a natural language query."""
|
|
82
|
+
status, data = self._request("POST", "/api/query", {
|
|
83
|
+
"query": query,
|
|
84
|
+
"auto_discover": auto_discover,
|
|
85
|
+
})
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
def query_batch(self, queries: list) -> Dict[str, Any]:
|
|
89
|
+
"""Execute multiple queries in one call."""
|
|
90
|
+
status, data = self._request("POST", "/api/query/batch", {
|
|
91
|
+
"queries": queries,
|
|
92
|
+
})
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
def preflight(self, query: str) -> Dict[str, Any]:
|
|
96
|
+
"""Pre-analyze a query to identify needed providers."""
|
|
97
|
+
status, data = self._request("POST", "/api/query/preflight", {
|
|
98
|
+
"query": query,
|
|
99
|
+
})
|
|
100
|
+
return data
|
|
101
|
+
|
|
102
|
+
def agentic(self, query: str, execution_id: Optional[str] = None) -> Dict[str, Any]:
|
|
103
|
+
"""Execute a query with agentic (execution) mode."""
|
|
104
|
+
payload = {"query": query}
|
|
105
|
+
if execution_id:
|
|
106
|
+
payload["execution_id"] = execution_id
|
|
107
|
+
status, data = self._request("POST", "/api/query/agentic", payload, timeout=120)
|
|
108
|
+
return data
|
|
109
|
+
|
|
110
|
+
def discover(self, provider_name: str, user_intent: Optional[str] = None) -> Dict[str, Any]:
|
|
111
|
+
"""Discover a provider by name."""
|
|
112
|
+
payload = {"provider_name": provider_name}
|
|
113
|
+
if user_intent:
|
|
114
|
+
payload["user_intent"] = user_intent
|
|
115
|
+
status, data = self._request("POST", "/api/discover/search", payload, timeout=60)
|
|
116
|
+
return data
|
|
117
|
+
|
|
118
|
+
def discover_url(self, url: str, user_intent: Optional[str] = None) -> Dict[str, Any]:
|
|
119
|
+
"""Discover a provider from documentation URL."""
|
|
120
|
+
payload = {"url": url}
|
|
121
|
+
if user_intent:
|
|
122
|
+
payload["user_intent"] = user_intent
|
|
123
|
+
status, data = self._request("POST", "/api/discover/from-url", payload, timeout=60)
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
def health(self) -> Dict[str, Any]:
|
|
127
|
+
"""Get API health status."""
|
|
128
|
+
status, data = self._request("GET", "/api/health")
|
|
129
|
+
return data
|
semanticapi_cli/cli.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Semantic API CLI - Query APIs with natural language from your terminal.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
semanticapi query "send an SMS"
|
|
7
|
+
semanticapi query "send an SMS" --key sapi_your_key
|
|
8
|
+
semanticapi query "send an SMS" --execute
|
|
9
|
+
semanticapi batch "send SMS" "process payment" "get weather"
|
|
10
|
+
semanticapi preflight "send an SMS"
|
|
11
|
+
semanticapi discover "twilio"
|
|
12
|
+
semanticapi discover-url "https://docs.stripe.com/api"
|
|
13
|
+
semanticapi config set-key sapi_your_key
|
|
14
|
+
semanticapi status
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from typing import List, Optional
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .config import (
|
|
23
|
+
get_api_key,
|
|
24
|
+
get_base_url,
|
|
25
|
+
set_api_key,
|
|
26
|
+
set_base_url,
|
|
27
|
+
load_config,
|
|
28
|
+
clear_config,
|
|
29
|
+
CONFIG_FILE,
|
|
30
|
+
)
|
|
31
|
+
from .api import APIClient, APIError
|
|
32
|
+
from .output import (
|
|
33
|
+
print_error,
|
|
34
|
+
print_result,
|
|
35
|
+
format_query_result,
|
|
36
|
+
format_preflight_result,
|
|
37
|
+
format_discover_result,
|
|
38
|
+
format_status_result,
|
|
39
|
+
format_agentic_result,
|
|
40
|
+
success,
|
|
41
|
+
warning,
|
|
42
|
+
dim,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Exit codes
|
|
47
|
+
EXIT_SUCCESS = 0
|
|
48
|
+
EXIT_ERROR = 1
|
|
49
|
+
EXIT_AUTH_REQUIRED = 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
53
|
+
"""Create the argument parser with all subcommands."""
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="semanticapi",
|
|
56
|
+
description="Semantic API CLI - Query APIs with natural language",
|
|
57
|
+
epilog="Examples:\n"
|
|
58
|
+
" semanticapi query \"send an SMS via twilio\"\n"
|
|
59
|
+
" semanticapi preflight \"send an email\"\n"
|
|
60
|
+
" semanticapi discover sendgrid\n"
|
|
61
|
+
" semanticapi config set-key sapi_your_key",
|
|
62
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--version", "-v",
|
|
67
|
+
action="version",
|
|
68
|
+
version=f"semanticapi-cli {__version__}",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Global options
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--key", "-k",
|
|
74
|
+
metavar="KEY",
|
|
75
|
+
help="API key (overrides SEMANTICAPI_KEY env var and config file)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--url", "-u",
|
|
79
|
+
metavar="URL",
|
|
80
|
+
help="Base URL (default: https://semanticapi.dev)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--raw", "-r",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Output compact JSON (no formatting)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--quiet", "-q",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="Output minimal information",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
94
|
+
|
|
95
|
+
# ── query command ──────────────────────────────────────
|
|
96
|
+
query_parser = subparsers.add_parser(
|
|
97
|
+
"query",
|
|
98
|
+
help="Execute a natural language query",
|
|
99
|
+
description="Execute a natural language query against connected APIs.",
|
|
100
|
+
)
|
|
101
|
+
query_parser.add_argument(
|
|
102
|
+
"query",
|
|
103
|
+
help="Natural language query (e.g., 'send an SMS')",
|
|
104
|
+
)
|
|
105
|
+
query_parser.add_argument(
|
|
106
|
+
"--execute", "-x",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="Use agentic mode (actually execute API calls)",
|
|
109
|
+
)
|
|
110
|
+
query_parser.add_argument(
|
|
111
|
+
"--no-discover",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="Disable auto-discovery of unknown providers",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# ── batch command ──────────────────────────────────────
|
|
117
|
+
batch_parser = subparsers.add_parser(
|
|
118
|
+
"batch",
|
|
119
|
+
help="Execute multiple queries at once",
|
|
120
|
+
description="Execute multiple queries in a single API call (max 10).",
|
|
121
|
+
)
|
|
122
|
+
batch_parser.add_argument(
|
|
123
|
+
"queries",
|
|
124
|
+
nargs="+",
|
|
125
|
+
help="Queries to execute",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# ── preflight command ──────────────────────────────────
|
|
129
|
+
preflight_parser = subparsers.add_parser(
|
|
130
|
+
"preflight",
|
|
131
|
+
help="Pre-check a query (free, no LLM cost)",
|
|
132
|
+
description="Pre-analyze a query to identify required providers and their status.",
|
|
133
|
+
)
|
|
134
|
+
preflight_parser.add_argument(
|
|
135
|
+
"query",
|
|
136
|
+
help="Query to analyze",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# ── discover command ──────────────────────────────────
|
|
140
|
+
discover_parser = subparsers.add_parser(
|
|
141
|
+
"discover",
|
|
142
|
+
help="Discover a provider by name",
|
|
143
|
+
description="Search for and auto-discover a provider configuration by name.",
|
|
144
|
+
)
|
|
145
|
+
discover_parser.add_argument(
|
|
146
|
+
"provider",
|
|
147
|
+
help="Provider name (e.g., 'twilio', 'sendgrid')",
|
|
148
|
+
)
|
|
149
|
+
discover_parser.add_argument(
|
|
150
|
+
"--intent", "-i",
|
|
151
|
+
metavar="TEXT",
|
|
152
|
+
help="What you want to do (helps discover relevant capabilities)",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# ── discover-url command ───────────────────────────────
|
|
156
|
+
discover_url_parser = subparsers.add_parser(
|
|
157
|
+
"discover-url",
|
|
158
|
+
help="Discover a provider from documentation URL",
|
|
159
|
+
description="Generate a provider configuration from API documentation.",
|
|
160
|
+
)
|
|
161
|
+
discover_url_parser.add_argument(
|
|
162
|
+
"url",
|
|
163
|
+
help="API documentation URL",
|
|
164
|
+
)
|
|
165
|
+
discover_url_parser.add_argument(
|
|
166
|
+
"--intent", "-i",
|
|
167
|
+
metavar="TEXT",
|
|
168
|
+
help="What you want to do (guides capability extraction)",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# ── status command ─────────────────────────────────────
|
|
172
|
+
subparsers.add_parser(
|
|
173
|
+
"status",
|
|
174
|
+
help="Check API status and show config",
|
|
175
|
+
description="Check Semantic API health and show current configuration.",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ── config command ─────────────────────────────────────
|
|
179
|
+
config_parser = subparsers.add_parser(
|
|
180
|
+
"config",
|
|
181
|
+
help="Manage configuration",
|
|
182
|
+
description="View and update CLI configuration.",
|
|
183
|
+
)
|
|
184
|
+
config_subparsers = config_parser.add_subparsers(dest="config_command", metavar="ACTION")
|
|
185
|
+
|
|
186
|
+
# config set-key
|
|
187
|
+
set_key_parser = config_subparsers.add_parser(
|
|
188
|
+
"set-key",
|
|
189
|
+
help="Set default API key",
|
|
190
|
+
)
|
|
191
|
+
set_key_parser.add_argument("key", help="API key to save")
|
|
192
|
+
|
|
193
|
+
# config set-url
|
|
194
|
+
set_url_parser = config_subparsers.add_parser(
|
|
195
|
+
"set-url",
|
|
196
|
+
help="Set default base URL",
|
|
197
|
+
)
|
|
198
|
+
set_url_parser.add_argument("url", help="Base URL to save")
|
|
199
|
+
|
|
200
|
+
# config show
|
|
201
|
+
config_subparsers.add_parser(
|
|
202
|
+
"show",
|
|
203
|
+
help="Show current configuration",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# config clear
|
|
207
|
+
config_subparsers.add_parser(
|
|
208
|
+
"clear",
|
|
209
|
+
help="Clear all configuration",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return parser
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_query(args) -> int:
|
|
216
|
+
"""Handle query command."""
|
|
217
|
+
api_key = get_api_key(args.key)
|
|
218
|
+
base_url = get_base_url(args.url)
|
|
219
|
+
|
|
220
|
+
if not api_key:
|
|
221
|
+
print_error("API key required. Set with --key, SEMANTICAPI_KEY env var, or 'semanticapi config set-key'")
|
|
222
|
+
return EXIT_AUTH_REQUIRED
|
|
223
|
+
|
|
224
|
+
client = APIClient(base_url, api_key)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
if args.execute:
|
|
228
|
+
# Agentic mode
|
|
229
|
+
result = client.agentic(args.query)
|
|
230
|
+
print_result(result, raw=args.raw, quiet=args.quiet, formatter=format_agentic_result)
|
|
231
|
+
else:
|
|
232
|
+
# Regular query
|
|
233
|
+
result = client.query(args.query, auto_discover=not args.no_discover)
|
|
234
|
+
print_result(result, raw=args.raw, quiet=args.quiet, formatter=format_query_result)
|
|
235
|
+
|
|
236
|
+
return EXIT_SUCCESS if result.get("success") else EXIT_ERROR
|
|
237
|
+
|
|
238
|
+
except APIError as e:
|
|
239
|
+
if e.status_code == 401:
|
|
240
|
+
print_error("Invalid API key or authentication failed")
|
|
241
|
+
return EXIT_AUTH_REQUIRED
|
|
242
|
+
elif e.status_code == 429:
|
|
243
|
+
print_error("Rate limit exceeded. Try again later.")
|
|
244
|
+
return EXIT_ERROR
|
|
245
|
+
else:
|
|
246
|
+
print_error(str(e))
|
|
247
|
+
return EXIT_ERROR
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_batch(args) -> int:
|
|
251
|
+
"""Handle batch command."""
|
|
252
|
+
api_key = get_api_key(args.key)
|
|
253
|
+
base_url = get_base_url(args.url)
|
|
254
|
+
|
|
255
|
+
if not api_key:
|
|
256
|
+
print_error("API key required")
|
|
257
|
+
return EXIT_AUTH_REQUIRED
|
|
258
|
+
|
|
259
|
+
if len(args.queries) > 10:
|
|
260
|
+
print_error("Maximum 10 queries per batch")
|
|
261
|
+
return EXIT_ERROR
|
|
262
|
+
|
|
263
|
+
client = APIClient(base_url, api_key)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
result = client.query_batch(args.queries)
|
|
267
|
+
print_result(result, raw=args.raw, quiet=args.quiet)
|
|
268
|
+
|
|
269
|
+
# Check if all queries succeeded
|
|
270
|
+
results = result.get("results", [])
|
|
271
|
+
all_success = all(r.get("success") for r in results)
|
|
272
|
+
return EXIT_SUCCESS if all_success else EXIT_ERROR
|
|
273
|
+
|
|
274
|
+
except APIError as e:
|
|
275
|
+
if e.status_code == 401:
|
|
276
|
+
print_error("Invalid API key")
|
|
277
|
+
return EXIT_AUTH_REQUIRED
|
|
278
|
+
print_error(str(e))
|
|
279
|
+
return EXIT_ERROR
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_preflight(args) -> int:
|
|
283
|
+
"""Handle preflight command."""
|
|
284
|
+
api_key = get_api_key(args.key)
|
|
285
|
+
base_url = get_base_url(args.url)
|
|
286
|
+
|
|
287
|
+
if not api_key:
|
|
288
|
+
print_error("API key required")
|
|
289
|
+
return EXIT_AUTH_REQUIRED
|
|
290
|
+
|
|
291
|
+
client = APIClient(base_url, api_key)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
result = client.preflight(args.query)
|
|
295
|
+
print_result(result, raw=args.raw, quiet=args.quiet, formatter=format_preflight_result)
|
|
296
|
+
return EXIT_SUCCESS
|
|
297
|
+
|
|
298
|
+
except APIError as e:
|
|
299
|
+
if e.status_code == 401:
|
|
300
|
+
print_error("Invalid API key")
|
|
301
|
+
return EXIT_AUTH_REQUIRED
|
|
302
|
+
print_error(str(e))
|
|
303
|
+
return EXIT_ERROR
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cmd_discover(args) -> int:
|
|
307
|
+
"""Handle discover command."""
|
|
308
|
+
api_key = get_api_key(args.key)
|
|
309
|
+
base_url = get_base_url(args.url)
|
|
310
|
+
|
|
311
|
+
if not api_key:
|
|
312
|
+
print_error("API key required")
|
|
313
|
+
return EXIT_AUTH_REQUIRED
|
|
314
|
+
|
|
315
|
+
client = APIClient(base_url, api_key)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
result = client.discover(args.provider, user_intent=args.intent)
|
|
319
|
+
print_result(result, raw=args.raw, quiet=args.quiet, formatter=format_discover_result)
|
|
320
|
+
return EXIT_SUCCESS if result.get("success") else EXIT_ERROR
|
|
321
|
+
|
|
322
|
+
except APIError as e:
|
|
323
|
+
if e.status_code == 401:
|
|
324
|
+
print_error("Invalid API key")
|
|
325
|
+
return EXIT_AUTH_REQUIRED
|
|
326
|
+
print_error(str(e))
|
|
327
|
+
return EXIT_ERROR
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def cmd_discover_url(args) -> int:
|
|
331
|
+
"""Handle discover-url command."""
|
|
332
|
+
api_key = get_api_key(args.key)
|
|
333
|
+
base_url = get_base_url(args.url)
|
|
334
|
+
|
|
335
|
+
if not api_key:
|
|
336
|
+
print_error("API key required")
|
|
337
|
+
return EXIT_AUTH_REQUIRED
|
|
338
|
+
|
|
339
|
+
client = APIClient(base_url, api_key)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
result = client.discover_url(args.url, user_intent=args.intent)
|
|
343
|
+
print_result(result, raw=args.raw, quiet=args.quiet, formatter=format_discover_result)
|
|
344
|
+
return EXIT_SUCCESS if result.get("success") else EXIT_ERROR
|
|
345
|
+
|
|
346
|
+
except APIError as e:
|
|
347
|
+
if e.status_code == 401:
|
|
348
|
+
print_error("Invalid API key")
|
|
349
|
+
return EXIT_AUTH_REQUIRED
|
|
350
|
+
print_error(str(e))
|
|
351
|
+
return EXIT_ERROR
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def cmd_status(args) -> int:
|
|
355
|
+
"""Handle status command."""
|
|
356
|
+
base_url = get_base_url(args.url)
|
|
357
|
+
api_key = get_api_key(args.key)
|
|
358
|
+
|
|
359
|
+
# Show config first
|
|
360
|
+
if not args.quiet and not args.raw:
|
|
361
|
+
print(f" {dim('Config file:')} {CONFIG_FILE}")
|
|
362
|
+
print(f" {dim('Base URL:')} {base_url}")
|
|
363
|
+
if api_key:
|
|
364
|
+
masked = api_key[:8] + "..." + api_key[-4:] if len(api_key) > 12 else "***"
|
|
365
|
+
print(f" {dim('API key:')} {masked}")
|
|
366
|
+
else:
|
|
367
|
+
print(f" {dim('API key:')} {warning('not set')}")
|
|
368
|
+
print()
|
|
369
|
+
|
|
370
|
+
# Check API health (no auth required for health check)
|
|
371
|
+
client = APIClient(base_url, api_key)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
result = client.health()
|
|
375
|
+
print_result(result, raw=args.raw, quiet=args.quiet, formatter=format_status_result)
|
|
376
|
+
return EXIT_SUCCESS
|
|
377
|
+
|
|
378
|
+
except APIError as e:
|
|
379
|
+
print_error(f"Could not reach API: {e}")
|
|
380
|
+
return EXIT_ERROR
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def cmd_config(args) -> int:
|
|
384
|
+
"""Handle config command."""
|
|
385
|
+
if args.config_command == "set-key":
|
|
386
|
+
set_api_key(args.key)
|
|
387
|
+
print(success(f"✓ API key saved to {CONFIG_FILE}"))
|
|
388
|
+
return EXIT_SUCCESS
|
|
389
|
+
|
|
390
|
+
elif args.config_command == "set-url":
|
|
391
|
+
set_base_url(args.url)
|
|
392
|
+
print(success(f"✓ Base URL saved to {CONFIG_FILE}"))
|
|
393
|
+
return EXIT_SUCCESS
|
|
394
|
+
|
|
395
|
+
elif args.config_command == "show":
|
|
396
|
+
config = load_config()
|
|
397
|
+
if not config:
|
|
398
|
+
print(dim("No configuration file found."))
|
|
399
|
+
print(dim(f"Create one with: semanticapi config set-key YOUR_KEY"))
|
|
400
|
+
else:
|
|
401
|
+
print(f"Config file: {CONFIG_FILE}")
|
|
402
|
+
print()
|
|
403
|
+
for key, value in config.items():
|
|
404
|
+
if key == "api_key" and value:
|
|
405
|
+
# Mask API key
|
|
406
|
+
masked = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
|
|
407
|
+
print(f" {key}: {masked}")
|
|
408
|
+
else:
|
|
409
|
+
print(f" {key}: {value}")
|
|
410
|
+
return EXIT_SUCCESS
|
|
411
|
+
|
|
412
|
+
elif args.config_command == "clear":
|
|
413
|
+
clear_config()
|
|
414
|
+
print(success("✓ Configuration cleared"))
|
|
415
|
+
return EXIT_SUCCESS
|
|
416
|
+
|
|
417
|
+
else:
|
|
418
|
+
# No subcommand - show current config
|
|
419
|
+
return cmd_config_show(args)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def cmd_config_show(args) -> int:
|
|
423
|
+
"""Show current configuration."""
|
|
424
|
+
config = load_config()
|
|
425
|
+
if not config:
|
|
426
|
+
print(dim("No configuration file found."))
|
|
427
|
+
print(dim(f"Create one with: semanticapi config set-key YOUR_KEY"))
|
|
428
|
+
else:
|
|
429
|
+
print(f"Config file: {CONFIG_FILE}")
|
|
430
|
+
print()
|
|
431
|
+
for key, value in config.items():
|
|
432
|
+
if key == "api_key" and value:
|
|
433
|
+
masked = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
|
|
434
|
+
print(f" {key}: {masked}")
|
|
435
|
+
else:
|
|
436
|
+
print(f" {key}: {value}")
|
|
437
|
+
return EXIT_SUCCESS
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
441
|
+
"""Main entry point for CLI."""
|
|
442
|
+
parser = create_parser()
|
|
443
|
+
args = parser.parse_args(argv)
|
|
444
|
+
|
|
445
|
+
if not args.command:
|
|
446
|
+
parser.print_help()
|
|
447
|
+
return EXIT_SUCCESS
|
|
448
|
+
|
|
449
|
+
# Route to command handler
|
|
450
|
+
handlers = {
|
|
451
|
+
"query": cmd_query,
|
|
452
|
+
"batch": cmd_batch,
|
|
453
|
+
"preflight": cmd_preflight,
|
|
454
|
+
"discover": cmd_discover,
|
|
455
|
+
"discover-url": cmd_discover_url,
|
|
456
|
+
"status": cmd_status,
|
|
457
|
+
"config": cmd_config,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
handler = handlers.get(args.command)
|
|
461
|
+
if handler:
|
|
462
|
+
try:
|
|
463
|
+
return handler(args)
|
|
464
|
+
except KeyboardInterrupt:
|
|
465
|
+
print("\nInterrupted")
|
|
466
|
+
return EXIT_ERROR
|
|
467
|
+
except Exception as e:
|
|
468
|
+
print_error(f"Unexpected error: {e}")
|
|
469
|
+
return EXIT_ERROR
|
|
470
|
+
else:
|
|
471
|
+
parser.print_help()
|
|
472
|
+
return EXIT_ERROR
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
if __name__ == "__main__":
|
|
476
|
+
sys.exit(main())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Semantic API CLI.
|
|
3
|
+
|
|
4
|
+
Config priority: CLI args > environment variables > config file
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR = Path.home() / ".semanticapi"
|
|
14
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
15
|
+
DEFAULT_BASE_URL = "https://semanticapi.dev"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ensure_config_dir() -> None:
|
|
19
|
+
"""Create config directory if it doesn't exist."""
|
|
20
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config() -> Dict[str, Any]:
|
|
24
|
+
"""Load config from ~/.semanticapi/config.json."""
|
|
25
|
+
if not CONFIG_FILE.exists():
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
with open(CONFIG_FILE, "r") as f:
|
|
30
|
+
return json.load(f)
|
|
31
|
+
except (json.JSONDecodeError, IOError):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_config(config: Dict[str, Any]) -> None:
|
|
36
|
+
"""Save config to ~/.semanticapi/config.json."""
|
|
37
|
+
ensure_config_dir()
|
|
38
|
+
with open(CONFIG_FILE, "w") as f:
|
|
39
|
+
json.dump(config, f, indent=2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_api_key(cli_key: Optional[str] = None) -> Optional[str]:
|
|
43
|
+
"""
|
|
44
|
+
Get API key with priority: CLI arg > env var > config file.
|
|
45
|
+
|
|
46
|
+
Returns None if no key is found.
|
|
47
|
+
"""
|
|
48
|
+
# 1. CLI argument (highest priority)
|
|
49
|
+
if cli_key:
|
|
50
|
+
return cli_key
|
|
51
|
+
|
|
52
|
+
# 2. Environment variable
|
|
53
|
+
env_key = os.environ.get("SEMANTICAPI_KEY")
|
|
54
|
+
if env_key:
|
|
55
|
+
return env_key
|
|
56
|
+
|
|
57
|
+
# 3. Config file
|
|
58
|
+
config = load_config()
|
|
59
|
+
return config.get("api_key")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_base_url(cli_url: Optional[str] = None) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Get base URL with priority: CLI arg > env var > config file > default.
|
|
65
|
+
"""
|
|
66
|
+
# 1. CLI argument
|
|
67
|
+
if cli_url:
|
|
68
|
+
return cli_url.rstrip("/")
|
|
69
|
+
|
|
70
|
+
# 2. Environment variable
|
|
71
|
+
env_url = os.environ.get("SEMANTICAPI_URL")
|
|
72
|
+
if env_url:
|
|
73
|
+
return env_url.rstrip("/")
|
|
74
|
+
|
|
75
|
+
# 3. Config file
|
|
76
|
+
config = load_config()
|
|
77
|
+
if config.get("base_url"):
|
|
78
|
+
return config["base_url"].rstrip("/")
|
|
79
|
+
|
|
80
|
+
# 4. Default
|
|
81
|
+
return DEFAULT_BASE_URL
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def set_api_key(key: str) -> None:
|
|
85
|
+
"""Save API key to config file."""
|
|
86
|
+
config = load_config()
|
|
87
|
+
config["api_key"] = key
|
|
88
|
+
save_config(config)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_base_url(url: str) -> None:
|
|
92
|
+
"""Save base URL to config file."""
|
|
93
|
+
config = load_config()
|
|
94
|
+
config["base_url"] = url.rstrip("/")
|
|
95
|
+
save_config(config)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def clear_config() -> None:
|
|
99
|
+
"""Clear all config."""
|
|
100
|
+
if CONFIG_FILE.exists():
|
|
101
|
+
CONFIG_FILE.unlink()
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output formatting for Semantic API CLI.
|
|
3
|
+
|
|
4
|
+
Handles pretty JSON, compact JSON, and quiet mode output.
|
|
5
|
+
Uses ANSI colors when outputting to a TTY.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ANSI color codes
|
|
14
|
+
class Colors:
|
|
15
|
+
RESET = "\033[0m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
DIM = "\033[2m"
|
|
18
|
+
|
|
19
|
+
# Foreground
|
|
20
|
+
RED = "\033[31m"
|
|
21
|
+
GREEN = "\033[32m"
|
|
22
|
+
YELLOW = "\033[33m"
|
|
23
|
+
BLUE = "\033[34m"
|
|
24
|
+
MAGENTA = "\033[35m"
|
|
25
|
+
CYAN = "\033[36m"
|
|
26
|
+
WHITE = "\033[37m"
|
|
27
|
+
|
|
28
|
+
# Bright
|
|
29
|
+
BRIGHT_GREEN = "\033[92m"
|
|
30
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
31
|
+
BRIGHT_BLUE = "\033[94m"
|
|
32
|
+
BRIGHT_CYAN = "\033[96m"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_tty() -> bool:
|
|
36
|
+
"""Check if stdout is a TTY (supports colors)."""
|
|
37
|
+
return hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def colorize(text: str, color: str) -> str:
|
|
41
|
+
"""Apply color to text if outputting to TTY."""
|
|
42
|
+
if is_tty():
|
|
43
|
+
return f"{color}{text}{Colors.RESET}"
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def success(text: str) -> str:
|
|
48
|
+
"""Green success text."""
|
|
49
|
+
return colorize(text, Colors.GREEN)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def error(text: str) -> str:
|
|
53
|
+
"""Red error text."""
|
|
54
|
+
return colorize(text, Colors.RED)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def warning(text: str) -> str:
|
|
58
|
+
"""Yellow warning text."""
|
|
59
|
+
return colorize(text, Colors.YELLOW)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def info(text: str) -> str:
|
|
63
|
+
"""Blue info text."""
|
|
64
|
+
return colorize(text, Colors.BLUE)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dim(text: str) -> str:
|
|
68
|
+
"""Dimmed text."""
|
|
69
|
+
return colorize(text, Colors.DIM)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def bold(text: str) -> str:
|
|
73
|
+
"""Bold text."""
|
|
74
|
+
return colorize(text, Colors.BOLD)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_json(data: Any, raw: bool = False) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Format data as JSON.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
data: Data to format
|
|
83
|
+
raw: If True, compact JSON. Otherwise, pretty-printed.
|
|
84
|
+
"""
|
|
85
|
+
if raw:
|
|
86
|
+
return json.dumps(data, separators=(',', ':'))
|
|
87
|
+
return json.dumps(data, indent=2)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_query_result(data: Dict[str, Any], quiet: bool = False) -> str:
|
|
91
|
+
"""
|
|
92
|
+
Format a query result for display.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
data: Query response data
|
|
96
|
+
quiet: If True, show minimal output
|
|
97
|
+
"""
|
|
98
|
+
lines = []
|
|
99
|
+
|
|
100
|
+
if quiet:
|
|
101
|
+
# Minimal output: just the essential fields
|
|
102
|
+
if data.get("success"):
|
|
103
|
+
provider = data.get("provider", "?")
|
|
104
|
+
operation = data.get("operation", "?")
|
|
105
|
+
lines.append(f"{provider}.{operation}")
|
|
106
|
+
if data.get("data"):
|
|
107
|
+
# Show snippet or first key info
|
|
108
|
+
result_data = data["data"]
|
|
109
|
+
if isinstance(result_data, dict):
|
|
110
|
+
if "endpoint" in result_data:
|
|
111
|
+
lines.append(result_data["endpoint"])
|
|
112
|
+
elif "url" in result_data:
|
|
113
|
+
lines.append(result_data["url"])
|
|
114
|
+
else:
|
|
115
|
+
lines.append(error(data.get("error", "Unknown error")))
|
|
116
|
+
else:
|
|
117
|
+
# Full formatted output
|
|
118
|
+
if data.get("success"):
|
|
119
|
+
lines.append(success("✓ Query matched"))
|
|
120
|
+
lines.append("")
|
|
121
|
+
|
|
122
|
+
provider = data.get("provider", "unknown")
|
|
123
|
+
operation = data.get("operation", "unknown")
|
|
124
|
+
lines.append(f" {bold('Provider:')} {provider}")
|
|
125
|
+
lines.append(f" {bold('Operation:')} {operation}")
|
|
126
|
+
|
|
127
|
+
# Show parsed intent params if present
|
|
128
|
+
intent = data.get("parsed_intent", {})
|
|
129
|
+
params = intent.get("params", {})
|
|
130
|
+
if params:
|
|
131
|
+
lines.append(f" {bold('Parameters:')}")
|
|
132
|
+
for k, v in params.items():
|
|
133
|
+
lines.append(f" {dim(k)}: {v}")
|
|
134
|
+
|
|
135
|
+
# Show data/snippet if present
|
|
136
|
+
result_data = data.get("data", {})
|
|
137
|
+
if isinstance(result_data, dict):
|
|
138
|
+
if result_data.get("endpoint"):
|
|
139
|
+
lines.append("")
|
|
140
|
+
lines.append(f" {bold('Endpoint:')} {result_data['endpoint']}")
|
|
141
|
+
if result_data.get("curl"):
|
|
142
|
+
lines.append(f" {bold('cURL:')}")
|
|
143
|
+
lines.append(f" {dim(result_data['curl'][:200])}")
|
|
144
|
+
if result_data.get("python"):
|
|
145
|
+
lines.append(f" {bold('Python:')}")
|
|
146
|
+
for line in result_data['python'].split('\n')[:5]:
|
|
147
|
+
lines.append(f" {dim(line)}")
|
|
148
|
+
else:
|
|
149
|
+
lines.append(error("✗ Query failed"))
|
|
150
|
+
lines.append("")
|
|
151
|
+
lines.append(f" {data.get('error', 'Unknown error')}")
|
|
152
|
+
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def format_preflight_result(data: Dict[str, Any], quiet: bool = False) -> str:
|
|
157
|
+
"""Format a preflight result for display."""
|
|
158
|
+
lines = []
|
|
159
|
+
|
|
160
|
+
if quiet:
|
|
161
|
+
providers = data.get("providers", [])
|
|
162
|
+
ready = data.get("ready", False)
|
|
163
|
+
status = "ready" if ready else "needs_setup"
|
|
164
|
+
provider_list = ", ".join(p.get("provider", "?") for p in providers)
|
|
165
|
+
return f"{status}: {provider_list}"
|
|
166
|
+
|
|
167
|
+
lines.append(bold("Preflight Check"))
|
|
168
|
+
lines.append("")
|
|
169
|
+
lines.append(f" {bold('Query:')} {data.get('query', '?')}")
|
|
170
|
+
lines.append("")
|
|
171
|
+
|
|
172
|
+
providers = data.get("providers", [])
|
|
173
|
+
if providers:
|
|
174
|
+
lines.append(f" {bold('Providers needed:')}")
|
|
175
|
+
for p in providers:
|
|
176
|
+
name = p.get("provider", "?")
|
|
177
|
+
status = p.get("status", "?")
|
|
178
|
+
|
|
179
|
+
if status == "ready":
|
|
180
|
+
status_str = success("✓ ready")
|
|
181
|
+
elif status == "needs_auth":
|
|
182
|
+
status_str = warning("⚠ needs auth")
|
|
183
|
+
elif status == "needs_discovery":
|
|
184
|
+
status_str = info("? needs discovery")
|
|
185
|
+
else:
|
|
186
|
+
status_str = dim(status)
|
|
187
|
+
|
|
188
|
+
lines.append(f" • {name}: {status_str}")
|
|
189
|
+
if p.get("reason"):
|
|
190
|
+
lines.append(f" {dim(p['reason'])}")
|
|
191
|
+
|
|
192
|
+
lines.append("")
|
|
193
|
+
if data.get("ready"):
|
|
194
|
+
lines.append(success(" All providers ready! You can run this query."))
|
|
195
|
+
else:
|
|
196
|
+
lines.append(warning(" " + data.get("message", "Some setup required.")))
|
|
197
|
+
|
|
198
|
+
return "\n".join(lines)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def format_discover_result(data: Dict[str, Any], quiet: bool = False) -> str:
|
|
202
|
+
"""Format a discover result for display."""
|
|
203
|
+
lines = []
|
|
204
|
+
|
|
205
|
+
if quiet:
|
|
206
|
+
if data.get("success"):
|
|
207
|
+
provider = data.get("provider", {})
|
|
208
|
+
name = provider.get("name", "?")
|
|
209
|
+
caps = data.get("capabilities_found", 0)
|
|
210
|
+
return f"{name}: {caps} capabilities"
|
|
211
|
+
else:
|
|
212
|
+
return error(data.get("error", "Discovery failed"))
|
|
213
|
+
|
|
214
|
+
if data.get("success"):
|
|
215
|
+
lines.append(success("✓ Provider discovered"))
|
|
216
|
+
lines.append("")
|
|
217
|
+
|
|
218
|
+
provider = data.get("provider", {})
|
|
219
|
+
lines.append(f" {bold('Name:')} {provider.get('name', '?')}")
|
|
220
|
+
lines.append(f" {bold('ID:')} {provider.get('provider', '?')}")
|
|
221
|
+
lines.append(f" {bold('Base URL:')} {provider.get('base_url', '?')}")
|
|
222
|
+
lines.append(f" {bold('Capabilities:')} {data.get('capabilities_found', 0)}")
|
|
223
|
+
|
|
224
|
+
if provider.get("description"):
|
|
225
|
+
lines.append(f" {bold('Description:')} {provider['description'][:100]}")
|
|
226
|
+
|
|
227
|
+
if data.get("source_url") and data["source_url"] != "(cached)":
|
|
228
|
+
lines.append(f" {bold('Source:')} {data['source_url']}")
|
|
229
|
+
|
|
230
|
+
if data.get("usage_note"):
|
|
231
|
+
lines.append("")
|
|
232
|
+
lines.append(f" {dim(data['usage_note'])}")
|
|
233
|
+
|
|
234
|
+
# Show capabilities
|
|
235
|
+
caps = provider.get("capabilities", [])
|
|
236
|
+
if caps:
|
|
237
|
+
lines.append("")
|
|
238
|
+
lines.append(f" {bold('Capabilities:')}")
|
|
239
|
+
for cap in caps[:10]: # Show first 10
|
|
240
|
+
lines.append(f" • {cap.get('name', cap.get('id', '?'))}")
|
|
241
|
+
if len(caps) > 10:
|
|
242
|
+
lines.append(f" {dim(f'... and {len(caps) - 10} more')}")
|
|
243
|
+
else:
|
|
244
|
+
lines.append(error("✗ Discovery failed"))
|
|
245
|
+
lines.append("")
|
|
246
|
+
lines.append(f" {data.get('error', 'Unknown error')}")
|
|
247
|
+
|
|
248
|
+
return "\n".join(lines)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def format_status_result(data: Dict[str, Any], quiet: bool = False) -> str:
|
|
252
|
+
"""Format health/status result for display."""
|
|
253
|
+
lines = []
|
|
254
|
+
|
|
255
|
+
if quiet:
|
|
256
|
+
return data.get("status", "unknown")
|
|
257
|
+
|
|
258
|
+
status = data.get("status", "unknown")
|
|
259
|
+
if status == "ok":
|
|
260
|
+
lines.append(success("✓ Semantic API is healthy"))
|
|
261
|
+
elif status == "degraded":
|
|
262
|
+
lines.append(warning("⚠ Semantic API is degraded"))
|
|
263
|
+
else:
|
|
264
|
+
lines.append(error(f"✗ Status: {status}"))
|
|
265
|
+
|
|
266
|
+
lines.append("")
|
|
267
|
+
lines.append(f" {bold('Version:')} {data.get('version', '?')}")
|
|
268
|
+
|
|
269
|
+
if data.get("database"):
|
|
270
|
+
lines.append(f" {bold('Database:')} {data['database']}")
|
|
271
|
+
|
|
272
|
+
if data.get("cache"):
|
|
273
|
+
cache = data["cache"]
|
|
274
|
+
lines.append(f" {bold('Cache:')}")
|
|
275
|
+
lines.append(f" Memory size: {cache.get('memory_size', '?')}")
|
|
276
|
+
lines.append(f" Hits: {cache.get('memory_hits', '?')}")
|
|
277
|
+
|
|
278
|
+
return "\n".join(lines)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def format_agentic_result(data: Dict[str, Any], quiet: bool = False) -> str:
|
|
282
|
+
"""Format an agentic (execution) result for display."""
|
|
283
|
+
lines = []
|
|
284
|
+
|
|
285
|
+
if quiet:
|
|
286
|
+
if data.get("success"):
|
|
287
|
+
return data.get("response", "")[:200]
|
|
288
|
+
else:
|
|
289
|
+
return error(data.get("error", "Execution failed"))
|
|
290
|
+
|
|
291
|
+
if data.get("success"):
|
|
292
|
+
lines.append(success("✓ Execution complete"))
|
|
293
|
+
lines.append("")
|
|
294
|
+
|
|
295
|
+
# Show the synthesized response
|
|
296
|
+
response = data.get("response", "")
|
|
297
|
+
if response:
|
|
298
|
+
lines.append(f" {bold('Response:')}")
|
|
299
|
+
for line in response.split('\n'):
|
|
300
|
+
lines.append(f" {line}")
|
|
301
|
+
|
|
302
|
+
# Show steps taken
|
|
303
|
+
steps = data.get("steps", [])
|
|
304
|
+
if steps:
|
|
305
|
+
lines.append("")
|
|
306
|
+
lines.append(f" {bold('Steps:')} ({len(steps)} API calls)")
|
|
307
|
+
for i, step in enumerate(steps[:5], 1):
|
|
308
|
+
tool = step.get("tool", "?")
|
|
309
|
+
lines.append(f" {i}. {dim(tool)}")
|
|
310
|
+
if len(steps) > 5:
|
|
311
|
+
lines.append(f" {dim(f'... and {len(steps) - 5} more')}")
|
|
312
|
+
|
|
313
|
+
# Show status if not complete
|
|
314
|
+
status = data.get("status", "complete")
|
|
315
|
+
if status == "needs_input":
|
|
316
|
+
lines.append("")
|
|
317
|
+
lines.append(warning(f" ⚠ {data.get('question', 'Input needed')}"))
|
|
318
|
+
if data.get("options"):
|
|
319
|
+
for opt in data["options"]:
|
|
320
|
+
lines.append(f" • {opt}")
|
|
321
|
+
elif status == "awaiting_auth":
|
|
322
|
+
lines.append("")
|
|
323
|
+
lines.append(warning(" ⚠ Authentication required"))
|
|
324
|
+
lines.append(f" Execution ID: {data.get('execution_id', '?')}")
|
|
325
|
+
else:
|
|
326
|
+
lines.append(error("✗ Execution failed"))
|
|
327
|
+
lines.append("")
|
|
328
|
+
lines.append(f" {data.get('error', 'Unknown error')}")
|
|
329
|
+
|
|
330
|
+
return "\n".join(lines)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def print_error(message: str) -> None:
|
|
334
|
+
"""Print error message to stderr."""
|
|
335
|
+
print(error(f"Error: {message}"), file=sys.stderr)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def print_result(data: Any, raw: bool = False, quiet: bool = False, formatter=None) -> None:
|
|
339
|
+
"""
|
|
340
|
+
Print result to stdout.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
data: Data to print
|
|
344
|
+
raw: If True, output compact JSON
|
|
345
|
+
quiet: If True, output minimal info
|
|
346
|
+
formatter: Optional function to format non-JSON output
|
|
347
|
+
"""
|
|
348
|
+
if raw:
|
|
349
|
+
print(format_json(data, raw=True))
|
|
350
|
+
elif formatter and not quiet:
|
|
351
|
+
print(formatter(data, quiet=quiet))
|
|
352
|
+
elif quiet and formatter:
|
|
353
|
+
print(formatter(data, quiet=True))
|
|
354
|
+
else:
|
|
355
|
+
print(format_json(data))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semanticapi-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for Semantic API — discover and query 700+ APIs with natural language
|
|
5
|
+
Author-email: Peter Thompson <peter@coveai.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://semanticapi.dev
|
|
8
|
+
Project-URL: Documentation, https://semanticapi.dev/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/peter-j-thompson/semanticapi-cli
|
|
10
|
+
Project-URL: Issues, https://github.com/peter-j-thompson/semanticapi-cli/issues
|
|
11
|
+
Keywords: api,cli,semantic,discovery,mcp,ai-agents
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# Semantic API CLI
|
|
29
|
+
|
|
30
|
+
Query 700+ APIs with natural language from your terminal. Zero dependencies.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install semanticapi
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Save your API key
|
|
40
|
+
semanticapi config set-key sapi_your_key
|
|
41
|
+
|
|
42
|
+
# Query any API
|
|
43
|
+
semanticapi query "send an SMS via Twilio"
|
|
44
|
+
|
|
45
|
+
# Pre-check what you'll need (free, no LLM cost)
|
|
46
|
+
semanticapi preflight "send an email"
|
|
47
|
+
|
|
48
|
+
# Discover a provider
|
|
49
|
+
semanticapi discover stripe
|
|
50
|
+
|
|
51
|
+
# Batch queries
|
|
52
|
+
semanticapi batch "send email" "upload file" "translate text"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
| Command | Description |
|
|
58
|
+
|---------|-------------|
|
|
59
|
+
| `query` | Natural language API query |
|
|
60
|
+
| `batch` | Multiple queries in one call |
|
|
61
|
+
| `preflight` | Pre-check (free, identifies needed auth) |
|
|
62
|
+
| `discover` | Look up a provider by name |
|
|
63
|
+
| `discover-url` | Discover provider from docs URL |
|
|
64
|
+
| `status` | Show config and API health |
|
|
65
|
+
| `config` | Manage API key and settings |
|
|
66
|
+
|
|
67
|
+
## Authentication
|
|
68
|
+
|
|
69
|
+
API key priority (first found wins):
|
|
70
|
+
|
|
71
|
+
1. `--key sapi_xxx` flag
|
|
72
|
+
2. `SEMANTICAPI_KEY` environment variable
|
|
73
|
+
3. `~/.semanticapi/config.json` (saved via `config set-key`)
|
|
74
|
+
|
|
75
|
+
Get your key at [semanticapi.dev](https://semanticapi.dev).
|
|
76
|
+
|
|
77
|
+
## Output Modes
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Pretty-printed (default)
|
|
81
|
+
semanticapi query "get weather"
|
|
82
|
+
|
|
83
|
+
# Raw JSON (for piping)
|
|
84
|
+
semanticapi --raw query "get weather"
|
|
85
|
+
|
|
86
|
+
# Minimal output
|
|
87
|
+
semanticapi --quiet query "get weather"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Exit Codes
|
|
91
|
+
|
|
92
|
+
| Code | Meaning |
|
|
93
|
+
|------|---------|
|
|
94
|
+
| 0 | Success |
|
|
95
|
+
| 1 | Error |
|
|
96
|
+
| 2 | Auth required |
|
|
97
|
+
|
|
98
|
+
## What You Get Back
|
|
99
|
+
|
|
100
|
+
Every query returns:
|
|
101
|
+
- **Provider** and endpoint details
|
|
102
|
+
- **Code snippets** (curl + Python) ready to copy-paste
|
|
103
|
+
- **Auth requirements** and setup instructions
|
|
104
|
+
- **Alternative providers** ranked by relevance
|
|
105
|
+
|
|
106
|
+
## Related
|
|
107
|
+
|
|
108
|
+
- [Semantic API](https://semanticapi.dev) — The API
|
|
109
|
+
- [MCP Server](https://pypi.org/project/semanticapi-mcp/) — For Claude Desktop / ChatGPT
|
|
110
|
+
- [Agent Skill](https://pypi.org/project/semantic-api-skill/) — For autonomous agents
|
|
111
|
+
- [Open Source Engine](https://github.com/peter-j-thompson/semanticapi-engine) — AGPL-3.0
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
semanticapi_cli/__init__.py,sha256=ez-HFyWySAGAhL-56yFZOi6tQaEzi8VrvpAuslHO1EM,271
|
|
2
|
+
semanticapi_cli/__main__.py,sha256=rNB4M8zxt72oLEyPXODI0h_0ZbBXCq7O9QeoGH5HE5Y,133
|
|
3
|
+
semanticapi_cli/api.py,sha256=Hb70he3-bGiClbeIQtljf09WS2mxXcRukVWd_pAOfGs,4566
|
|
4
|
+
semanticapi_cli/cli.py,sha256=ytTRq4uz1rl2GgCG2CezTvw_4GyTdqqHxqR76mszKF4,14979
|
|
5
|
+
semanticapi_cli/config.py,sha256=__lObK74bxrSVYwzWDTvDRT9DAybuj8_0Ss3eqg16TQ,2431
|
|
6
|
+
semanticapi_cli/output.py,sha256=POXCQBIMqjyJiTQmOwsrsvZfbzX3ZWfda_SgKmdkUOI,11640
|
|
7
|
+
semanticapi_cli-0.1.0.dist-info/licenses/LICENSE,sha256=EzlU4nH4j0f_qGkSfmVtX1WuiAVu3p-cXgpo4LK7ePw,1081
|
|
8
|
+
semanticapi_cli-0.1.0.dist-info/METADATA,sha256=X6t-RcvWHFZ8E130Y9ulsuiTU84ExkSucrMF4D4KcCk,3188
|
|
9
|
+
semanticapi_cli-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
10
|
+
semanticapi_cli-0.1.0.dist-info/entry_points.txt,sha256=dryK67Wwwl2wRVxBGuFi-6WobL-vIKKee61DExBj7SE,57
|
|
11
|
+
semanticapi_cli-0.1.0.dist-info/top_level.txt,sha256=WB902-mnUPAWXRpVC7qSnYXFjtIr02QzbCoygfes-xA,16
|
|
12
|
+
semanticapi_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter Thompson / Cove AI
|
|
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
|
+
semanticapi_cli
|