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.
@@ -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"
@@ -0,0 +1,9 @@
1
+ """
2
+ Entry point for python -m semanticapi_cli
3
+ """
4
+
5
+ import sys
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ semanticapi = semanticapi_cli.cli:main
@@ -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