constants-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- constants_cli-0.1.0/.gitignore +41 -0
- constants_cli-0.1.0/PKG-INFO +54 -0
- constants_cli-0.1.0/README.md +44 -0
- constants_cli-0.1.0/pyproject.toml +21 -0
- constants_cli-0.1.0/src/constants_cli/__init__.py +3 -0
- constants_cli-0.1.0/src/constants_cli/auth.py +44 -0
- constants_cli-0.1.0/src/constants_cli/client.py +151 -0
- constants_cli-0.1.0/src/constants_cli/commands/__init__.py +5 -0
- constants_cli-0.1.0/src/constants_cli/commands/auth.py +29 -0
- constants_cli-0.1.0/src/constants_cli/commands/list_tools.py +29 -0
- constants_cli-0.1.0/src/constants_cli/commands/run.py +183 -0
- constants_cli-0.1.0/src/constants_cli/exceptions.py +29 -0
- constants_cli-0.1.0/src/constants_cli/main.py +16 -0
- constants_cli-0.1.0/src/constants_cli/output.py +37 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
/coverage
|
|
15
|
+
|
|
16
|
+
# next.js
|
|
17
|
+
/.next/
|
|
18
|
+
/out/
|
|
19
|
+
|
|
20
|
+
# production
|
|
21
|
+
/build
|
|
22
|
+
|
|
23
|
+
# misc
|
|
24
|
+
.DS_Store
|
|
25
|
+
*.pem
|
|
26
|
+
|
|
27
|
+
# debug
|
|
28
|
+
npm-debug.log*
|
|
29
|
+
yarn-debug.log*
|
|
30
|
+
yarn-error.log*
|
|
31
|
+
.pnpm-debug.log*
|
|
32
|
+
|
|
33
|
+
# env files (can opt-in for committing if needed)
|
|
34
|
+
.env*
|
|
35
|
+
|
|
36
|
+
# vercel
|
|
37
|
+
.vercel
|
|
38
|
+
|
|
39
|
+
# typescript
|
|
40
|
+
*.tsbuildinfo
|
|
41
|
+
next-env.d.ts
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: constants-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for running Constants.ai tools from the command line
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: click>=8.0
|
|
8
|
+
Requires-Dist: requests>=2.28
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# constants-cli
|
|
12
|
+
|
|
13
|
+
CLI for running [Constants](https://app.constants.ai) tools from the command line.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv tool install constants-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Authenticate (or set CONSTANTS_API_KEY env var)
|
|
25
|
+
constants auth
|
|
26
|
+
|
|
27
|
+
# List your tools
|
|
28
|
+
constants list
|
|
29
|
+
|
|
30
|
+
# Run a tool
|
|
31
|
+
constants run my_tool_abc12345 query="hello world"
|
|
32
|
+
|
|
33
|
+
# Run with file input (auto-uploaded)
|
|
34
|
+
constants run pdf_tool_abc12345 pdf_file=./report.pdf
|
|
35
|
+
|
|
36
|
+
# Run with JSON input
|
|
37
|
+
constants run my_tool_abc12345 --input-json '{"query": "hello", "limit": 10}'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Authentication
|
|
41
|
+
|
|
42
|
+
Set your API key via environment variable:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export CONSTANTS_API_KEY="wk_your_key_here"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or store it persistently:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
constants auth
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Get your API key at [app.constants.ai/settings](https://app.constants.ai/settings).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# constants-cli
|
|
2
|
+
|
|
3
|
+
CLI for running [Constants](https://app.constants.ai) tools from the command line.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install constants-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Authenticate (or set CONSTANTS_API_KEY env var)
|
|
15
|
+
constants auth
|
|
16
|
+
|
|
17
|
+
# List your tools
|
|
18
|
+
constants list
|
|
19
|
+
|
|
20
|
+
# Run a tool
|
|
21
|
+
constants run my_tool_abc12345 query="hello world"
|
|
22
|
+
|
|
23
|
+
# Run with file input (auto-uploaded)
|
|
24
|
+
constants run pdf_tool_abc12345 pdf_file=./report.pdf
|
|
25
|
+
|
|
26
|
+
# Run with JSON input
|
|
27
|
+
constants run my_tool_abc12345 --input-json '{"query": "hello", "limit": 10}'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Authentication
|
|
31
|
+
|
|
32
|
+
Set your API key via environment variable:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export CONSTANTS_API_KEY="wk_your_key_here"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or store it persistently:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
constants auth
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Get your API key at [app.constants.ai/settings](https://app.constants.ai/settings).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "constants-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for running Constants.ai tools from the command line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"click>=8.0",
|
|
14
|
+
"requests>=2.28",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
constants = "constants_cli.main:cli"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/constants_cli"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""API key resolution: env var -> config file -> error."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".constants"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
9
|
+
ENV_VAR = "CONSTANTS_API_KEY"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_api_key() -> str | None:
|
|
13
|
+
"""Resolve API key from env var or config file."""
|
|
14
|
+
key = os.environ.get(ENV_VAR)
|
|
15
|
+
if key:
|
|
16
|
+
return key
|
|
17
|
+
|
|
18
|
+
if CONFIG_FILE.exists():
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(CONFIG_FILE.read_text())
|
|
21
|
+
return data.get("api_key")
|
|
22
|
+
except (json.JSONDecodeError, OSError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_api_key(key: str) -> None:
|
|
29
|
+
"""Persist API key to config file."""
|
|
30
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
CONFIG_FILE.write_text(json.dumps({"api_key": key}, indent=2) + "\n")
|
|
32
|
+
CONFIG_FILE.chmod(0o600)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def require_api_key() -> str:
|
|
36
|
+
"""Get API key or raise with instructions."""
|
|
37
|
+
key = get_api_key()
|
|
38
|
+
if not key:
|
|
39
|
+
raise SystemExit(
|
|
40
|
+
"No API key found.\n"
|
|
41
|
+
f"Set {ENV_VAR} or run: constants auth\n"
|
|
42
|
+
"Get your key at https://app.constants.ai/settings"
|
|
43
|
+
)
|
|
44
|
+
return key
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""HTTP client for the Constants v1 REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from constants_cli.auth import require_api_key
|
|
12
|
+
from constants_cli.exceptions import AuthError, NotFoundError, ExecutionError
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://app.constants.ai"
|
|
15
|
+
ENV_BASE_URL = "CONSTANTS_API_URL"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConstantsClient:
|
|
19
|
+
"""Thin HTTP wrapper around /api/v1/* endpoints."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None):
|
|
22
|
+
self.api_key = api_key or require_api_key()
|
|
23
|
+
self.base_url = (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
|
|
24
|
+
self._tools_cache: list[dict[str, Any]] | None = None
|
|
25
|
+
|
|
26
|
+
def _headers(self) -> dict[str, str]:
|
|
27
|
+
return {
|
|
28
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def _handle_error(self, resp: requests.Response) -> None:
|
|
33
|
+
if resp.status_code == 200 or resp.status_code == 201:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
body = resp.json()
|
|
38
|
+
message = body.get("message") or body.get("error") or resp.text
|
|
39
|
+
except Exception:
|
|
40
|
+
message = resp.text or f"HTTP {resp.status_code}"
|
|
41
|
+
|
|
42
|
+
if resp.status_code in (401, 403):
|
|
43
|
+
raise AuthError(message, resp.status_code)
|
|
44
|
+
if resp.status_code == 404:
|
|
45
|
+
raise NotFoundError(message, resp.status_code)
|
|
46
|
+
raise ExecutionError(message, resp.status_code)
|
|
47
|
+
|
|
48
|
+
# ── Tool listing ─────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
51
|
+
"""Fetch all available tools. Returns the raw tools array."""
|
|
52
|
+
resp = requests.get(f"{self.base_url}/api/v1/tools", headers=self._headers())
|
|
53
|
+
self._handle_error(resp)
|
|
54
|
+
tools = resp.json().get("tools", [])
|
|
55
|
+
self._tools_cache = tools
|
|
56
|
+
return tools
|
|
57
|
+
|
|
58
|
+
def get_tool(self, name: str) -> dict[str, Any] | None:
|
|
59
|
+
"""Look up a single tool by name (uses cache if available)."""
|
|
60
|
+
tools = self._tools_cache if self._tools_cache is not None else self.list_tools()
|
|
61
|
+
for t in tools:
|
|
62
|
+
if t["name"] == name:
|
|
63
|
+
return t
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# ── Tool execution ───────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def run_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
"""Execute a tool and return the full response."""
|
|
70
|
+
resp = requests.post(
|
|
71
|
+
f"{self.base_url}/api/v1/run/{name}",
|
|
72
|
+
headers=self._headers(),
|
|
73
|
+
json=args,
|
|
74
|
+
)
|
|
75
|
+
self._handle_error(resp)
|
|
76
|
+
return resp.json()
|
|
77
|
+
|
|
78
|
+
# ── File upload ──────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def upload_file(self, tool_name: str, field_name: str, file_path: str | Path) -> str:
|
|
81
|
+
"""Upload a local file for a tool field. Returns the upload ID."""
|
|
82
|
+
path = Path(file_path)
|
|
83
|
+
if not path.is_file():
|
|
84
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
85
|
+
|
|
86
|
+
resp = requests.post(
|
|
87
|
+
f"{self.base_url}/api/v1/upload",
|
|
88
|
+
headers=self._headers(),
|
|
89
|
+
json={
|
|
90
|
+
"toolName": tool_name,
|
|
91
|
+
"fieldName": field_name,
|
|
92
|
+
"filename": path.name,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
self._handle_error(resp)
|
|
96
|
+
data = resp.json()
|
|
97
|
+
|
|
98
|
+
signed_url = data["signedUrl"]
|
|
99
|
+
mime_type = data.get("mimeType", "application/octet-stream")
|
|
100
|
+
|
|
101
|
+
put_resp = requests.put(
|
|
102
|
+
signed_url,
|
|
103
|
+
headers={"Content-Type": mime_type},
|
|
104
|
+
data=path.read_bytes(),
|
|
105
|
+
)
|
|
106
|
+
if put_resp.status_code not in (200, 201):
|
|
107
|
+
raise ExecutionError(f"File upload PUT failed: HTTP {put_resp.status_code}")
|
|
108
|
+
|
|
109
|
+
return data["uploadId"]
|
|
110
|
+
|
|
111
|
+
def upload_directory(self, tool_name: str, field_name: str, dir_path: str | Path) -> str:
|
|
112
|
+
"""Zip a directory and upload it. Returns the upload ID."""
|
|
113
|
+
import tempfile
|
|
114
|
+
import zipfile
|
|
115
|
+
|
|
116
|
+
path = Path(dir_path)
|
|
117
|
+
if not path.is_dir():
|
|
118
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
119
|
+
|
|
120
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
|
121
|
+
tmp_path = Path(tmp.name)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
125
|
+
for file in path.rglob("*"):
|
|
126
|
+
if file.is_file():
|
|
127
|
+
zf.write(file, file.relative_to(path))
|
|
128
|
+
|
|
129
|
+
resp = requests.post(
|
|
130
|
+
f"{self.base_url}/api/v1/upload",
|
|
131
|
+
headers=self._headers(),
|
|
132
|
+
json={
|
|
133
|
+
"toolName": tool_name,
|
|
134
|
+
"fieldName": field_name,
|
|
135
|
+
"filename": f"{path.name}.zip",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
self._handle_error(resp)
|
|
139
|
+
data = resp.json()
|
|
140
|
+
|
|
141
|
+
put_resp = requests.put(
|
|
142
|
+
data["signedUrl"],
|
|
143
|
+
headers={"Content-Type": "application/zip"},
|
|
144
|
+
data=tmp_path.read_bytes(),
|
|
145
|
+
)
|
|
146
|
+
if put_resp.status_code not in (200, 201):
|
|
147
|
+
raise ExecutionError(f"Directory upload PUT failed: HTTP {put_resp.status_code}")
|
|
148
|
+
|
|
149
|
+
return data["uploadId"]
|
|
150
|
+
finally:
|
|
151
|
+
tmp_path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""constants auth - store API key."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from constants_cli.main import cli
|
|
6
|
+
from constants_cli.auth import save_api_key
|
|
7
|
+
from constants_cli.client import ConstantsClient
|
|
8
|
+
from constants_cli.output import print_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cli.command("auth")
|
|
12
|
+
def auth():
|
|
13
|
+
"""Store your Constants API key."""
|
|
14
|
+
key = click.prompt("Enter your API key", hide_input=True)
|
|
15
|
+
|
|
16
|
+
if not key.startswith("wk_"):
|
|
17
|
+
print_error("Invalid key format. Keys start with 'wk_'.")
|
|
18
|
+
raise SystemExit(1)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
client = ConstantsClient(api_key=key)
|
|
22
|
+
tools = client.list_tools()
|
|
23
|
+
click.echo(f"Authenticated. {len(tools)} tool(s) available.")
|
|
24
|
+
except Exception as e:
|
|
25
|
+
print_error(f"Key validation failed: {e}")
|
|
26
|
+
raise SystemExit(1)
|
|
27
|
+
|
|
28
|
+
save_api_key(key)
|
|
29
|
+
click.echo("API key saved to ~/.constants/config.json")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""constants list - list available tools."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from constants_cli.main import cli
|
|
6
|
+
from constants_cli.client import ConstantsClient
|
|
7
|
+
from constants_cli.auth import require_api_key
|
|
8
|
+
from constants_cli.output import print_json, print_table, print_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cli.command("list")
|
|
12
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
13
|
+
def list_tools(as_json: bool):
|
|
14
|
+
"""List available tools."""
|
|
15
|
+
api_key = require_api_key()
|
|
16
|
+
client = ConstantsClient(api_key=api_key)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
tools = client.list_tools()
|
|
20
|
+
except Exception as e:
|
|
21
|
+
print_error(str(e))
|
|
22
|
+
raise SystemExit(1)
|
|
23
|
+
|
|
24
|
+
if as_json:
|
|
25
|
+
print_json(tools)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
rows = [{"name": t["name"], "description": (t.get("description") or "")[:80]} for t in tools]
|
|
29
|
+
print_table(rows, [("name", "Name"), ("description", "Description")])
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""constants run - execute a tool with key=value args and automatic file upload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from constants_cli.main import cli
|
|
15
|
+
from constants_cli.auth import require_api_key
|
|
16
|
+
from constants_cli.client import ConstantsClient
|
|
17
|
+
from constants_cli.exceptions import ValidationError
|
|
18
|
+
from constants_cli.output import print_json, print_error
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _coerce_value(raw: str, schema_type: str | None) -> Any:
|
|
22
|
+
"""Coerce a string value using the tool schema type."""
|
|
23
|
+
if schema_type == "boolean":
|
|
24
|
+
return raw.lower() in ("true", "1", "yes")
|
|
25
|
+
if schema_type in ("number", "integer"):
|
|
26
|
+
try:
|
|
27
|
+
return int(raw) if schema_type == "integer" else float(raw)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return raw
|
|
30
|
+
return raw
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_key_value_args(
|
|
34
|
+
pairs: tuple[str, ...],
|
|
35
|
+
input_schema: dict[str, Any] | None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Parse key=value pairs, coercing types via the tool's input schema."""
|
|
38
|
+
type_map: dict[str, str] = {}
|
|
39
|
+
if input_schema:
|
|
40
|
+
for prop in input_schema.get("properties", {}):
|
|
41
|
+
if isinstance(prop, str):
|
|
42
|
+
info = input_schema["properties"][prop]
|
|
43
|
+
type_map[prop] = info.get("type", "string")
|
|
44
|
+
elif isinstance(prop, dict):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
if isinstance(input_schema.get("properties"), dict):
|
|
48
|
+
for name, info in input_schema["properties"].items():
|
|
49
|
+
type_map[name] = info.get("type", "string")
|
|
50
|
+
|
|
51
|
+
result: dict[str, Any] = {}
|
|
52
|
+
for pair in pairs:
|
|
53
|
+
if "=" not in pair:
|
|
54
|
+
print_error(f"Invalid argument '{pair}'. Use key=value format.")
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
key, value = pair.split("=", 1)
|
|
57
|
+
result[key] = _coerce_value(value, type_map.get(key))
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate_file_accept(file_path: Path, accept: list[str] | None) -> None:
|
|
62
|
+
"""Validate a file's extension against accept patterns."""
|
|
63
|
+
if not accept:
|
|
64
|
+
return
|
|
65
|
+
name = file_path.name.lower()
|
|
66
|
+
ext = file_path.suffix.lower()
|
|
67
|
+
for pattern in accept:
|
|
68
|
+
if pattern.startswith(".") and ext == pattern.lower():
|
|
69
|
+
return
|
|
70
|
+
if "/" in pattern and fnmatch.fnmatch(f"x/{name}", f"x/{pattern.split('/')[1]}"):
|
|
71
|
+
return
|
|
72
|
+
if pattern == "*/*" or pattern == "*":
|
|
73
|
+
return
|
|
74
|
+
raise ValidationError(
|
|
75
|
+
f"File '{file_path.name}' does not match accepted types: {', '.join(accept)}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _handle_file_uploads(
|
|
80
|
+
client: ConstantsClient,
|
|
81
|
+
tool_name: str,
|
|
82
|
+
args: dict[str, Any],
|
|
83
|
+
file_fields: list[dict[str, Any]],
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""Detect local file paths in args, upload them, and substitute references."""
|
|
86
|
+
file_field_map = {f["name"]: f for f in file_fields}
|
|
87
|
+
result = dict(args)
|
|
88
|
+
|
|
89
|
+
for field_name, field_info in file_field_map.items():
|
|
90
|
+
value = result.get(field_name)
|
|
91
|
+
if value is None:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
field_type = field_info.get("type", "file")
|
|
95
|
+
accept = field_info.get("accept")
|
|
96
|
+
multiple = field_info.get("multiple", False)
|
|
97
|
+
|
|
98
|
+
if field_type == "directory":
|
|
99
|
+
dir_path = Path(str(value))
|
|
100
|
+
if dir_path.is_dir():
|
|
101
|
+
click.echo(f"Uploading directory {dir_path}...", err=True)
|
|
102
|
+
upload_id = client.upload_directory(tool_name, field_name, dir_path)
|
|
103
|
+
result[field_name] = upload_id
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if multiple:
|
|
107
|
+
paths = [Path(p.strip()) for p in str(value).split(",")]
|
|
108
|
+
upload_ids = []
|
|
109
|
+
for p in paths:
|
|
110
|
+
if p.is_file():
|
|
111
|
+
_validate_file_accept(p, accept)
|
|
112
|
+
click.echo(f"Uploading {p}...", err=True)
|
|
113
|
+
upload_ids.append(client.upload_file(tool_name, field_name, p))
|
|
114
|
+
else:
|
|
115
|
+
upload_ids.append(str(p))
|
|
116
|
+
result[field_name] = upload_ids
|
|
117
|
+
else:
|
|
118
|
+
file_path = Path(str(value))
|
|
119
|
+
if file_path.is_file():
|
|
120
|
+
_validate_file_accept(file_path, accept)
|
|
121
|
+
click.echo(f"Uploading {file_path}...", err=True)
|
|
122
|
+
upload_id = client.upload_file(tool_name, field_name, file_path)
|
|
123
|
+
result[field_name] = upload_id
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@cli.command("run")
|
|
129
|
+
@click.argument("tool_name")
|
|
130
|
+
@click.argument("key_value_args", nargs=-1)
|
|
131
|
+
@click.option("--input-json", "input_json", default=None, help="JSON string with tool arguments")
|
|
132
|
+
@click.option("--raw", is_flag=True, help="Print only the output field")
|
|
133
|
+
def run(tool_name: str, key_value_args: tuple[str, ...], input_json: str | None, raw: bool):
|
|
134
|
+
"""Run a tool.
|
|
135
|
+
|
|
136
|
+
Pass arguments as key=value pairs or via --input-json.
|
|
137
|
+
|
|
138
|
+
\b
|
|
139
|
+
Examples:
|
|
140
|
+
constants run my_tool query="hello world"
|
|
141
|
+
constants run my_tool --input-json '{"query": "hello"}'
|
|
142
|
+
constants run pdf_tool pdf_file=./report.pdf
|
|
143
|
+
"""
|
|
144
|
+
api_key = require_api_key()
|
|
145
|
+
client = ConstantsClient(api_key=api_key)
|
|
146
|
+
|
|
147
|
+
# Build args from key=value pairs
|
|
148
|
+
tool = client.get_tool(tool_name)
|
|
149
|
+
input_schema = tool.get("inputSchema") if tool else None
|
|
150
|
+
args = _parse_key_value_args(key_value_args, input_schema)
|
|
151
|
+
|
|
152
|
+
# Merge --input-json (takes precedence)
|
|
153
|
+
if input_json:
|
|
154
|
+
try:
|
|
155
|
+
json_args = json.loads(input_json)
|
|
156
|
+
except json.JSONDecodeError as e:
|
|
157
|
+
print_error(f"Invalid --input-json: {e}")
|
|
158
|
+
raise SystemExit(1)
|
|
159
|
+
args.update(json_args)
|
|
160
|
+
|
|
161
|
+
# Handle file uploads if tool has file fields
|
|
162
|
+
file_fields = tool.get("fileFields", []) if tool else []
|
|
163
|
+
if file_fields:
|
|
164
|
+
try:
|
|
165
|
+
args = _handle_file_uploads(client, tool_name, args, file_fields)
|
|
166
|
+
except ValidationError as e:
|
|
167
|
+
print_error(str(e))
|
|
168
|
+
raise SystemExit(1)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
result = client.run_tool(tool_name, args)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print_error(str(e))
|
|
174
|
+
raise SystemExit(1)
|
|
175
|
+
|
|
176
|
+
if raw:
|
|
177
|
+
output = result.get("output", result)
|
|
178
|
+
if isinstance(output, str):
|
|
179
|
+
print(output)
|
|
180
|
+
else:
|
|
181
|
+
print_json(output)
|
|
182
|
+
else:
|
|
183
|
+
print_json(result)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Typed exceptions for the Constants client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ConstantsError(Exception):
|
|
5
|
+
"""Base exception for Constants API errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(ConstantsError):
|
|
13
|
+
"""Authentication or authorization failure (401/403)."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotFoundError(ConstantsError):
|
|
18
|
+
"""Tool or resource not found (404)."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExecutionError(ConstantsError):
|
|
23
|
+
"""Tool execution failed (500 or error in output)."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ValidationError(ConstantsError):
|
|
28
|
+
"""Input validation failure (e.g., file type mismatch)."""
|
|
29
|
+
pass
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from constants_cli import __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.version_option(version=__version__, prog_name="constants")
|
|
10
|
+
def cli():
|
|
11
|
+
"""Constants CLI - Run Constants.ai tools from the command line."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Commands are registered via imports below
|
|
16
|
+
from constants_cli.commands import auth, list_tools, run # noqa: E402, F401
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Result formatting for CLI output."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def print_json(data: object) -> None:
|
|
8
|
+
"""Print data as formatted JSON to stdout."""
|
|
9
|
+
print(json.dumps(data, indent=2, default=str))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def print_error(message: str) -> None:
|
|
13
|
+
"""Print error message to stderr."""
|
|
14
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def print_table(rows: list[dict], columns: list[tuple[str, str]]) -> None:
|
|
18
|
+
"""Print a simple text table.
|
|
19
|
+
|
|
20
|
+
columns: list of (key, header) tuples.
|
|
21
|
+
"""
|
|
22
|
+
if not rows:
|
|
23
|
+
print("(no results)")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
widths = {key: len(header) for key, header in columns}
|
|
27
|
+
for row in rows:
|
|
28
|
+
for key, _ in columns:
|
|
29
|
+
val = str(row.get(key, ""))
|
|
30
|
+
widths[key] = max(widths[key], len(val))
|
|
31
|
+
|
|
32
|
+
header_line = " ".join(h.ljust(widths[k]) for k, h in columns)
|
|
33
|
+
print(header_line)
|
|
34
|
+
print(" ".join("-" * widths[k] for k, _ in columns))
|
|
35
|
+
for row in rows:
|
|
36
|
+
line = " ".join(str(row.get(k, "")).ljust(widths[k]) for k, _ in columns)
|
|
37
|
+
print(line)
|