Add project tools and strict provider execution

This commit is contained in:
rob
2026-07-20 22:27:36 -03:00
parent c289e812fb
commit 6a88fe2e0f
19 changed files with 1504 additions and 84 deletions
+219
View File
@@ -157,6 +157,33 @@ class TestListCommand:
assert main() == 1
assert "--full requires --json" in capsys.readouterr().err
def test_list_includes_project_tools_and_prefers_project_shadow(
self, temp_tools_dir, tmp_path, monkeypatch, capsys
):
from cmdforge.tool import save_tool
save_tool(Tool(name="shadowed", description="global"))
project = tmp_path / "project"
project.mkdir()
save_tool(
Tool(name="shadowed", description="project"),
tools_dir=project / ".cmdforge",
)
save_tool(
Tool(name="project-only", description="local"),
tools_dir=project / ".cmdforge",
)
monkeypatch.chdir(project)
with patch("sys.argv", ["cmdforge", "list", "--json"]):
assert main() == 0
payload = json.loads(capsys.readouterr().out)
by_name = {item["name"]: item for item in payload}
assert by_name["shadowed"]["description"] == "project"
assert "project-only" in by_name
assert [item["name"] for item in payload].count("shadowed") == 1
class TestRunOnceCommand:
def test_combines_instruction_with_piped_input(self, capsys):
@@ -243,6 +270,29 @@ class TestRunOnceCommand:
call_provider.assert_not_called()
assert message in capsys.readouterr().err
def test_json_envelope_contains_runtime_provenance(self, capsys):
from cmdforge.providers import ProviderResult
result = ProviderResult(
text="answer", success=True,
requested_provider="local", actual_provider="local",
attempted_providers=["local"], locality="local",
)
with (
patch("sys.argv", [
"cmdforge", "run-once", "hello", "--provider", "local",
"--no-fallback", "--result-envelope", "json",
]),
patch("sys.stdin", StringIO()),
patch("cmdforge.providers.call_provider", return_value=result),
):
assert main() == 0
payload = json.loads(capsys.readouterr().out)
call = payload["execution"]["provider_calls"][0]
assert call["actual_provider"] == "local"
assert call["fallback_used"] is False
class TestCreateCommand:
"""Tests for 'cmdforge create' command."""
@@ -296,6 +346,36 @@ class TestCreateCommand:
captured = capsys.readouterr()
assert 'invalid' in captured.out.lower() or 'invalid' in captured.err.lower()
def test_create_project_tool_does_not_create_global_wrapper(
self, temp_tools_dir, tmp_path, monkeypatch, capsys
):
project = tmp_path / "project"
project.mkdir()
monkeypatch.chdir(project)
with patch("sys.argv", [
"cmdforge", "create", "project-echo", "--project",
"--prompt", "Echo {input}", "--provider", "mock",
]):
assert main() == 0
config = project / ".cmdforge" / "project-echo" / "config.yaml"
assert config.exists()
assert not (tmp_path / ".cmdforge" / "project-echo" / "config.yaml").exists()
assert not (tmp_path / ".local" / "bin" / "project-echo").exists()
assert "cmdforge run project-echo" in capsys.readouterr().out
def test_create_output_dir_uses_explicit_tool_root(
self, temp_tools_dir, tmp_path
):
target = tmp_path / "portable-tools"
with patch("sys.argv", [
"cmdforge", "create", "portable", "--output-dir", str(target),
]):
assert main() == 0
assert (target / "portable" / "config.yaml").exists()
class TestDeleteCommand:
"""Tests for 'cmdforge delete' command."""
@@ -405,6 +485,91 @@ class TestRunCommand:
assert result != 0
def test_run_prefers_project_local_tool(
self, temp_tools_dir, tmp_path, monkeypatch, capsys
):
from cmdforge.tool import save_tool
save_tool(Tool(name="shadowed", output="global"))
project = tmp_path / "project"
project.mkdir()
save_tool(
Tool(name="shadowed", output="project"),
tools_dir=project / ".cmdforge",
)
monkeypatch.chdir(project)
with (
patch("sys.argv", ["cmdforge", "run", "shadowed"]),
patch("sys.stdin", StringIO("")),
patch("sys.stdin.isatty", return_value=True),
):
assert main() == 0
assert capsys.readouterr().out.strip() == "project"
def test_run_json_envelope_preserves_output_and_provenance(
self, temp_tools_dir, capsys
):
from cmdforge.tool import save_tool
save_tool(Tool(
name="audited",
steps=[PromptStep(
prompt="Echo {input}", provider="mock",
output_var="answer", plain_text=True,
)],
output="{answer}",
))
with (
patch("sys.argv", [
"cmdforge", "run", "audited", "--result-envelope", "json",
]),
patch("sys.stdin", StringIO("hello")),
patch("sys.stdin.isatty", return_value=False),
):
assert main() == 0
payload = json.loads(capsys.readouterr().out)
assert "MOCK" in payload["output"]
assert payload["execution"]["actual_providers"] == ["mock"]
assert payload["execution"]["provider_calls"][0]["locality"] == "local"
def test_failed_run_still_emits_audit_envelope(
self, temp_tools_dir, capsys
):
from cmdforge.providers import ProviderResult
from cmdforge.tool import save_tool
save_tool(Tool(
name="strict",
steps=[PromptStep(
prompt="Private", provider="primary",
output_var="answer", plain_text=True,
)],
output="{answer}",
))
failure = ProviderResult(
text="", success=False, error="offline",
requested_provider="primary", attempted_providers=["primary"],
)
with (
patch("sys.argv", [
"cmdforge", "run", "strict", "--no-fallback",
"--result-envelope", "json",
]),
patch("sys.stdin", StringIO("private")),
patch("sys.stdin.isatty", return_value=False),
patch("cmdforge.runner.call_provider", return_value=failure),
):
assert main() == 2
payload = json.loads(capsys.readouterr().out)
assert payload["output"] == ""
assert payload["execution"]["exit_code"] == 2
assert payload["execution"]["provider_calls"][0]["error"] == "offline"
class TestTestCommand:
"""Tests for 'cmdforge test' command."""
@@ -458,6 +623,29 @@ class TestProvidersCommand:
# Should show some default providers
assert 'mock' in captured.out.lower() or 'claude' in captured.out.lower()
def test_providers_list_json_exposes_routing_metadata(
self, temp_providers_file, capsys
):
from cmdforge.providers import Provider, save_providers
save_providers([Provider(
"safe", "safe-cli", locality="local",
capabilities=["structured-json"], data_policy="private",
max_context_tokens=8192,
)])
with (
patch("sys.argv", ["cmdforge", "providers", "list", "--json"]),
patch("cmdforge.cli.provider_commands._provider_status", return_value=(True, "Ready")),
):
assert main() == 0
payload = json.loads(capsys.readouterr().out)
assert payload[0]["locality"] == "local"
assert payload[0]["capabilities"] == ["structured-json"]
assert payload[0]["data_policy"] == "private"
assert payload[0]["max_context_tokens"] == 8192
assert payload[0]["available"] is True
def test_providers_add(self, temp_providers_file, capsys):
"""Add a custom provider."""
with patch('sys.argv', ['cmdforge', 'providers', 'add',
@@ -479,6 +667,13 @@ class TestProvidersCommand:
'--model', 'example/model',
'--api-key-env', 'CUSTOM_API_KEY',
'--tag', 'api',
'--locality', 'remote',
'--capability', 'structured-json',
'--model-digest', 'sha256:abc',
'--cost-class', 'low',
'--latency-class', 'fast',
'--max-context-tokens', '128000',
'--data-policy', 'internal',
'--fallback-chain', 'free',
]):
result = main()
@@ -491,6 +686,13 @@ class TestProvidersCommand:
assert provider.api_key_env == 'CUSTOM_API_KEY'
assert provider.tags == ['api']
assert provider.fallback_chain == PRESET_CHAINS['free']
assert provider.locality == 'remote'
assert provider.capabilities == ['structured-json']
assert provider.model_digest == 'sha256:abc'
assert provider.cost_class == 'low'
assert provider.latency_class == 'fast'
assert provider.max_context_tokens == 128000
assert provider.data_policy == 'internal'
def test_providers_list_reports_missing_api_key(self, temp_providers_file, capsys):
from cmdforge.providers import Provider, save_providers
@@ -828,6 +1030,23 @@ def test_switch_to_existing_tool_closes_creation_page_first():
]
def test_tool_builder_save_scope_prefers_recognized_projects(tmp_path):
pytest.importorskip("PySide6")
from cmdforge.gui.pages.tool_builder_page import default_save_scope
assert default_save_scope(tmp_path) == "global"
(tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n")
assert default_save_scope(tmp_path) == "project"
def test_tool_builder_existing_tool_root_does_not_nest_tool_name(tmp_path):
pytest.importorskip("PySide6")
from cmdforge.gui.pages.tool_builder_page import existing_tool_root
config = tmp_path / ".cmdforge" / "demo" / "config.yaml"
assert existing_tool_root(config) == (tmp_path / ".cmdforge").resolve()
def test_guided_extraction_builds_new_tool_without_mutating_draft():
pytest.importorskip("PySide6")
from cmdforge.gui.pages.tool_builder_page import (