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.
@@ -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,3 @@
1
+ """Constants CLI - Run Constants.ai tools from the command line."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,5 @@
1
+ """CLI commands."""
2
+
3
+ from constants_cli.commands.auth import auth # noqa: F401
4
+ from constants_cli.commands.list_tools import list_tools # noqa: F401
5
+ from constants_cli.commands.run import run # noqa: F401
@@ -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)