Add project tools and strict provider execution
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user