runspec 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.
runspec/__init__.py ADDED
@@ -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
+ ]
runspec/cli.py ADDED
@@ -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
+ """)
runspec/errors.py ADDED
@@ -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
runspec/finder.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ finder.py — Locates the runspec configuration file.
3
+
4
+ Search order (per SPEC.md):
5
+ 1. pyproject.toml with [tool.runspec] section
6
+ 2. runspec.toml
7
+ Walk up from the starting directory repeating 1 and 2.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ if sys.version_info >= (3, 11):
16
+ import tomllib
17
+ else:
18
+ import tomli as tomllib # type: ignore[no-redef]
19
+
20
+
21
+ def find_config(start: Path | None = None) -> tuple[Path, str]:
22
+ """
23
+ Find the runspec config file starting from `start` and walking up.
24
+
25
+ Returns:
26
+ (config_path, format) where format is "pyproject" or "runspec"
27
+
28
+ Raises:
29
+ FileNotFoundError: if no config file is found
30
+ """
31
+ search_dir = (start or _caller_directory()).resolve()
32
+
33
+ for directory in [search_dir, *search_dir.parents]:
34
+ # Check pyproject.toml first
35
+ pyproject = directory / "pyproject.toml"
36
+ if pyproject.exists() and _has_runspec_section(pyproject):
37
+ return pyproject, "pyproject"
38
+
39
+ # Then runspec.toml
40
+ runspec_toml = directory / "runspec.toml"
41
+ if runspec_toml.exists():
42
+ return runspec_toml, "runspec"
43
+
44
+ raise FileNotFoundError(
45
+ "No runspec configuration found.\nExpected one of:\n - pyproject.toml with [tool.runspec] section\n - runspec.toml\n\nRun 'runspec check' to validate your project setup."
46
+ )
47
+
48
+
49
+ def find_script_name(config_path: Path, format: str) -> str | None:
50
+ """
51
+ Infer the calling script's name from [project.scripts] or
52
+ [tool.poetry.scripts] by matching the calling executable.
53
+
54
+ Returns the script name if found, None if it cannot be determined.
55
+ """
56
+ try:
57
+ with open(config_path, "rb") as f:
58
+ data = tomllib.load(f)
59
+ except Exception:
60
+ return None
61
+
62
+ # Get the name of the currently running script/executable
63
+ caller = Path(sys.argv[0]).stem if sys.argv else None
64
+ if not caller:
65
+ return None
66
+
67
+ # Check [project.scripts] first (PEP 517/518 standard)
68
+ project_scripts = data.get("project", {}).get("scripts", {})
69
+ if caller in project_scripts:
70
+ return caller
71
+
72
+ # Fall back to [tool.poetry.scripts] with a nudge logged
73
+ poetry_scripts = data.get("tool", {}).get("poetry", {}).get("scripts", {})
74
+ if caller in poetry_scripts:
75
+ _nudge_poetry()
76
+ return caller
77
+
78
+ return caller # return caller name even if not in scripts — let loader handle it
79
+
80
+
81
+ def _has_runspec_section(pyproject_path: Path) -> bool:
82
+ """Return True if pyproject.toml contains a [tool.runspec] section."""
83
+ try:
84
+ with open(pyproject_path, "rb") as f:
85
+ data = tomllib.load(f)
86
+ return "runspec" in data.get("tool", {})
87
+ except Exception:
88
+ return False
89
+
90
+
91
+ def _caller_directory() -> Path:
92
+ """Return the directory of the script that called parse()."""
93
+ # Walk up the call stack to find the first frame outside runspec
94
+ import inspect
95
+
96
+ for frame_info in inspect.stack():
97
+ frame_path = Path(frame_info.filename).resolve()
98
+ # Skip frames that are inside the runspec package itself
99
+ if "runspec" not in frame_path.parts[-3:]:
100
+ return frame_path.parent
101
+
102
+ # Fallback to current working directory
103
+ return Path.cwd()
104
+
105
+
106
+ def _nudge_poetry() -> None:
107
+ """Print a one-time informational nudge about [project.scripts]."""
108
+ import warnings
109
+
110
+ warnings.warn(
111
+ "\nrunspec: Using [tool.poetry.scripts] — consider migrating to [project.scripts]\n"
112
+ "for better compatibility with modern Python packaging tools.\n"
113
+ "See: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/\n",
114
+ stacklevel=4,
115
+ )