Harden agent-friendly discovery and one-shot execution
This commit is contained in:
@@ -35,6 +35,11 @@ def main():
|
||||
p_list = subparsers.add_parser("list", help="List all tools")
|
||||
p_list.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
p_list.add_argument("--filter", help="Filter by name or description (substring match)")
|
||||
p_list.add_argument("--limit", type=int, help="Return at most this many matching tools")
|
||||
p_list.add_argument(
|
||||
"--full", action="store_true",
|
||||
help="Include complete definitions in JSON output (prompts and code)",
|
||||
)
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
# 'create' command
|
||||
@@ -81,8 +86,18 @@ def main():
|
||||
|
||||
# 'run-once' command — one-shot without creating a tool
|
||||
p_run_once = subparsers.add_parser("run-once", help="Run a one-shot prompt through a provider without creating a tool")
|
||||
p_run_once.add_argument("prompt", help="Prompt text (or '-' to read from stdin)")
|
||||
p_run_once.add_argument("-p", "--provider", default="opencode-pickle", help="Provider to use")
|
||||
p_run_once.add_argument(
|
||||
"prompt",
|
||||
help=(
|
||||
"Prompt or template; piped/file input replaces {input}, or is "
|
||||
"appended when the placeholder is absent ('-' reads stdin as the whole prompt)"
|
||||
),
|
||||
)
|
||||
p_run_once.add_argument("-i", "--input", help="Read input data from this file (or '-' for stdin)")
|
||||
p_run_once.add_argument(
|
||||
"-p", "--provider",
|
||||
help="Provider to use (default: configured provider, then first available provider)",
|
||||
)
|
||||
p_run_once.add_argument("--max-tokens", type=int, help="Max output tokens")
|
||||
p_run_once.add_argument("--timeout", type=int, default=60, help="Timeout in seconds")
|
||||
p_run_once.set_defaults(func=cmd_run_once)
|
||||
|
||||
@@ -13,38 +13,50 @@ from ..gui import run_gui
|
||||
def cmd_list(args):
|
||||
"""List all tools."""
|
||||
import json
|
||||
import sys
|
||||
|
||||
tools = list_tools()
|
||||
filter_text = (getattr(args, "filter", "") or "").casefold()
|
||||
limit = getattr(args, "limit", None)
|
||||
if getattr(args, "full", False) and not getattr(args, "json", False):
|
||||
print("Error: --full requires --json.", file=sys.stderr)
|
||||
return 1
|
||||
if limit is not None and limit < 1:
|
||||
print("Error: --limit must be at least 1.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
filter_text = getattr(args, "filter", "") or ""
|
||||
matches = []
|
||||
for name in sorted(list_tools()):
|
||||
tool = load_tool(name)
|
||||
if not tool:
|
||||
continue
|
||||
if filter_text and (
|
||||
filter_text not in name.casefold()
|
||||
and filter_text not in (tool.description or "").casefold()
|
||||
):
|
||||
continue
|
||||
matches.append((name, tool))
|
||||
if limit is not None and len(matches) >= limit:
|
||||
break
|
||||
|
||||
if getattr(args, "json", False):
|
||||
result = []
|
||||
for name in sorted(tools):
|
||||
tool = load_tool(name)
|
||||
if not tool:
|
||||
continue
|
||||
if filter_text and filter_text.lower() not in name.lower() and filter_text.lower() not in (tool.description or "").lower():
|
||||
continue
|
||||
result.append(tool.to_dict())
|
||||
full = getattr(args, "full", False)
|
||||
result = [
|
||||
_full_tool_entry(name, tool) if full else _catalog_tool_entry(name, tool)
|
||||
for name, tool in matches
|
||||
]
|
||||
json.dump(result, sys.stdout, indent=2)
|
||||
print()
|
||||
return 0
|
||||
|
||||
if not tools:
|
||||
print("No tools found.")
|
||||
print("Create your first tool with: cmdforge ui")
|
||||
if not matches:
|
||||
if filter_text:
|
||||
print(f"No tools match filter '{getattr(args, 'filter', '')}'.")
|
||||
else:
|
||||
print("No tools found.")
|
||||
print("Create your first tool with: cmdforge ui")
|
||||
return 0
|
||||
|
||||
print(f"Available tools ({len(tools)}):\n")
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
if not tool:
|
||||
continue
|
||||
if filter_text and filter_text.lower() not in name.lower() and filter_text.lower() not in (tool.description or "").lower():
|
||||
continue
|
||||
|
||||
print(f"Available tools ({len(matches)}):\n")
|
||||
for name, tool in matches:
|
||||
# Show source indicator for imported tools
|
||||
source_marker = ""
|
||||
if tool.source and tool.source.type == "imported":
|
||||
@@ -77,23 +89,71 @@ def cmd_list(args):
|
||||
return 0
|
||||
|
||||
|
||||
def _catalog_tool_entry(name, tool) -> dict:
|
||||
"""Return the stable, compact discovery contract intended for agents."""
|
||||
entry = {
|
||||
"name": name,
|
||||
"description": tool.description or "",
|
||||
"version": tool.version or "",
|
||||
"category": tool.category or "Other",
|
||||
"arguments": [argument.to_dict() for argument in tool.arguments],
|
||||
"input_schema": tool.input_schema,
|
||||
"output_schema": tool.output_schema,
|
||||
"deprecated": bool(tool.deprecated),
|
||||
}
|
||||
if tool.replacement:
|
||||
entry["replacement"] = tool.replacement
|
||||
return entry
|
||||
|
||||
|
||||
def _full_tool_entry(name, tool) -> dict:
|
||||
"""Return an explicitly requested complete definition."""
|
||||
entry = tool.to_dict()
|
||||
# list_tools() owns the canonical local/qualified reference. A config's
|
||||
# internal name may be only the short name.
|
||||
entry["name"] = name
|
||||
return entry
|
||||
|
||||
|
||||
def cmd_run_once(args):
|
||||
"""Run a one-shot prompt through a provider without creating a tool."""
|
||||
import sys
|
||||
from ..providers import call_provider
|
||||
|
||||
prompt = args.prompt
|
||||
if prompt == "-":
|
||||
prompt = sys.stdin.read()
|
||||
timeout = getattr(args, "timeout", 60)
|
||||
max_tokens = getattr(args, "max_tokens", None)
|
||||
if timeout < 1 or timeout > 3600:
|
||||
print("Error: --timeout must be between 1 and 3600 seconds.", file=sys.stderr)
|
||||
return 1
|
||||
if max_tokens is not None and not 1 <= max_tokens <= 1_000_000:
|
||||
print("Error: --max-tokens must be between 1 and 1000000.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
prompt = _build_one_shot_prompt(
|
||||
args.prompt, getattr(args, "input", None), sys.stdin
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Error reading input: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not prompt.strip():
|
||||
print("Error: No prompt provided.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
provider = args.provider or "opencode-pickle"
|
||||
provider = _resolve_one_shot_provider(getattr(args, "provider", None))
|
||||
if not provider:
|
||||
print(
|
||||
"Error: No usable provider found. Pass --provider, set "
|
||||
"'cmdforge config set default_provider NAME', or run "
|
||||
"'cmdforge providers install'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"[run-once] Provider: {provider}", file=sys.stderr)
|
||||
result = call_provider(provider, prompt, timeout=args.timeout, max_tokens=args.max_tokens)
|
||||
result = call_provider(
|
||||
provider, prompt, timeout=timeout, max_tokens=max_tokens
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
print(f"Error: {result.error}", file=sys.stderr)
|
||||
@@ -103,6 +163,75 @@ def cmd_run_once(args):
|
||||
return 0
|
||||
|
||||
|
||||
def _build_one_shot_prompt(template: str, input_path, stdin) -> str:
|
||||
"""Combine a one-shot instruction with explicit or piped input."""
|
||||
if template == "-":
|
||||
if input_path and input_path != "-":
|
||||
raise ValueError("prompt '-' cannot be combined with --input")
|
||||
return stdin.read()
|
||||
|
||||
input_text = None
|
||||
if input_path:
|
||||
input_text = (
|
||||
stdin.read()
|
||||
if input_path == "-"
|
||||
else Path(input_path).read_text(encoding="utf-8")
|
||||
)
|
||||
elif not stdin.isatty():
|
||||
input_text = stdin.read()
|
||||
|
||||
if not input_text:
|
||||
return template
|
||||
if "{input}" in template:
|
||||
return template.replace("{input}", input_text)
|
||||
return f"{template.rstrip()}\n\n{input_text}"
|
||||
|
||||
|
||||
def _resolve_one_shot_provider(explicit: str = None):
|
||||
"""Honor the configured default, otherwise choose an available provider."""
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
from ..config import load_config
|
||||
from ..providers import PRESET_CHAINS, load_providers
|
||||
|
||||
configured = load_config().default_provider
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
providers = {provider.name: provider for provider in load_providers()}
|
||||
ordered_names = list(PRESET_CHAINS["balanced"][:-1])
|
||||
ordered_names.extend(
|
||||
name for name in providers if name not in ordered_names and name != "mock"
|
||||
)
|
||||
for name in ordered_names:
|
||||
provider = providers.get(name)
|
||||
if provider and _provider_is_available(provider):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _provider_is_available(provider) -> bool:
|
||||
"""Perform a non-invasive availability check without contacting a model."""
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
|
||||
if provider.type == "api":
|
||||
key_name = provider.api_key_env or (
|
||||
f"{provider.name.upper().replace('-', '_')}_API_KEY"
|
||||
)
|
||||
return bool(os.environ.get(key_name))
|
||||
try:
|
||||
parts = shlex.split(os.path.expandvars(provider.command))
|
||||
except ValueError:
|
||||
return False
|
||||
if not parts:
|
||||
return False
|
||||
executable = Path(os.path.expanduser(parts[0]))
|
||||
return bool(shutil.which(str(executable)) or executable.is_file())
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
"""Create a new tool (basic CLI creation - use 'ui' for full builder)."""
|
||||
name = args.name
|
||||
|
||||
Reference in New Issue
Block a user