runspec 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.
- runspec-0.1.0/PKG-INFO +28 -0
- runspec-0.1.0/pyproject.toml +83 -0
- runspec-0.1.0/runspec/__init__.py +23 -0
- runspec-0.1.0/runspec/cli.py +349 -0
- runspec-0.1.0/runspec/errors.py +168 -0
- runspec-0.1.0/runspec/finder.py +115 -0
- runspec-0.1.0/runspec/inference.py +112 -0
- runspec-0.1.0/runspec/loader.py +176 -0
- runspec-0.1.0/runspec/models.py +213 -0
- runspec-0.1.0/runspec/parser.py +378 -0
- runspec-0.1.0/runspec/py.typed +0 -0
- runspec-0.1.0/runspec/types.py +146 -0
- runspec-0.1.0/runspec/validator.py +98 -0
- runspec-0.1.0/runspec.egg-info/PKG-INFO +28 -0
- runspec-0.1.0/runspec.egg-info/SOURCES.txt +24 -0
- runspec-0.1.0/runspec.egg-info/dependency_links.txt +1 -0
- runspec-0.1.0/runspec.egg-info/entry_points.txt +2 -0
- runspec-0.1.0/runspec.egg-info/requires.txt +9 -0
- runspec-0.1.0/runspec.egg-info/top_level.txt +3 -0
- runspec-0.1.0/setup.cfg +4 -0
- runspec-0.1.0/tests/__init__.py +0 -0
- runspec-0.1.0/tests/test_inference.py +164 -0
- runspec-0.1.0/tests/test_loader.py +417 -0
- runspec-0.1.0/tests/test_models.py +158 -0
- runspec-0.1.0/tests/test_types.py +87 -0
- runspec-0.1.0/tests/test_validator.py +169 -0
runspec-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A language-agnostic, TOML-based interface specification for anything runnable
|
|
5
|
+
Author-email: Jason Finestone <jason@finestone.dev>
|
|
6
|
+
Project-URL: Homepage, https://github.com/JasonFinestone/runspec
|
|
7
|
+
Project-URL: Repository, https://github.com/JasonFinestone/runspec
|
|
8
|
+
Project-URL: Issues, https://github.com/JasonFinestone/runspec/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/JasonFinestone/runspec/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: cli,argparse,arguments,agents,mcp,tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "runspec"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A language-agnostic, TOML-based interface specification for anything runnable"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { file = "../../LICENSE" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Jason Finestone", email = "jason@finestone.dev" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
keywords = ["cli", "argparse", "arguments", "agents", "mcp", "tools"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Topic :: Utilities",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# tomli provides tomllib for Python < 3.11 (stdlib from 3.11 onwards)
|
|
26
|
+
dependencies = [
|
|
27
|
+
"tomli >= 2.0; python_version < '3.11'",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-cov>=5.0",
|
|
34
|
+
"ruff>=0.4",
|
|
35
|
+
"mypy>=1.10",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
runspec = "runspec.cli:main"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/JasonFinestone/runspec"
|
|
43
|
+
Repository = "https://github.com/JasonFinestone/runspec"
|
|
44
|
+
Issues = "https://github.com/JasonFinestone/runspec/issues"
|
|
45
|
+
Changelog = "https://github.com/JasonFinestone/runspec/blob/main/CHANGELOG.md"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["setuptools>=69.0.2", "wheel"]
|
|
49
|
+
build-backend = "setuptools.build_meta"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["."]
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.package-data]
|
|
56
|
+
runspec = ["py.typed"]
|
|
57
|
+
|
|
58
|
+
# ── ruff ──────────────────────────────────────────────────────────────────────
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 200
|
|
61
|
+
target-version = "py310"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
65
|
+
ignore = []
|
|
66
|
+
|
|
67
|
+
# ── mypy ──────────────────────────────────────────────────────────────────────
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
python_version = "3.11"
|
|
70
|
+
strict = true
|
|
71
|
+
warn_return_any = true
|
|
72
|
+
warn_unused_configs = true
|
|
73
|
+
disallow_untyped_defs = true
|
|
74
|
+
|
|
75
|
+
# ── pytest ────────────────────────────────────────────────────────────────────
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
addopts = "--cov=runspec --cov-report=term-missing -v"
|
|
79
|
+
filterwarnings = ["error"]
|
|
80
|
+
|
|
81
|
+
[tool.coverage.run]
|
|
82
|
+
source = ["runspec"]
|
|
83
|
+
omit = ["tests/*"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
runspec — A language-agnostic, TOML-based interface specification
|
|
3
|
+
for anything runnable.
|
|
4
|
+
|
|
5
|
+
Public API:
|
|
6
|
+
parse() → RunSpec Parse arguments for the calling script
|
|
7
|
+
load_spec() → RunSpec Load spec without parsing argv
|
|
8
|
+
register_type() → None Register a custom type coercer
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from runspec.models import Arg, Group, RunSpec
|
|
12
|
+
from runspec.parser import load_spec, parse
|
|
13
|
+
from runspec.types import register_type
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"parse",
|
|
18
|
+
"load_spec",
|
|
19
|
+
"register_type",
|
|
20
|
+
"RunSpec",
|
|
21
|
+
"Arg",
|
|
22
|
+
"Group",
|
|
23
|
+
]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — The runspec command-line interface.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
runspec discover [--format mcp|openai|anthropic|json]
|
|
6
|
+
runspec check
|
|
7
|
+
runspec emit --script <name> [--format mcp|openai|anthropic]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
"""Entry point for the runspec CLI binary."""
|
|
20
|
+
args = sys.argv[1:]
|
|
21
|
+
|
|
22
|
+
if not args or args[0] in ("-h", "--help"):
|
|
23
|
+
_print_help()
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
command = args[0]
|
|
27
|
+
rest = args[1:]
|
|
28
|
+
|
|
29
|
+
commands = {
|
|
30
|
+
"discover": cmd_discover,
|
|
31
|
+
"check": cmd_check,
|
|
32
|
+
"emit": cmd_emit,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if command not in commands:
|
|
36
|
+
print(f"✗ Unknown command: {command}")
|
|
37
|
+
print(f" Available commands: {', '.join(commands)}")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
commands[command](rest)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_discover(args: list[str]) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Discover all runspec-aware runnables in the current environment.
|
|
46
|
+
Checks installed packages and the current directory.
|
|
47
|
+
"""
|
|
48
|
+
fmt = _get_flag(args, "--format", default="text")
|
|
49
|
+
|
|
50
|
+
discovered: list[dict[str, Any]] = []
|
|
51
|
+
|
|
52
|
+
# Check current directory
|
|
53
|
+
local = _discover_local()
|
|
54
|
+
if local:
|
|
55
|
+
discovered.extend(local)
|
|
56
|
+
|
|
57
|
+
# Check installed packages
|
|
58
|
+
installed = _discover_installed()
|
|
59
|
+
if installed:
|
|
60
|
+
discovered.extend(installed)
|
|
61
|
+
|
|
62
|
+
if not discovered:
|
|
63
|
+
print("No runspec-aware runnables found in this environment.")
|
|
64
|
+
print("Add a [tool.runspec.yourname] section to pyproject.toml or create runspec.toml")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if fmt == "text":
|
|
68
|
+
_print_discover_text(discovered)
|
|
69
|
+
elif fmt == "json":
|
|
70
|
+
print(json.dumps(discovered, indent=2, default=str))
|
|
71
|
+
elif fmt in ("mcp", "openai", "anthropic"):
|
|
72
|
+
schema = _emit_all(discovered, fmt)
|
|
73
|
+
print(json.dumps(schema, indent=2, default=str))
|
|
74
|
+
else:
|
|
75
|
+
print(f"✗ Unknown format: {fmt}")
|
|
76
|
+
print(" Available formats: text, json, mcp, openai, anthropic")
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_check(args: list[str]) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Validate the current project's runspec setup.
|
|
83
|
+
"""
|
|
84
|
+
from runspec.finder import find_config
|
|
85
|
+
from runspec.loader import load_raw
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
config_path, fmt = find_config(Path.cwd())
|
|
89
|
+
except FileNotFoundError as e:
|
|
90
|
+
print(str(e))
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
raw = load_raw(config_path, fmt)
|
|
94
|
+
errors: list[str] = []
|
|
95
|
+
warnings: list[str] = []
|
|
96
|
+
ok: list[str] = []
|
|
97
|
+
|
|
98
|
+
# Check config file found
|
|
99
|
+
ok.append(f"Config found: {config_path}")
|
|
100
|
+
|
|
101
|
+
# Check entry points if pyproject.toml
|
|
102
|
+
if fmt == "pyproject":
|
|
103
|
+
entry_points = raw.get("entry_points", {})
|
|
104
|
+
if entry_points:
|
|
105
|
+
ok.append(f"[project.scripts] found — {len(entry_points)} entry point(s)")
|
|
106
|
+
else:
|
|
107
|
+
warnings.append("No [project.scripts] found — agents may not discover runnables automatically\n Add entry points to pyproject.toml or use runspec.toml")
|
|
108
|
+
|
|
109
|
+
# Check for reserved name
|
|
110
|
+
if "config" in raw["runnables"]:
|
|
111
|
+
errors.append("'config' is a reserved name — rename your runnable to something else")
|
|
112
|
+
|
|
113
|
+
# Check each runnable
|
|
114
|
+
for runnable_name, runnable in raw["runnables"].items():
|
|
115
|
+
if not runnable.get("description"):
|
|
116
|
+
warnings.append(f"'{runnable_name}' has no description — agents won't know what it does")
|
|
117
|
+
else:
|
|
118
|
+
ok.append(f"'{runnable_name}' — description present")
|
|
119
|
+
|
|
120
|
+
if not runnable.get("autonomy"):
|
|
121
|
+
warnings.append(f"'{runnable_name}' autonomy not declared — will default to '{raw['config']['autonomy_default']}'")
|
|
122
|
+
else:
|
|
123
|
+
ok.append(f"'{runnable_name}' — autonomy: {runnable['autonomy']}")
|
|
124
|
+
|
|
125
|
+
for arg_name, arg in runnable.get("args", {}).items():
|
|
126
|
+
if not arg.get("description") and arg.get("required"):
|
|
127
|
+
warnings.append(f"'{runnable_name}.{arg_name}' is required but has no description")
|
|
128
|
+
|
|
129
|
+
# Print results
|
|
130
|
+
for msg in ok:
|
|
131
|
+
print(f" ✓ {msg}")
|
|
132
|
+
for msg in warnings:
|
|
133
|
+
print(f" ℹ {msg}")
|
|
134
|
+
for msg in errors:
|
|
135
|
+
print(f" ✗ {msg}")
|
|
136
|
+
|
|
137
|
+
if errors:
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
elif not warnings:
|
|
140
|
+
print("\n All checks passed.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_emit(args: list[str]) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Emit a tool schema for one or all runnables.
|
|
146
|
+
"""
|
|
147
|
+
script_name = _get_flag(args, "--script")
|
|
148
|
+
fmt = _get_flag(args, "--format", default="mcp")
|
|
149
|
+
|
|
150
|
+
from runspec.finder import find_config
|
|
151
|
+
from runspec.inference import infer_script
|
|
152
|
+
from runspec.loader import load_raw
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
config_path, file_fmt = find_config(Path.cwd())
|
|
156
|
+
except FileNotFoundError as e:
|
|
157
|
+
print(str(e))
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
raw = load_raw(config_path, file_fmt)
|
|
161
|
+
config = raw["config"]
|
|
162
|
+
|
|
163
|
+
if script_name:
|
|
164
|
+
if script_name not in raw["runnables"]:
|
|
165
|
+
print(f"✗ Runnable '{script_name}' not found")
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
runnables = {script_name: raw["runnables"][script_name]}
|
|
168
|
+
else:
|
|
169
|
+
runnables = raw["runnables"]
|
|
170
|
+
|
|
171
|
+
schema = {}
|
|
172
|
+
for name, runnable in runnables.items():
|
|
173
|
+
inferred = infer_script(runnable, config["autonomy_default"])
|
|
174
|
+
schema[name] = _build_schema(name, inferred, fmt or "mcp")
|
|
175
|
+
|
|
176
|
+
output = {"tools": list(schema.values())} if fmt == "mcp" else schema
|
|
177
|
+
|
|
178
|
+
print(json.dumps(output, indent=2, default=str))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── Schema builders ───────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _build_schema(name: str, script: dict[str, Any], fmt: str) -> dict[str, Any]:
|
|
185
|
+
"""Build a tool schema for a script in the requested format."""
|
|
186
|
+
properties: dict[str, Any] = {}
|
|
187
|
+
required_args: list[str] = []
|
|
188
|
+
|
|
189
|
+
for arg_name, arg in script.get("args", {}).items():
|
|
190
|
+
prop = _arg_to_json_schema(arg)
|
|
191
|
+
properties[arg_name] = prop
|
|
192
|
+
if arg.get("required"):
|
|
193
|
+
required_args.append(arg_name)
|
|
194
|
+
|
|
195
|
+
schema: dict[str, Any] = {
|
|
196
|
+
"name": name,
|
|
197
|
+
"description": script.get("description") or "",
|
|
198
|
+
"x-autonomy": script.get("autonomy", "confirm"),
|
|
199
|
+
"inputSchema": {
|
|
200
|
+
"type": "object",
|
|
201
|
+
"properties": properties,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if required_args:
|
|
206
|
+
schema["inputSchema"]["required"] = required_args
|
|
207
|
+
|
|
208
|
+
if script.get("autonomy_reason"):
|
|
209
|
+
schema["x-autonomy-reason"] = script["autonomy_reason"]
|
|
210
|
+
|
|
211
|
+
return schema
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _arg_to_json_schema(arg: dict[str, Any]) -> dict[str, Any]:
|
|
215
|
+
"""Convert a runspec arg spec to a JSON Schema property."""
|
|
216
|
+
type_map = {
|
|
217
|
+
"str": "string",
|
|
218
|
+
"int": "integer",
|
|
219
|
+
"float": "number",
|
|
220
|
+
"bool": "boolean",
|
|
221
|
+
"flag": "boolean",
|
|
222
|
+
"path": "string",
|
|
223
|
+
"choice": "string",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
prop: dict[str, Any] = {
|
|
227
|
+
"type": type_map.get(arg.get("type", "str"), "string"),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if arg.get("description"):
|
|
231
|
+
prop["description"] = arg["description"]
|
|
232
|
+
|
|
233
|
+
if arg.get("default") is not None:
|
|
234
|
+
prop["default"] = arg["default"]
|
|
235
|
+
|
|
236
|
+
if arg.get("options"):
|
|
237
|
+
prop["enum"] = arg["options"]
|
|
238
|
+
|
|
239
|
+
if arg.get("range"):
|
|
240
|
+
min_val, max_val = arg["range"]
|
|
241
|
+
prop["minimum"] = min_val
|
|
242
|
+
prop["maximum"] = max_val
|
|
243
|
+
|
|
244
|
+
if arg.get("multiple"):
|
|
245
|
+
prop = {"type": "array", "items": prop}
|
|
246
|
+
|
|
247
|
+
return prop
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── Discovery helpers ─────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _discover_local() -> list[dict[str, Any]]:
|
|
254
|
+
"""Look for runspec config in the current directory."""
|
|
255
|
+
from runspec.finder import find_config
|
|
256
|
+
from runspec.loader import load_raw
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
config_path, fmt = find_config(Path.cwd())
|
|
260
|
+
raw = load_raw(config_path, fmt)
|
|
261
|
+
return [{"source": str(config_path), "runnable": name, "spec": spec} for name, spec in raw["runnables"].items()]
|
|
262
|
+
except FileNotFoundError:
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _discover_installed() -> list[dict[str, Any]]:
|
|
267
|
+
"""
|
|
268
|
+
Find runspec-aware packages in the current Python environment
|
|
269
|
+
using importlib.metadata.
|
|
270
|
+
"""
|
|
271
|
+
import importlib.metadata as meta
|
|
272
|
+
|
|
273
|
+
discovered: list[dict[str, Any]] = []
|
|
274
|
+
|
|
275
|
+
for dist in meta.packages_distributions():
|
|
276
|
+
try:
|
|
277
|
+
meta.metadata(dist)
|
|
278
|
+
# Check if package has runspec.toml as package data
|
|
279
|
+
# This is a simplified check — full implementation uses
|
|
280
|
+
# importlib.resources to look for the file
|
|
281
|
+
pass
|
|
282
|
+
except Exception:
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
return discovered
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _emit_all(discovered: list[dict[str, Any]], fmt: str) -> dict[str, Any]:
|
|
289
|
+
"""Emit all discovered runnables as a unified schema."""
|
|
290
|
+
tools = []
|
|
291
|
+
for item in discovered:
|
|
292
|
+
tool = _build_schema(item["script"], item["spec"], fmt)
|
|
293
|
+
tools.append(tool)
|
|
294
|
+
return {"tools": tools}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _print_discover_text(discovered: list[dict[str, Any]]) -> None:
|
|
298
|
+
"""Pretty-print discovered runnables."""
|
|
299
|
+
by_source: dict[str, list[str]] = {}
|
|
300
|
+
for item in discovered:
|
|
301
|
+
src = item["source"]
|
|
302
|
+
by_source.setdefault(src, []).append(item["runnable"])
|
|
303
|
+
|
|
304
|
+
print(f"Found {len(discovered)} runspec-aware runnable(s):\n")
|
|
305
|
+
for source, runnables in by_source.items():
|
|
306
|
+
print(f" {source}")
|
|
307
|
+
for r in runnables:
|
|
308
|
+
print(f" • {r}")
|
|
309
|
+
print()
|
|
310
|
+
print("Run 'runspec discover --format mcp' to emit MCP tool schemas.")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ── Arg parsing helpers ───────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _get_flag(args: list[str], flag: str, default: str | None = None) -> str | None:
|
|
317
|
+
"""Extract a --flag value from args list."""
|
|
318
|
+
try:
|
|
319
|
+
idx = args.index(flag)
|
|
320
|
+
return args[idx + 1]
|
|
321
|
+
except (ValueError, IndexError):
|
|
322
|
+
return default
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _print_help() -> None:
|
|
326
|
+
print("""runspec — interface specification for anything runnable
|
|
327
|
+
|
|
328
|
+
Usage:
|
|
329
|
+
runspec <command> [options]
|
|
330
|
+
|
|
331
|
+
Commands:
|
|
332
|
+
discover Find all runspec-aware runnables in this environment
|
|
333
|
+
check Validate this project's runspec setup
|
|
334
|
+
emit Emit tool schemas for agent frameworks
|
|
335
|
+
|
|
336
|
+
Options for discover:
|
|
337
|
+
--format Output format: text (default), json, mcp, openai, anthropic
|
|
338
|
+
|
|
339
|
+
Options for emit:
|
|
340
|
+
--script Runnable name to emit (all runnables if omitted)
|
|
341
|
+
--format Output format: mcp (default), openai, anthropic
|
|
342
|
+
|
|
343
|
+
Examples:
|
|
344
|
+
runspec discover
|
|
345
|
+
runspec discover --format mcp
|
|
346
|
+
runspec check
|
|
347
|
+
runspec emit --name compress --format mcp
|
|
348
|
+
runspec emit --format openai
|
|
349
|
+
""")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
errors.py — Human-first error formatting with fuzzy suggestions.
|
|
3
|
+
|
|
4
|
+
Every error includes:
|
|
5
|
+
- what failed
|
|
6
|
+
- what was expected
|
|
7
|
+
- what was received
|
|
8
|
+
- a suggestion where possible (via difflib, stdlib, zero dependencies)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import difflib
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RunSpecError(Exception):
|
|
18
|
+
"""Base class for all runspec errors."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MissingRequiredArg(RunSpecError):
|
|
22
|
+
"""A required argument was not provided."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidChoice(RunSpecError):
|
|
26
|
+
"""A value was not in the declared options list."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OutOfRange(RunSpecError):
|
|
30
|
+
"""A numeric value was outside the declared range."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnknownArg(RunSpecError):
|
|
34
|
+
"""An argument was provided that is not in the spec."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GroupViolation(RunSpecError):
|
|
38
|
+
"""A group constraint was violated."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AutonomyViolation(RunSpecError):
|
|
42
|
+
"""An agent attempted to exceed its declared autonomy level."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_missing_required(name: str, arg_spec: dict[str, Any]) -> str:
|
|
46
|
+
lines = [
|
|
47
|
+
f"✗ Missing required argument: --{name}",
|
|
48
|
+
f" Type: {arg_spec.get('type', 'str')}",
|
|
49
|
+
]
|
|
50
|
+
if arg_spec.get("description"):
|
|
51
|
+
lines.append(f" Description: {arg_spec['description']}")
|
|
52
|
+
if arg_spec.get("env"):
|
|
53
|
+
lines.append(f" Tip: set environment variable {arg_spec['env']} as an alternative")
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_invalid_choice(value: str, options: list[str], name: str) -> str:
|
|
58
|
+
lines = [
|
|
59
|
+
f"✗ Invalid value for --{name}: {value!r}",
|
|
60
|
+
f" Expected one of: {', '.join(str(o) for o in options)}",
|
|
61
|
+
f" Got: {value!r}",
|
|
62
|
+
]
|
|
63
|
+
suggestion = _suggest(value, [str(o) for o in options])
|
|
64
|
+
if suggestion:
|
|
65
|
+
lines.append(f"\n Did you mean: {suggestion}?")
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_out_of_range(
|
|
70
|
+
value: int | float,
|
|
71
|
+
range_: tuple[Any, Any],
|
|
72
|
+
name: str,
|
|
73
|
+
) -> str:
|
|
74
|
+
min_val, max_val = range_
|
|
75
|
+
return "\n".join(
|
|
76
|
+
[
|
|
77
|
+
f"✗ Value out of range for --{name}: {value}",
|
|
78
|
+
f" Expected: between {min_val} and {max_val}",
|
|
79
|
+
f" Got: {value}",
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def format_unknown_arg(name: str, known_args: list[str]) -> str:
|
|
85
|
+
lines = [
|
|
86
|
+
f"✗ Unknown argument: --{name}",
|
|
87
|
+
f" Known arguments: {', '.join(f'--{a}' for a in sorted(known_args))}",
|
|
88
|
+
]
|
|
89
|
+
suggestion = _suggest(name, known_args)
|
|
90
|
+
if suggestion:
|
|
91
|
+
lines.append(f"\n Did you mean: --{suggestion}?")
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_group_exclusive(group_name: str, provided: list[str]) -> str:
|
|
96
|
+
return "\n".join(
|
|
97
|
+
[
|
|
98
|
+
f"✗ Conflicting arguments in group '{group_name}'",
|
|
99
|
+
f" --{provided[0]} and --{provided[1]} cannot be used together",
|
|
100
|
+
" Choose one or the other",
|
|
101
|
+
]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_group_inclusive(group_name: str, missing: list[str]) -> str:
|
|
106
|
+
missing_flags = " and ".join(f"--{m}" for m in missing)
|
|
107
|
+
return "\n".join(
|
|
108
|
+
[
|
|
109
|
+
f"✗ Incomplete argument group '{group_name}'",
|
|
110
|
+
" Providing one of these args requires all of them",
|
|
111
|
+
f" Also provide: {missing_flags}",
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def format_group_at_least_one(group_name: str, args: list[str]) -> str:
|
|
117
|
+
return "\n".join(
|
|
118
|
+
[
|
|
119
|
+
f"✗ Group '{group_name}' requires at least one argument",
|
|
120
|
+
f" Provide at least one of: {', '.join(f'--{a}' for a in args)}",
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_group_exactly_one(group_name: str, args: list[str], provided: list[str]) -> str:
|
|
126
|
+
if not provided:
|
|
127
|
+
return "\n".join(
|
|
128
|
+
[
|
|
129
|
+
f"✗ Group '{group_name}' requires exactly one argument",
|
|
130
|
+
f" Provide exactly one of: {', '.join(f'--{a}' for a in args)}",
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
return "\n".join(
|
|
134
|
+
[
|
|
135
|
+
f"✗ Group '{group_name}' requires exactly one argument",
|
|
136
|
+
f" Got {len(provided)}: {', '.join(f'--{a}' for a in provided)}",
|
|
137
|
+
f" Provide exactly one of: {', '.join(f'--{a}' for a in args)}",
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_autonomy_violation(
|
|
143
|
+
script_name: str,
|
|
144
|
+
required_level: str,
|
|
145
|
+
reason: str | None,
|
|
146
|
+
) -> str:
|
|
147
|
+
lines = [
|
|
148
|
+
f"✗ Cannot run '{script_name}' autonomously",
|
|
149
|
+
f" Autonomy level: {required_level}",
|
|
150
|
+
]
|
|
151
|
+
if reason:
|
|
152
|
+
lines.append(f" Reason: {reason}")
|
|
153
|
+
lines.append("\n Awaiting human confirmation...")
|
|
154
|
+
return "\n".join(lines)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def format_deprecated(name: str, message: str) -> str:
|
|
158
|
+
return f"⚠ --{name} is deprecated: {message}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _suggest(value: str, candidates: list[str]) -> str | None:
|
|
162
|
+
"""
|
|
163
|
+
Return the closest match from candidates using difflib.
|
|
164
|
+
Returns None if no close match found.
|
|
165
|
+
Uses stdlib only — zero extra dependencies.
|
|
166
|
+
"""
|
|
167
|
+
matches = difflib.get_close_matches(value, candidates, n=1, cutoff=0.6)
|
|
168
|
+
return matches[0] if matches else None
|