Harden agent-friendly discovery and one-shot execution

This commit is contained in:
rob
2026-07-20 17:28:44 -03:00
parent 23723eaddb
commit 06a1606370
6 changed files with 377 additions and 33 deletions
+17 -2
View File
@@ -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)
+157 -28
View File
@@ -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