From 6a88fe2e0ff6c28dcb9451a7283ae30e493e7d97 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 20 Jul 2026 22:24:44 -0300 Subject: [PATCH] Add project tools and strict provider execution --- AGENTS.md | 2 + CLAUDE.md | 6 + README.md | 9 +- .../tool-builder-save-location.png | Bin 0 -> 83691 bytes src/cmdforge/cli/__init__.py | 50 ++- src/cmdforge/cli/provider_commands.py | 40 ++ src/cmdforge/cli/tool_commands.py | 171 +++++++- src/cmdforge/gui/dialogs/provider_dialog.py | 82 +++- src/cmdforge/gui/dialogs/step_dialog.py | 15 +- src/cmdforge/gui/pages/tool_builder_page.py | 75 +++- src/cmdforge/providers.py | 386 ++++++++++++++++-- src/cmdforge/resolver.py | 4 +- src/cmdforge/runner.py | 150 ++++++- src/cmdforge/tool.py | 54 ++- tests/test_cli.py | 219 ++++++++++ tests/test_providers.py | 216 +++++++++- tests/test_runner.py | 69 ++++ tests/test_skills.py | 4 +- tests/test_tool.py | 36 ++ 19 files changed, 1504 insertions(+), 84 deletions(-) create mode 100644 assets/screenshots/tool-builder-save-location.png diff --git a/AGENTS.md b/AGENTS.md index 1d121a3..b7ed9ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,8 @@ cf # Interactive tool picker - Use `cmdforge run-once "Instruction {input}"` with piped input for ad-hoc AI work; create a permanent tool only when the workflow is reusable. - Prefer CmdForge for local automation and composable workflows. Direct SDK/API integration is appropriate when the external API is a runtime dependency of the product itself. - Bootstrap supported coding hosts with `cmdforge mcp configure codex` or `cmdforge mcp configure claude-code`; preview changes with `--dry-run`. This updates only a marked policy block and never expands `server.expose`. +- Create project-owned tools with `cmdforge create NAME --project` or `forge-tool --name NAME --project`; do not edit CmdForge's source repository to add a consumer project's tools. +- For sensitive inputs, use `--no-fallback`, explicit provider constraints, and `--result-envelope json`; provenance is attached by CmdForge and must not be generated by the model. ## Testing diff --git a/CLAUDE.md b/CLAUDE.md index 7b8a7fb..4b4009a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ python -m cmdforge.cli # Alternative CLI invocation - **resolver.py**: Tool resolution. `resolve_tool()` searches: project manifest → local tools → owner/name → registry. Returns `ResolvedTool` with path info - **collection.py**: Collection management (`Collection` dataclass, `resolve_tool_references()`, `classify_tool_reference()`), local collection storage in `~/.cmdforge/collections/` - **providers.py**: Provider abstraction. Supports subprocess CLI tools, OpenAI-compatible HTTP APIs, experimental PTY wrappers, fallback chains, and tool/MCP-server allowlists. Auto-discovers installed providers on first run. Config in `~/.cmdforge/providers.yaml` with versioned migration. +- **Provider execution contract**: Providers declare locality, typed capabilities, model identity, context/cost/latency hints, and an approved data classification. `ProviderExecutionPolicy` filters every fallback candidate; `ProviderResult` records runtime-owned provider/model provenance. - **skills.py**: Validated Agent Skills loader for per-provider `SKILL.md` context under `~/.cmdforge/providers//skills/` - **mcp_client.py / mcp_server.py**: Stdio and Streamable HTTP MCP integration, configuration, schema discovery, transport security, and exposure policy - **profiles.py**: AI persona profiles with system prompts, stored in `~/.cmdforge/profiles/` @@ -115,6 +116,11 @@ Use `cmdforge mcp configure codex` or `cmdforge mcp configure claude-code --scope project` to register the stdio MCP server and install a marked project-policy block. Preview with `--dry-run`. This bootstrap never adds tools to the MCP `server.expose` allowlist. +Project-owned tools belong under `./.cmdforge/`, created with +`cmdforge create NAME --project` or `forge-tool --name NAME --project`. Do not +edit CmdForge core merely to add another project's tools. Sensitive runs should +use `--no-fallback` and `--result-envelope json`; provenance must come from the +runtime rather than model-generated fields. ### Step Types diff --git a/README.md b/README.md index 417dc31..01fb0d3 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,9 @@ Opens the graphical interface where you can create and manage tools visually. Fe - **Registry** - Search and install community tools from the CmdForge registry - **Providers** - Manage AI provider configurations - **Reuse & Discovery** - Compare similar tools and preview safe extraction of reusable steps +- **Scoped saving** - Save a new tool to the current project or your global tool collection + +![Tool Builder save-location selector](assets/screenshots/tool-builder-save-location.png) ### CLI Mode @@ -132,11 +135,15 @@ Opens the graphical interface where you can create and manage tools visually. Fe cmdforge list --filter summarize # Filter installed tools for humans cmdforge list --json --filter summarize --limit 10 # Compact agent catalog cmdforge list --json --full --limit 1 # Explicitly include prompts/code -cmdforge create mytool # Create new tool +cmdforge create mytool # Create a global tool +cmdforge create mytool --project # Versionable ./.cmdforge tool +echo "Build a bounded classifier" | forge-tool --name classify --project cmdforge edit mytool # Edit in $EDITOR cmdforge delete mytool # Delete tool cmdforge run mytool # Run a tool cat document.txt | cmdforge run-once "Summarize this:\n\n{input}" +cmdforge run private-tool --provider ollama --no-fallback \ + --require-local --data-classification private --result-envelope json cmdforge test mytool # Test with mock provider cmdforge check mytool # Check dependencies (meta-tools) cmdforge inspect mytool # Preflight, contract proposals, safe conformance tests diff --git a/assets/screenshots/tool-builder-save-location.png b/assets/screenshots/tool-builder-save-location.png new file mode 100644 index 0000000000000000000000000000000000000000..8bc86697959b59cb274e8eb36518e16b479d37ca GIT binary patch literal 83691 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 literal 0 HcmV?d00001 diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index 039a00a..741e039 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -2,6 +2,7 @@ import argparse import sys +from pathlib import Path from .. import __version__ @@ -50,6 +51,15 @@ def main(): p_create.add_argument("-p", "--prompt", help="Prompt template") p_create.add_argument("--provider", help="AI provider (default: mock)") p_create.add_argument("-f", "--force", action="store_true", help="Overwrite existing") + create_location = p_create.add_mutually_exclusive_group() + create_location.add_argument( + "--project", action="store_true", + help="Save under ./.cmdforge instead of ~/.cmdforge", + ) + create_location.add_argument( + "--output-dir", type=Path, + help="Save under an explicit tool root directory", + ) p_create.set_defaults(func=cmd_create) # 'edit' command @@ -81,6 +91,13 @@ def main(): p_run.add_argument("--show-prompt", action="store_true", help="Show prompts in addition to output") p_run.add_argument("-v", "--verbose", action="store_true", help="Show debug information") p_run.add_argument("--auto-install", action="store_true", help="Automatically install missing tool dependencies") + p_run.add_argument("--no-fallback", action="store_true", help="Fail instead of trying configured fallback providers") + p_run.add_argument("--require-local", action="store_true", help="Allow only providers declared local") + p_run.add_argument("--require-capability", action="append", default=[], help="Required provider capability (repeatable)") + p_run.add_argument("--data-classification", choices=["public", "internal", "private"], help="Input data classification") + p_run.add_argument("--require-model-identity", action="store_true") + p_run.add_argument("--require-model-digest", action="store_true") + p_run.add_argument("--result-envelope", choices=["json"], help="Wrap output with verified execution provenance") p_run.add_argument("tool_args", nargs=argparse.REMAINDER, help="Additional tool-specific arguments (use -- to separate)") p_run.set_defaults(func=cmd_run) @@ -100,6 +117,13 @@ def main(): ) 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.add_argument("--no-fallback", action="store_true") + p_run_once.add_argument("--require-local", action="store_true") + p_run_once.add_argument("--require-capability", action="append", default=[]) + p_run_once.add_argument("--data-classification", choices=["public", "internal", "private"]) + p_run_once.add_argument("--require-model-identity", action="store_true") + p_run_once.add_argument("--require-model-digest", action="store_true") + p_run_once.add_argument("--result-envelope", choices=["json"]) p_run_once.set_defaults(func=cmd_run_once) # 'ui' command (explicit) @@ -157,6 +181,7 @@ def main(): # providers list p_prov_list = providers_sub.add_parser("list", help="List all providers and their status") + p_prov_list.add_argument("--json", action="store_true") p_prov_list.set_defaults(func=cmd_providers) # providers check @@ -181,6 +206,27 @@ def main(): p_prov_add.add_argument("--model", help="Model ID for API providers") p_prov_add.add_argument("--api-key-env", help="Environment variable containing the API key") p_prov_add.add_argument("--tag", action="append", dest="tags", help="Provider tag (repeatable)") + p_prov_add.add_argument( + "--locality", choices=["local", "remote", "unknown"], + help="Where inference runs", + ) + p_prov_add.add_argument( + "--capability", action="append", dest="capabilities", + help="Typed capability such as structured-json (repeatable)", + ) + p_prov_add.add_argument("--model-digest", help="Verified model content digest") + p_prov_add.add_argument( + "--cost-class", choices=["free", "low", "medium", "high"] + ) + p_prov_add.add_argument( + "--latency-class", choices=["fast", "standard", "slow"] + ) + p_prov_add.add_argument("--max-context-tokens", type=int) + p_prov_add.add_argument( + "--data-policy", + choices=["unspecified", "public", "internal", "private"], + help="Maximum approved input classification", + ) p_prov_add.add_argument("--fallback", help="Fallback provider name; pass an empty value to clear") p_prov_add.add_argument( "--fallback-chain", @@ -667,9 +713,9 @@ def main(): def cmd_inspect(args): """Run preflight analysis on a tool.""" from ..preflight import analyze_tool - from ..tool import load_tool + from .tool_commands import _resolve_existing_tool - tool = load_tool(args.name) + tool = _resolve_existing_tool(args.name) if not tool: print(f"Error: Tool '{args.name}' not found.", file=sys.stderr) return 1 diff --git a/src/cmdforge/cli/provider_commands.py b/src/cmdforge/cli/provider_commands.py index ce4f86a..d725d10 100644 --- a/src/cmdforge/cli/provider_commands.py +++ b/src/cmdforge/cli/provider_commands.py @@ -353,6 +353,17 @@ def _cmd_providers_discover(args): def _cmd_providers_list(args): """List all providers and their status.""" providers = load_providers() + if getattr(args, "json", False): + import json + payload = [] + for provider in providers: + available, status = _provider_status(provider) + item = provider.to_dict() + item["available"] = available + item["status"] = status + payload.append(item) + print(json.dumps(payload, indent=2)) + return 0 print(f"Configured providers ({len(providers)}):\n") for p in providers: exists, status = _provider_status(p) @@ -365,6 +376,15 @@ def _cmd_providers_list(args): print(f" Status: {status}") if p.description: print(f" Info: {p.description}") + print(f" Locality: {p.locality}") + print(f" Data policy: {p.data_policy}") + if p.capabilities: + print(f" Capabilities: {', '.join(p.capabilities)}") + if p.model: + identity = p.model + if p.model_digest: + identity += f" ({p.model_digest})" + print(f" Model: {identity}") print() return 0 @@ -379,6 +399,17 @@ def _cmd_providers_add(args): model = args.model if args.model is not None else (existing.model if existing else None) api_key_env = args.api_key_env if args.api_key_env is not None else (existing.api_key_env if existing else None) tags = args.tags if args.tags is not None else (existing.tags if existing else []) + locality = args.locality if args.locality is not None else (existing.locality if existing else "unknown") + capabilities = args.capabilities if args.capabilities is not None else (existing.capabilities if existing else []) + model_digest = args.model_digest if args.model_digest is not None else (existing.model_digest if existing else None) + cost_class = args.cost_class if args.cost_class is not None else (existing.cost_class if existing else None) + latency_class = args.latency_class if args.latency_class is not None else (existing.latency_class if existing else None) + max_context_tokens = ( + args.max_context_tokens + if args.max_context_tokens is not None + else existing.max_context_tokens if existing else None + ) + data_policy = args.data_policy if args.data_policy is not None else (existing.data_policy if existing else "unspecified") fallback = args.fallback if args.fallback is not None else (existing.fallback if existing else None) fallback = fallback or None @@ -406,6 +437,15 @@ def _cmd_providers_add(args): fallback_chain=fallback_chain, api_key_env=api_key_env, pty_config=existing.pty_config if existing else None, + tools=existing.tools if existing else None, + mcp_servers=existing.mcp_servers if existing else None, + locality=locality, + capabilities=capabilities, + model_digest=model_digest, + cost_class=cost_class, + latency_class=latency_class, + max_context_tokens=max_context_tokens, + data_policy=data_policy, ) add_provider(provider) print(f"Provider '{name}' added/updated.") diff --git a/src/cmdforge/cli/tool_commands.py b/src/cmdforge/cli/tool_commands.py index be5c1e0..8c2ad35 100644 --- a/src/cmdforge/cli/tool_commands.py +++ b/src/cmdforge/cli/tool_commands.py @@ -5,11 +5,105 @@ from pathlib import Path from ..tool import ( list_tools, load_tool, save_tool, delete_tool, get_tools_dir, - Tool, ToolArgument, PromptStep, CodeStep, ToolStep, validate_tool_name + get_project_tools_dir, Tool, ToolArgument, PromptStep, CodeStep, ToolStep, + validate_tool_name ) from ..gui import run_gui +def _resolve_existing_tool(name: str): + """Resolve project-local before global without registry auto-fetch.""" + from ..resolver import find_tool + + project_candidate = any( + (Path.cwd() / root / name / "config.yaml").exists() + for root in (".cmdforge", "cmdforge") + ) + if project_candidate: + resolved = find_tool(name) + if resolved: + return resolved.tool + from .. import tool as tool_module + return tool_module.load_tool(name) + + +def _project_tool_names(project_dir=None) -> list[str]: + """List project-local references using the same two resolver roots.""" + project_dir = (project_dir or Path.cwd()).resolve() + names = [] + for root_name in (".cmdforge", "cmdforge"): + root = project_dir / root_name + if not root.is_dir(): + continue + for item in sorted(root.iterdir()): + if not item.is_dir() or item.name.startswith("."): + continue + if (item / "config.yaml").is_file(): + names.append(item.name) + continue + for child in sorted(item.iterdir()): + if child.is_dir() and (child / "config.yaml").is_file(): + names.append(f"{item.name}/{child.name}") + return list(dict.fromkeys(names)) + + +def _execution_policy_from_args(*namespaces): + """Combine top-level and wrapper-compatible execution policy flags.""" + from ..providers import ProviderExecutionPolicy + + capabilities = [] + for namespace in namespaces: + capabilities.extend( + getattr(namespace, "require_capability", None) or [] + ) + data_classification = next(( + getattr(namespace, "data_classification", None) + for namespace in namespaces + if getattr(namespace, "data_classification", None) + ), "public") + return ProviderExecutionPolicy( + allow_fallback=not any( + getattr(namespace, "no_fallback", False) for namespace in namespaces + ), + required_locality=( + "local" if any( + getattr(namespace, "require_local", False) + for namespace in namespaces + ) else None + ), + required_capabilities=tuple(dict.fromkeys(capabilities)), + data_classification=data_classification, + require_model_identity=any( + getattr(namespace, "require_model_identity", False) + for namespace in namespaces + ), + require_model_digest=any( + getattr(namespace, "require_model_digest", False) + for namespace in namespaces + ), + ) + + +def _result_envelope( + output: str, provider_calls: list[dict], exit_code: int = 0 +) -> str: + """Build a stable machine-readable envelope without parsing tool output.""" + import json + + execution = { + "exit_code": exit_code, + "provider_calls": provider_calls, + "fallback_used": any( + call.get("fallback_used", False) for call in provider_calls + ), + "actual_providers": list(dict.fromkeys( + call["actual_provider"] for call in provider_calls + if call.get("actual_provider") + )), + } + return json.dumps({"output": output, "execution": execution}, indent=2) + + def cmd_list(args): """List all tools.""" import json @@ -24,8 +118,10 @@ def cmd_list(args): return 1 matches = [] - for name in sorted(list_tools()): - tool = load_tool(name) + project_names = _project_tool_names() + all_names = list(dict.fromkeys([*project_names, *sorted(list_tools())])) + for name in all_names: + tool = _resolve_existing_tool(name) if not tool: continue if filter_text and ( @@ -151,15 +247,22 @@ def cmd_run_once(args): return 1 print(f"[run-once] Provider: {provider}", file=sys.stderr) + policy = _execution_policy_from_args(args) result = call_provider( - provider, prompt, timeout=timeout, max_tokens=max_tokens + provider, prompt, timeout=timeout, max_tokens=max_tokens, + execution_policy=policy, ) if not result.success: + if getattr(args, "result_envelope", None) == "json": + print(_result_envelope("", [result.provenance()], exit_code=1)) print(f"Error: {result.error}", file=sys.stderr) return 1 - print(result.text) + if getattr(args, "result_envelope", None) == "json": + print(_result_envelope(result.text, [result.provenance()])) + else: + print(result.text) return 0 @@ -242,10 +345,22 @@ def cmd_create(args): print(f"Error: Invalid tool name '{name}': {error_msg}") return 1 - # Check if already exists - existing = load_tool(name) - if existing and not args.force: - print(f"Error: Tool '{name}' already exists. Use --force to overwrite.") + output_dir = getattr(args, "output_dir", None) + project = getattr(args, "project", False) + target_root = ( + Path(output_dir).resolve() + if output_dir is not None + else get_project_tools_dir() if project else get_tools_dir() + ) + + # Check only the requested scope. A project tool may intentionally shadow + # a global tool with the same name. + config_path = target_root / name / "config.yaml" + if config_path.exists() and not args.force: + print( + f"Error: Tool '{name}' already exists at {config_path}. " + "Use --force to overwrite." + ) return 1 # Create a tool with a single prompt step @@ -266,11 +381,19 @@ def cmd_create(args): output="{response}" if steps else "{input}" ) - path = save_tool(tool) + path = save_tool(tool, tools_dir=target_root) print(f"Created tool '{name}'") print(f"Config: {path}") - print(f"\nUse 'cmdforge ui' to add arguments, steps, and customize.") - print(f"Or run: {name} < input.txt") + print("\nUse 'cmdforge ui' to add arguments, steps, and customize.") + if project: + print(f"Run from its project: cmdforge run {name} < input.txt") + elif output_dir is not None: + print( + "Saved under the requested tool root. CmdForge automatically " + "discovers roots named .cmdforge or cmdforge." + ) + else: + print(f"Or run: {name} < input.txt") return 0 @@ -323,7 +446,7 @@ def cmd_delete(args): def cmd_test(args): """Test a tool with mock provider.""" - tool = load_tool(args.name) + tool = _resolve_existing_tool(args.name) if not tool: print(f"Error: Tool '{args.name}' not found.") return 1 @@ -360,7 +483,7 @@ def cmd_run(args): """Run a tool.""" from ..runner import collect_custom_args, create_argument_parser, run_tool - tool = load_tool(args.name) + tool = _resolve_existing_tool(args.name) if not tool: print(f"Error: Tool '{args.name}' not found.", file=sys.stderr) return 1 @@ -404,6 +527,12 @@ def cmd_run(args): show_prompt = args.show_prompt or parsed_tool_args.show_prompt verbose = args.verbose or parsed_tool_args.verbose auto_install = args.auto_install or parsed_tool_args.auto_install + execution_policy = _execution_policy_from_args(args, parsed_tool_args) + result_envelope = ( + getattr(args, "result_envelope", None) + or getattr(parsed_tool_args, "result_envelope", None) + ) + provider_calls = [] # Run tool output, code = run_tool( @@ -414,16 +543,22 @@ def cmd_run(args): dry_run=dry_run, show_prompt=show_prompt, verbose=verbose, - auto_install=auto_install + auto_install=auto_install, + execution_policy=execution_policy, + provenance_log=provider_calls, ) # Write output - if code == 0 and output: + if result_envelope == "json" or (code == 0 and output): + rendered_output = ( + _result_envelope(output, provider_calls, exit_code=code) + if result_envelope == "json" else output + ) output_file = args.output or parsed_tool_args.output_file if output_file: - Path(output_file).write_text(output) + Path(output_file).write_text(rendered_output) else: - print(output) + print(rendered_output) return code diff --git a/src/cmdforge/gui/dialogs/provider_dialog.py b/src/cmdforge/gui/dialogs/provider_dialog.py index 3f4e477..50abf59 100644 --- a/src/cmdforge/gui/dialogs/provider_dialog.py +++ b/src/cmdforge/gui/dialogs/provider_dialog.py @@ -44,6 +44,44 @@ class ProviderDialog(QDialog): self.desc_input.setPlaceholderText("Claude AI via claude-cli") form.addRow("Description:", self.desc_input) + self.locality_combo = QComboBox() + for value in ("unknown", "local", "remote"): + self.locality_combo.addItem(value.title(), value) + form.addRow("Locality:", self.locality_combo) + + self.data_policy_combo = QComboBox() + for value in ("unspecified", "public", "internal", "private"): + self.data_policy_combo.addItem(value.title(), value) + form.addRow("Data policy:", self.data_policy_combo) + + self.capabilities_input = QLineEdit() + self.capabilities_input.setPlaceholderText("text, structured-json, reasoning") + form.addRow("Capabilities:", self.capabilities_input) + + self.model_input = QLineEdit() + self.model_input.setPlaceholderText("Model ID, if known") + form.addRow("Model:", self.model_input) + + self.model_digest_input = QLineEdit() + self.model_digest_input.setPlaceholderText("sha256:... (verified local models)") + form.addRow("Model digest:", self.model_digest_input) + + self.max_context_input = QLineEdit() + self.max_context_input.setPlaceholderText("Maximum context tokens") + form.addRow("Context tokens:", self.max_context_input) + + self.cost_class_combo = QComboBox() + self.cost_class_combo.addItem("Unspecified", None) + for value in ("free", "low", "medium", "high"): + self.cost_class_combo.addItem(value.title(), value) + form.addRow("Cost class:", self.cost_class_combo) + + self.latency_class_combo = QComboBox() + self.latency_class_combo.addItem("Unspecified", None) + for value in ("fast", "standard", "slow"): + self.latency_class_combo.addItem(value.title(), value) + form.addRow("Latency class:", self.latency_class_combo) + self.fallback_combo = QComboBox() self.fallback_combo.addItem("(none)", None) # Populate with available providers @@ -86,6 +124,24 @@ class ProviderDialog(QDialog): self.name_input.setEnabled(False) # Can't rename self.cmd_input.setText(provider.command) self.desc_input.setText(provider.description or "") + self.locality_combo.setCurrentIndex( + max(self.locality_combo.findData(provider.locality), 0) + ) + self.data_policy_combo.setCurrentIndex( + max(self.data_policy_combo.findData(provider.data_policy), 0) + ) + self.capabilities_input.setText(", ".join(provider.capabilities)) + self.model_input.setText(provider.model or "") + self.model_digest_input.setText(provider.model_digest or "") + self.max_context_input.setText( + str(provider.max_context_tokens) if provider.max_context_tokens else "" + ) + self.cost_class_combo.setCurrentIndex( + max(self.cost_class_combo.findData(provider.cost_class), 0) + ) + self.latency_class_combo.setCurrentIndex( + max(self.latency_class_combo.findData(provider.latency_class), 0) + ) # Set fallback selection if provider.fallback: idx = self.fallback_combo.findData(provider.fallback) @@ -107,6 +163,21 @@ class ProviderDialog(QDialog): description = self.desc_input.text().strip() fallback = self.fallback_combo.currentData() + capabilities = [ + value.strip() for value in self.capabilities_input.text().split(",") + if value.strip() + ] + try: + max_context_tokens = ( + int(self.max_context_input.text().strip()) + if self.max_context_input.text().strip() else None + ) + except ValueError: + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning( + self, "Invalid", "Context tokens must be a positive integer." + ) + return # Prevent self-referential fallback if fallback == name: @@ -122,12 +193,21 @@ class ProviderDialog(QDialog): description=description, fallback=fallback, type=existing.type if existing else "subprocess", - model=existing.model if existing else None, + model=self.model_input.text().strip() or None, tags=existing.tags if existing else [], install=existing.install if existing else None, fallback_chain=existing.fallback_chain if existing else None, api_key_env=existing.api_key_env if existing else None, pty_config=existing.pty_config if existing else None, + tools=existing.tools if existing else None, + mcp_servers=existing.mcp_servers if existing else None, + locality=self.locality_combo.currentData(), + capabilities=capabilities, + model_digest=self.model_digest_input.text().strip() or None, + cost_class=self.cost_class_combo.currentData(), + latency_class=self.latency_class_combo.currentData(), + max_context_tokens=max_context_tokens, + data_policy=self.data_policy_combo.currentData(), )) self.accept() except Exception as e: diff --git a/src/cmdforge/gui/dialogs/step_dialog.py b/src/cmdforge/gui/dialogs/step_dialog.py index 36bd35d..ec3b465 100644 --- a/src/cmdforge/gui/dialogs/step_dialog.py +++ b/src/cmdforge/gui/dialogs/step_dialog.py @@ -321,6 +321,15 @@ class PromptStepDialog(QDialog): self.strip_fences_check = QCheckBox("Strip markdown code fences from output") form.addRow("", self.strip_fences_check) + self.allow_fallback_check = QCheckBox( + "Allow configured fallback providers if this provider fails" + ) + self.allow_fallback_check.setChecked(True) + self.allow_fallback_check.setToolTip( + "Disable for privacy-sensitive or reproducible steps that must fail closed." + ) + form.addRow("", self.allow_fallback_check) + # Structured output options form.addRow(QLabel("")) # Spacer structured_label = QLabel("Structured Output") @@ -455,6 +464,7 @@ class PromptStepDialog(QDialog): self.output_input.setText(step.output_var) self.prompt_input.setPlainText(step.prompt) self.strip_fences_check.setChecked(step.strip_fences) + self.allow_fallback_check.setChecked(step.fallback_policy != "deny") # Structured output fields self.plain_text_check.setChecked(step.plain_text) @@ -497,7 +507,10 @@ class PromptStepDialog(QDialog): strip_fences=self.strip_fences_check.isChecked(), output_schema=self._output_schema, plain_text=self.plain_text_check.isChecked(), - max_retries=self.retries_spin.value() + max_retries=self.retries_spin.value(), + fallback_policy=( + "allow" if self.allow_fallback_check.isChecked() else "deny" + ), ) diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index f594fe0..fbe5650 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -3,6 +3,7 @@ import copy import difflib import re +from pathlib import Path import yaml @@ -17,8 +18,8 @@ from PySide6.QtCore import Qt, QThread, Signal, QTimer from ...tool import ( Tool, ToolArgument, PromptStep, CodeStep, ToolStep, - load_tool, save_tool, tool_exists, validate_tool_name, get_all_categories, - ensure_settings + load_tool, save_tool, validate_tool_name, get_all_categories, + ensure_settings, get_project_tools_dir, get_tools_dir ) from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon @@ -107,6 +108,21 @@ def _switch_to_existing_tool(main_window, name: str) -> None: main_window.open_tool_builder(name) +def default_save_scope(project_dir: Path) -> str: + """Prefer project scope only when the cwd is recognizably a project.""" + project_dir = Path(project_dir) + if any((project_dir / marker).exists() for marker in ( + "cmdforge.yaml", ".cmdforge", ".git", "pyproject.toml", "package.json", + )): + return "project" + return "global" + + +def existing_tool_root(config_path: Path) -> Path: + """Return the tool-root directory containing an existing named tool.""" + return Path(config_path).resolve().parent.parent + + class ToolBuilderPage(QWidget): """Tool builder/editor page.""" @@ -185,6 +201,23 @@ class ToolBuilderPage(QWidget): self.category_combo.addItem(cat) info_layout.addRow("Category:", self.category_combo) + self.save_location_combo = QComboBox() + project_root = get_project_tools_dir() + self.save_location_combo.addItem( + f"This project ({project_root})", "project" + ) + self.save_location_combo.addItem( + f"My tools ({get_tools_dir()})", "global" + ) + preferred_scope = default_save_scope(Path.cwd()) + preferred_index = self.save_location_combo.findData(preferred_scope) + self.save_location_combo.setCurrentIndex(max(preferred_index, 0)) + self.save_location_combo.setToolTip( + "Project tools are versionable and resolve only from this project; " + "My tools are available globally and receive a shell wrapper." + ) + info_layout.addRow("Save tool:", self.save_location_combo) + left_layout.addWidget(info_box) # Reuse guidance is advisory and never changes a draft automatically. @@ -658,7 +691,8 @@ class ToolBuilderPage(QWidget): if not valid: QMessageBox.warning(self, "Validation", error) return - if tool_exists(name): + extraction_root = self._selected_tools_dir() + if (extraction_root / name / "config.yaml").exists(): QMessageBox.warning(self, "Tool Already Exists", f"'{name}' already exists.") return try: @@ -684,7 +718,7 @@ class ToolBuilderPage(QWidget): if preview.clickedButton() is not create_button: return try: - save_tool(extracted) + save_tool(extracted, tools_dir=extraction_root) except (OSError, ValueError) as exc: QMessageBox.critical(self, "Extraction Failed", str(exc)) return @@ -807,6 +841,17 @@ class ToolBuilderPage(QWidget): self._tool = tool self.name_input.setText(tool.name) self.name_input.setEnabled(False) # Can't rename + if tool.path: + project_root = get_project_tools_dir() + scope = ( + "project" + if existing_tool_root(tool.path) == project_root.resolve() + else "global" + ) + index = self.save_location_combo.findData(scope) + if index >= 0: + self.save_location_combo.setCurrentIndex(index) + self.save_location_combo.setEnabled(False) self.desc_input.setText(tool.description or "") # Set category @@ -1371,9 +1416,13 @@ class ToolBuilderPage(QWidget): QMessageBox.warning(self, "Validation", error) return - # Block silent overwrite when creating a new tool - if (not self.editing or name != self.original_name) and tool_exists(name): - existing = load_tool(name) + target_root = self._selected_tools_dir() + + # Block silent overwrite in the selected scope. A project-local tool + # may intentionally shadow a global tool with the same name. + target_config = target_root / name / "config.yaml" + if (not self.editing or name != self.original_name) and target_config.exists(): + existing = load_tool(name) if self.save_location_combo.currentData() == "global" else None existing_desc = f" ({existing.description})" if existing and existing.description else "" msg = QMessageBox(self) msg.setIcon(QMessageBox.Warning) @@ -1393,7 +1442,7 @@ class ToolBuilderPage(QWidget): return elif clicked is btn_copy: suffix = 2 - while tool_exists(f"{name}-{suffix}"): + while (target_root / f"{name}-{suffix}" / "config.yaml").exists(): suffix += 1 copy_name = f"{name}-{suffix}" self.name_input.setText(copy_name) @@ -1514,7 +1563,7 @@ class ToolBuilderPage(QWidget): tool.version = self._tool.version try: - config_path = save_tool(tool) + config_path = save_tool(tool, tools_dir=target_root) tool_dir = config_path.parent # Save defaults if provided @@ -1551,6 +1600,14 @@ class ToolBuilderPage(QWidget): """Cancel and return to tools page.""" self.main_window.close_tool_builder() + def _selected_tools_dir(self) -> Path: + """Return the save root selected by the author.""" + if self.editing and self._tool and self._tool.path: + return existing_tool_root(self._tool.path) + if self.save_location_combo.currentData() == "project": + return get_project_tools_dir() + return get_tools_dir().resolve() + def save_tool(self): """Public method for keyboard shortcut to save the tool.""" self._save() diff --git a/src/cmdforge/providers.py b/src/cmdforge/providers.py index 0ec457c..a73f50a 100644 --- a/src/cmdforge/providers.py +++ b/src/cmdforge/providers.py @@ -55,6 +55,9 @@ KNOWN_PROVIDER_CLIS = { "command": "opencode run --model opencode/big-pickle", "description": "OpenCode - Big Pickle (free general model)", "tags": ["free", "code", "general"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", "install_group": "opencode", }, "agy": { @@ -62,6 +65,9 @@ KNOWN_PROVIDER_CLIS = { "command": "agy -p", "description": "Antigravity - Google free tier, Gemini models", "tags": ["free-tier", "code", "large-context"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", "install_group": "agy", }, "codex": { @@ -69,6 +75,9 @@ KNOWN_PROVIDER_CLIS = { "command": "codex exec -", "description": "Codex CLI - OpenAI free tier available", "tags": ["free-tier", "code", "general"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", "install_group": "codex", }, "claude": { @@ -76,6 +85,9 @@ KNOWN_PROVIDER_CLIS = { "command": "claude -p", "description": "Claude Code - auto-routes to best model", "tags": ["paid", "subscription", "code"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", "install_group": "claude", }, "crush": { @@ -83,6 +95,9 @@ KNOWN_PROVIDER_CLIS = { "command": "crush run --quiet", "description": "Crush - multi-model via Hyper credits or API keys", "tags": ["free-tier", "multi", "code"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", "install_group": "crush", }, "ollama": { @@ -90,6 +105,10 @@ KNOWN_PROVIDER_CLIS = { "command": "ollama run llama3.2", "description": "Ollama - local, private, free", "tags": ["free", "local", "private"], + "model": "llama3.2", + "locality": "local", + "capabilities": ["text", "structured-json"], + "data_policy": "private", "install_group": "ollama", }, } @@ -102,6 +121,9 @@ KNOWN_API_KEYS = { "model": "openrouter/auto-beta", "description": "OpenRouter - 300+ models, one API key", "tags": ["api", "per-token", "multi"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", }, "DEEPSEEK_API_KEY": { "name": "deepseek-api", @@ -109,6 +131,9 @@ KNOWN_API_KEYS = { "model": "deepseek-chat", "description": "DeepSeek API - inexpensive per-token access", "tags": ["api", "per-token", "cheap"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", }, "OPENAI_API_KEY": { "name": "openai-api", @@ -116,6 +141,9 @@ KNOWN_API_KEYS = { "model": "gpt-4o", "description": "OpenAI API - direct GPT access", "tags": ["api", "per-token", "code"], + "locality": "remote", + "capabilities": ["text", "structured-json", "reasoning"], + "data_policy": "public", }, } @@ -181,6 +209,10 @@ def discover_installed_providers() -> List[dict]: "command": f"ollama run {model_name}", "description": f"Ollama local model: {model_name}", "tags": ["free", "local", "private"], + "model": model_name, + "locality": "local", + "capabilities": ["text", "structured-json"], + "data_policy": "private", }) except (subprocess.TimeoutExpired, FileNotFoundError): pass @@ -188,6 +220,26 @@ def discover_installed_providers() -> List[dict]: return found +def _provider_from_discovery(data: dict) -> "Provider": + """Convert trusted discovery facts without persisting scanner-only keys.""" + return Provider( + name=data["name"], + command=data["command"], + description=data.get("description", ""), + type=data.get("type", "subprocess"), + model=data.get("model"), + tags=data.get("tags", []), + api_key_env=data.get("env_var"), + locality=data.get("locality", "unknown"), + capabilities=data.get("capabilities", []), + model_digest=data.get("model_digest"), + cost_class=data.get("cost_class"), + latency_class=data.get("latency_class"), + max_context_tokens=data.get("max_context_tokens"), + data_policy=data.get("data_policy", "unspecified"), + ) + + @dataclass class Provider: """Definition of an AI provider. @@ -215,6 +267,13 @@ class Provider: pty_config: Optional[dict] = None # Patterns for pty-type providers tools: Optional[List[str]] = None # Allowed CmdForge tools (None = all) mcp_servers: Optional[List[str]] = None # MCP servers available to this provider + locality: str = "unknown" # "local" | "remote" | "unknown" + capabilities: List[str] = field(default_factory=list) + model_digest: Optional[str] = None + cost_class: Optional[str] = None # "free" | "low" | "medium" | "high" + latency_class: Optional[str] = None # "fast" | "standard" | "slow" + max_context_tokens: Optional[int] = None + data_policy: str = "unspecified" # max: public | internal | private def __post_init__(self) -> None: for field_name, values in ( @@ -228,6 +287,30 @@ class Provider: raise ValueError( f"Provider {field_name} must be a list of non-empty strings or null" ) + if self.locality not in {"local", "remote", "unknown"}: + raise ValueError("Provider locality must be local, remote, or unknown") + if ( + not isinstance(self.capabilities, list) + or not all(isinstance(value, str) and value for value in self.capabilities) + ): + raise ValueError("Provider capabilities must be a list of non-empty strings") + if self.cost_class not in {None, "free", "low", "medium", "high"}: + raise ValueError("Provider cost_class must be free, low, medium, or high") + if self.latency_class not in {None, "fast", "standard", "slow"}: + raise ValueError("Provider latency_class must be fast, standard, or slow") + if self.data_policy not in {"unspecified", "public", "internal", "private"}: + raise ValueError( + "Provider data_policy must be unspecified, public, internal, or private" + ) + if ( + self.max_context_tokens is not None + and ( + not isinstance(self.max_context_tokens, int) + or isinstance(self.max_context_tokens, bool) + or self.max_context_tokens <= 0 + ) + ): + raise ValueError("Provider max_context_tokens must be a positive integer") def to_dict(self) -> dict: d = { @@ -256,6 +339,20 @@ class Provider: d["tools"] = self.tools if self.mcp_servers is not None: d["mcp_servers"] = self.mcp_servers + if self.locality != "unknown": + d["locality"] = self.locality + if self.capabilities: + d["capabilities"] = self.capabilities + if self.model_digest: + d["model_digest"] = self.model_digest + if self.cost_class: + d["cost_class"] = self.cost_class + if self.latency_class: + d["latency_class"] = self.latency_class + if self.max_context_tokens is not None: + d["max_context_tokens"] = self.max_context_tokens + if self.data_policy != "unspecified": + d["data_policy"] = self.data_policy return d @classmethod @@ -265,7 +362,7 @@ class Provider: command=data["command"], description=data.get("description", ""), type=data.get("type", "subprocess"), - model=data.get("model"), + model=data.get("model", data.get("model_id")), fallback=data.get("fallback"), tags=data.get("tags", []) or [], install=data.get("install"), @@ -274,6 +371,15 @@ class Provider: pty_config=data.get("pty_config"), tools=data.get("tools"), mcp_servers=data.get("mcp_servers"), + locality=data.get("locality", "unknown"), + capabilities=data.get("capabilities", []) or [], + model_digest=data.get("model_digest"), + cost_class=data.get("cost_class"), + latency_class=data.get("latency_class"), + max_context_tokens=data.get( + "max_context_tokens", data.get("max_input_tokens") + ), + data_policy=data.get("data_policy", "unspecified"), ) @@ -283,6 +389,164 @@ class ProviderResult: text: str success: bool error: Optional[str] = None + requested_provider: Optional[str] = None + actual_provider: Optional[str] = None + model: Optional[str] = None + model_digest: Optional[str] = None + attempted_providers: List[str] = field(default_factory=list) + fallback_used: bool = False + locality: str = "unknown" + model_identity_source: str = "unknown" + + def provenance(self) -> dict: + """Return runtime-owned execution facts safe for result envelopes.""" + data = { + "success": self.success, + "requested_provider": self.requested_provider, + "actual_provider": self.actual_provider, + "attempted_providers": list(self.attempted_providers), + "fallback_used": self.fallback_used, + "model": self.model, + "model_digest": self.model_digest, + "locality": self.locality, + "model_identity_source": self.model_identity_source, + } + if self.error: + data["error"] = self.error + return data + + +@dataclass(frozen=True) +class ProviderExecutionPolicy: + """Caller-supplied, fail-closed constraints for provider execution.""" + + allow_fallback: bool = True + required_locality: Optional[str] = None + required_capabilities: tuple[str, ...] = () + data_classification: str = "public" + require_model_identity: bool = False + require_model_digest: bool = False + + def __post_init__(self) -> None: + if self.required_locality not in {None, "local", "remote"}: + raise ValueError("required_locality must be local, remote, or null") + if self.data_classification not in {"public", "internal", "private"}: + raise ValueError( + "data_classification must be public, internal, or private" + ) + + +_DATA_CLASS_LEVEL = {"public": 0, "internal": 1, "private": 2} + + +def effective_provider_locality(provider: Provider) -> str: + """Account for Ollama clients redirected to a non-loopback host.""" + try: + executable = shlex.split(os.path.expandvars(provider.command))[0] + except (IndexError, ValueError): + executable = "" + if Path(executable).name != "ollama": + return provider.locality + ollama_host = os.environ.get("OLLAMA_HOST", "").strip() + if not ollama_host: + return provider.locality + from urllib.parse import urlparse + parsed = urlparse( + ollama_host if "://" in ollama_host else f"//{ollama_host}" + ) + hostname = (parsed.hostname or "").lower() + if hostname in {"localhost", "127.0.0.1", "::1"}: + return "local" + return "remote" + + +def runtime_provider_identity(provider: Provider) -> tuple[Optional[str], Optional[str], str]: + """Return observed model identity when supported, else configured facts.""" + configured = ( + provider.model, + provider.model_digest, + "provider-config" if provider.model or provider.model_digest else "unknown", + ) + try: + parts = shlex.split(os.path.expandvars(provider.command)) + except ValueError: + return configured + if not parts or Path(parts[0]).name != "ollama": + return configured + if effective_provider_locality(provider) != "local": + return configured + try: + run_index = parts.index("run") + model = provider.model or parts[run_index + 1] + except (ValueError, IndexError): + return configured + + host = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434").strip() + if "://" not in host: + host = f"http://{host}" + from urllib.request import Request, urlopen + import json + + request = Request(host.rstrip("/") + "/api/tags") + api_key = os.environ.get("OLLAMA_API_KEY") + if api_key: + request.add_header("Authorization", f"Bearer {api_key}") + try: + with urlopen(request, timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + except Exception: + return configured + wanted = {model, f"{model}:latest"} + for item in payload.get("models", []): + names = {item.get("name"), item.get("model")} + if names & wanted or any( + value and value.removesuffix(":latest") == model.removesuffix(":latest") + for value in names + ): + digest = item.get("digest") or provider.model_digest + if digest and not digest.startswith("sha256:"): + digest = f"sha256:{digest}" + return item.get("model") or item.get("name") or model, digest, "ollama-api" + return configured + + +def provider_policy_error( + provider: Provider, + policy: ProviderExecutionPolicy, + runtime_identity: Optional[tuple[Optional[str], Optional[str], str]] = None, +) -> Optional[str]: + """Return why a provider is ineligible, or None when it is allowed.""" + if ( + policy.required_locality is not None + and effective_provider_locality(provider) != policy.required_locality + ): + return ( + f"Provider '{provider.name}' locality is " + f"{effective_provider_locality(provider)}; " + f"{policy.required_locality} is required" + ) + missing = sorted(set(policy.required_capabilities) - set(provider.capabilities)) + if missing: + return ( + f"Provider '{provider.name}' lacks required capabilities: " + + ", ".join(missing) + ) + if policy.data_classification != "public": + allowed = _DATA_CLASS_LEVEL.get(provider.data_policy, -1) + needed = _DATA_CLASS_LEVEL[policy.data_classification] + if allowed < needed: + return ( + f"Provider '{provider.name}' data_policy is {provider.data_policy}; " + f"{policy.data_classification} data requires an explicit compatible policy" + ) + model, digest, _ = runtime_identity or ( + provider.model, provider.model_digest, "provider-config" + ) + if policy.require_model_identity and not model: + return f"Provider '{provider.name}' does not declare a model identity" + if policy.require_model_digest and not digest: + return f"Provider '{provider.name}' does not declare a model digest" + return None # Default providers that come pre-configured @@ -334,7 +598,12 @@ DEFAULT_PROVIDERS = [ # LOCAL MODELS Provider("ollama", "ollama run llama3.2", "Ollama - local, private, free (GPU recommended)", - tags=["free", "local", "private"]), + model="llama3.2", + tags=["free", "local", "private"], + locality="local", + capabilities=["text", "structured-json"], + cost_class="free", + data_policy="private"), # API-TYPE PROVIDERS (pay-per-token, fallback when no CLI covers model) Provider("openrouter", @@ -343,18 +612,26 @@ DEFAULT_PROVIDERS = [ type="api", model="openrouter/auto-beta", api_key_env="OPENROUTER_API_KEY", - tags=["api", "per-token", "multi", "fallback"]), + tags=["api", "per-token", "multi", "fallback"], + locality="remote", + capabilities=["text", "structured-json", "reasoning"], + data_policy="public"), Provider("deepseek-api", "https://api.deepseek.com/v1", "DeepSeek API - inexpensive per-token access", type="api", model="deepseek-chat", api_key_env="DEEPSEEK_API_KEY", - tags=["api", "per-token", "cheap", "reasoning"]), + tags=["api", "per-token", "cheap", "reasoning"], + locality="remote", + capabilities=["text", "structured-json", "reasoning"], + data_policy="public"), # Mock for testing Provider("mock", "mock", "Mock provider for testing", - tags=["testing"]), + model="mock", tags=["testing"], locality="local", + capabilities=["text", "structured-json"], + data_policy="private"), ] @@ -379,10 +656,7 @@ def get_providers_file() -> Path: for d in cli_found: name = d["name"] desc = d.get("description", "") - selected.append(Provider( - name=name, command=d["command"], description=desc, - tags=d.get("tags", []), - )) + selected.append(_provider_from_discovery(d)) print(f" [+] {name:20s} {desc}", file=sys.stderr) if api_found: @@ -392,11 +666,7 @@ def get_providers_file() -> Path: desc = d.get("description", "") model = d.get("model", "auto") env_var = d.get("env_var", "") - selected.append(Provider( - name=name, command=d["command"], description=desc, - type="api", model=model, api_key_env=env_var, - tags=d.get("tags", []), - )) + selected.append(_provider_from_discovery(d)) print(f" [+] {name:20s} ({model})", file=sys.stderr) if ollama_found: @@ -404,10 +674,7 @@ def get_providers_file() -> Path: for d in ollama_found[:10]: name = d["name"] desc = d.get("description", "") - selected.append(Provider( - name=name, command=d["command"], description=desc, - tags=d.get("tags", []), - )) + selected.append(_provider_from_discovery(d)) print(f" [+] {name:30s} {d['command']}", file=sys.stderr) if len(ollama_found) > 10: print(f" ... and {len(ollama_found) - 10} more", file=sys.stderr) @@ -514,7 +781,17 @@ def delete_provider(name: str) -> bool: return False -def call_provider(provider_name: str, prompt: str, timeout: int = 300, max_tokens: Optional[int] = None, _tried: Optional[set] = None) -> ProviderResult: +def call_provider( + provider_name: str, + prompt: str, + timeout: int = 300, + max_tokens: Optional[int] = None, + _tried: Optional[set] = None, + *, + execution_policy: Optional[ProviderExecutionPolicy] = None, + _requested_provider: Optional[str] = None, + _attempted_providers: Optional[List[str]] = None, +) -> ProviderResult: """ Call an AI provider with the given prompt. @@ -528,59 +805,110 @@ def call_provider(provider_name: str, prompt: str, timeout: int = 300, max_token timeout: Maximum execution time in seconds max_tokens: Optional max output tokens (appends provider-specific flag) _tried: Internal set of already-tried providers (prevents infinite loops) + execution_policy: Optional fallback and eligibility constraints Returns: ProviderResult with the response text or error """ + policy = execution_policy or ProviderExecutionPolicy() + requested_provider = _requested_provider or provider_name + attempted = _attempted_providers if _attempted_providers is not None else [] + if provider_name not in attempted: + attempted.append(provider_name) + observed_identity = ("mock", None, "built-in") + # Track which providers we've tried to prevent infinite fallback loops if _tried is None: _tried = set() _tried.add(provider_name) + def finish( + result: ProviderResult, + provider: Optional[Provider] = None, + ) -> ProviderResult: + """Attach facts observed by CmdForge, never values generated by the model.""" + result.requested_provider = requested_provider + result.attempted_providers = list(attempted) + if result.success: + result.actual_provider = provider_name + ( + result.model, + result.model_digest, + result.model_identity_source, + ) = observed_identity + result.locality = ( + effective_provider_locality(provider) if provider else "local" + ) + result.fallback_used = provider_name != requested_provider + return result + # Handle mock provider specially if provider_name.lower() == "mock": - return mock_provider(prompt) + return finish(mock_provider(prompt)) # Look up provider provider = get_provider(provider_name) if not provider: - return ProviderResult( + return finish(ProviderResult( text="", success=False, error=f"Provider '{provider_name}' not found. Use 'cmdforge providers' to manage providers." - ) + )) + observed_identity = runtime_provider_identity(provider) if max_tokens is not None: try: max_tokens = int(max_tokens) except (TypeError, ValueError): - return ProviderResult(text="", success=False, error="max_tokens must be an integer") + return finish(ProviderResult(text="", success=False, error="max_tokens must be an integer"), provider) if max_tokens <= 0 or max_tokens > 1_000_000: - return ProviderResult( + return finish(ProviderResult( text="", success=False, error="max_tokens must be between 1 and 1000000" - ) + ), provider) # Helper to try fallback provider(s) if available def try_fallback(error_msg: str) -> ProviderResult: import sys last_error = error_msg + if not policy.allow_fallback: + return finish( + ProviderResult(text="", success=False, error=last_error), provider + ) # Walk fallback_chain first (ordered, multi-step) if provider.fallback_chain: for fb in provider.fallback_chain: if fb not in _tried: print(f"[fallback] {provider_name} failed, trying {fb}...", file=sys.stderr) - result = call_provider(fb, prompt, timeout, max_tokens, _tried) + result = call_provider( + fb, prompt, timeout, max_tokens, _tried, + execution_policy=policy, + _requested_provider=requested_provider, + _attempted_providers=attempted, + ) if result.success: return result last_error = result.error or last_error # Fall back to single fallback (backward-compat) if provider.fallback and provider.fallback not in _tried: print(f"[fallback] {provider_name} failed, trying {provider.fallback}...", file=sys.stderr) - result = call_provider(provider.fallback, prompt, timeout, max_tokens, _tried) + result = call_provider( + provider.fallback, prompt, timeout, max_tokens, _tried, + execution_policy=policy, + _requested_provider=requested_provider, + _attempted_providers=attempted, + ) if result.success: return result last_error = result.error or last_error - return ProviderResult(text="", success=False, error=last_error) + return finish( + ProviderResult(text="", success=False, error=last_error), provider + ) + + eligibility_error = provider_policy_error( + provider, policy, observed_identity + ) + if eligibility_error: + return try_fallback(eligibility_error) # Dispatch by provider type ptype = getattr(provider, "type", None) or "subprocess" @@ -595,7 +923,7 @@ def call_provider(provider_name: str, prompt: str, timeout: int = 300, max_token return try_fallback(f"Unknown provider type: {ptype}") if result.success: - return result + return finish(result, provider) return try_fallback(result.error or "Provider call failed") except Exception as e: return try_fallback(f"Provider error: {str(e)}") diff --git a/src/cmdforge/resolver.py b/src/cmdforge/resolver.py index 901e36b..c4713b9 100644 --- a/src/cmdforge/resolver.py +++ b/src/cmdforge/resolver.py @@ -16,7 +16,7 @@ from typing import Optional, Tuple import yaml -from .tool import Tool, TOOLS_DIR, get_bin_dir, BIN_DIR +from .tool import Tool, TOOLS_DIR, get_tools_dir, get_bin_dir, BIN_DIR from .config import is_auto_fetch_enabled, load_config from .manifest import load_manifest @@ -243,7 +243,7 @@ class ToolResolver: searched_paths: list ) -> Optional[ResolvedTool]: """Search for tool in global user directory.""" - global_dir = TOOLS_DIR + global_dir = get_tools_dir() if not global_dir.exists(): return None diff --git a/src/cmdforge/runner.py b/src/cmdforge/runner.py index b792d22..1184bee 100644 --- a/src/cmdforge/runner.py +++ b/src/cmdforge/runner.py @@ -12,7 +12,9 @@ from typing import Optional, List import yaml from .tool import Tool, PromptStep, CodeStep, ToolStep, McpStep -from .providers import call_provider, mock_provider +from .providers import ( + call_provider, mock_provider, ProviderExecutionPolicy, +) from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry from .manifest import load_manifest from .profiles import load_profile @@ -508,7 +510,9 @@ def execute_prompt_step( variables: dict, provider_override: str = None, verbose: bool = False, - base_dir: Optional[Path] = None + base_dir: Optional[Path] = None, + execution_policy: Optional[ProviderExecutionPolicy] = None, + provenance_log: Optional[list[dict]] = None, ) -> tuple[str, bool]: """ Execute a prompt step. @@ -525,6 +529,24 @@ def execute_prompt_step( Otherwise, enforces structured JSON output with schema validation. """ import re + from dataclasses import replace + + effective_policy = execution_policy or ProviderExecutionPolicy() + if step.fallback_policy == "deny" and effective_policy.allow_fallback: + effective_policy = replace(effective_policy, allow_fallback=False) + + def record_result(result, selected_provider: str) -> None: + # Mock calls bypass call_provider for deterministic tests, so attach + # the same runtime-owned identity here. + if selected_provider.lower() == "mock" and not result.actual_provider: + result.requested_provider = "mock" + result.actual_provider = "mock" + result.attempted_providers = ["mock"] + result.model = "mock" + result.locality = "local" + result.model_identity_source = "built-in" + if provenance_log is not None: + provenance_log.append(result.provenance()) # Build prompt with variable substitution try: @@ -557,7 +579,12 @@ def execute_prompt_step( if provider.lower() == "mock": result = mock_provider(prompt) else: - result = call_provider(provider, prompt, max_tokens=step.max_tokens) + result = call_provider( + provider, prompt, max_tokens=step.max_tokens, + execution_policy=effective_policy, + ) + + record_result(result, provider) if not result.success: print(f"Error in prompt step: {result.error}", file=sys.stderr) @@ -601,7 +628,12 @@ Please try again with valid JSON matching the schema exactly.""" if provider.lower() == "mock": result = mock_provider(current_prompt) else: - result = call_provider(provider, current_prompt, max_tokens=step.max_tokens) + result = call_provider( + provider, current_prompt, max_tokens=step.max_tokens, + execution_policy=effective_policy, + ) + + record_result(result, provider) if not result.success: print(f"Error in prompt step: {result.error}", file=sys.stderr) @@ -743,6 +775,8 @@ def execute_tool_step( agent_profile: Optional[str] = None, agent_skills: Optional[List[str]] = None, agent_tool_policies: Optional[List[List[str]]] = None, + execution_policy: Optional[ProviderExecutionPolicy] = None, + provenance_log: Optional[list[dict]] = None, ) -> tuple[str, bool]: """ Execute a tool step by calling another tool. @@ -815,6 +849,8 @@ def execute_tool_step( agent_tool_policies=_extend_agent_tool_policies( agent_tool_policies, step.tools ), + execution_policy=execution_policy, + provenance_log=provenance_log, ) return output, exit_code == 0 @@ -834,6 +870,8 @@ def run_tool( agent_profile: Optional[str] = None, agent_skills: Optional[List[str]] = None, agent_tool_policies: Optional[List[List[str]]] = None, + execution_policy: Optional[ProviderExecutionPolicy] = None, + provenance_log: Optional[list[dict]] = None, ) -> tuple[str, int]: """ Execute a tool. @@ -969,7 +1007,9 @@ def run_tool( variables, provider_override, verbose=verbose, - base_dir=tool_base_dir + base_dir=tool_base_dir, + execution_policy=execution_policy, + provenance_log=provenance_log, ) if not success: return "", 2 @@ -1018,6 +1058,8 @@ def run_tool( agent_profile=agent_profile, agent_skills=agent_skills, agent_tool_policies=agent_tool_policies, + execution_policy=execution_policy, + provenance_log=provenance_log, ) if not success: return "", 3 @@ -1133,14 +1175,65 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser: help="Show debug information") parser.add_argument("--auto-install", action="store_true", help="Automatically install missing tool dependencies") + parser.add_argument("--no-fallback", action="store_true", + help="Fail instead of trying configured fallbacks") + parser.add_argument("--require-local", action="store_true", + help="Allow only providers declared local") + parser.add_argument("--require-capability", action="append", default=[], + help="Required provider capability (repeatable)") + parser.add_argument("--data-classification", + choices=["public", "internal", "private"]) + parser.add_argument("--require-model-identity", action="store_true") + parser.add_argument("--require-model-digest", action="store_true") + parser.add_argument("--result-envelope", choices=["json"]) # Tool-specific flags from arguments for arg in tool.arguments: + existing_action = parser._option_string_actions.get(arg.flag) + if existing_action is not None: + # Preserve the legacy and useful case where a boolean tool option + # intentionally shares a universal flag (for example --verbose). + # Both consumers then read the same parsed boolean. + if arg.type == "boolean" and existing_action.dest == arg.variable: + continue + raise ValueError( + f"Tool argument {arg.flag!r} conflicts with CmdForge's " + "universal run options" + ) + + def parse_boolean(value): + if isinstance(value, bool): + return value + normalized = str(value).strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise argparse.ArgumentTypeError( + "expected true/false, yes/no, on/off, or 1/0" + ) + + value_type = { + "string": str, + "integer": int, + "number": float, + "boolean": parse_boolean, + }[arg.type] + options = { + "dest": arg.variable, + "default": arg.default, + "help": arg.description or f"{arg.variable} (default: {arg.default})", + "required": arg.required, + "type": value_type, + } + if arg.enum is not None: + options["choices"] = [value_type(value) for value in arg.enum] + if arg.type == "boolean": + options["nargs"] = "?" + options["const"] = True parser.add_argument( arg.flag, - dest=arg.variable, - default=arg.default, - help=arg.description or f"{arg.variable} (default: {arg.default})" + **options, ) return parser @@ -1215,6 +1308,16 @@ def main(): # Determine provider override (CLI flag takes precedence over manifest) effective_provider = args.provider or provider_override_from_manifest + execution_policy = ProviderExecutionPolicy( + allow_fallback=not args.no_fallback, + required_locality="local" if args.require_local else None, + required_capabilities=tuple(args.require_capability or []), + data_classification=args.data_classification or "public", + require_model_identity=args.require_model_identity, + require_model_digest=args.require_model_digest, + ) + provider_calls = [] + # Run tool output, exit_code = run_tool( tool=tool, @@ -1224,15 +1327,36 @@ def main(): dry_run=args.dry_run, show_prompt=args.show_prompt, verbose=args.verbose, - auto_install=args.auto_install + auto_install=args.auto_install, + execution_policy=execution_policy, + provenance_log=provider_calls, ) # Write output - if exit_code == 0 and output: - if args.output_file: - Path(args.output_file).write_text(output) + if args.result_envelope == "json" or (exit_code == 0 and output): + if args.result_envelope == "json": + import json + rendered_output = json.dumps({ + "output": output, + "execution": { + "exit_code": exit_code, + "provider_calls": provider_calls, + "fallback_used": any( + item.get("fallback_used", False) + for item in provider_calls + ), + "actual_providers": list(dict.fromkeys( + item["actual_provider"] for item in provider_calls + if item.get("actual_provider") + )), + }, + }, indent=2) else: - print(output) + rendered_output = output + if args.output_file: + Path(args.output_file).write_text(rendered_output) + else: + print(rendered_output) if exit_code == 0 and not args.dry_run: try: diff --git a/src/cmdforge/tool.py b/src/cmdforge/tool.py index 97148af..b46588a 100644 --- a/src/cmdforge/tool.py +++ b/src/cmdforge/tool.py @@ -124,6 +124,15 @@ class ToolArgument: required: bool = False def __post_init__(self) -> None: + # Older CmdForge tools used Python-style scalar names. Normalize them + # on load so typed parsing does not break existing user tools. + legacy_type_aliases = { + "str": "string", + "int": "integer", + "float": "number", + "bool": "boolean", + } + self.type = legacy_type_aliases.get(self.type, self.type) if self.type not in ("string", "integer", "number", "boolean"): raise ValueError( "ToolArgument type must be string, integer, number, or boolean" @@ -178,9 +187,12 @@ class PromptStep: plain_text: bool = False # Bypass structured output enforcement max_tokens: Optional[int] = None # Max output tokens (provider-dependent) skills: Optional[List[str]] = None # Skill names to enable for this step + fallback_policy: str = "allow" # "allow" | "deny" def __post_init__(self) -> None: _validate_skill_selection(self.skills) + if self.fallback_policy not in {"allow", "deny"}: + raise ValueError("PromptStep fallback_policy must be allow or deny") def to_dict(self) -> dict: d = { @@ -207,6 +219,8 @@ class PromptStep: d["max_tokens"] = self.max_tokens if self.skills is not None: d["skills"] = self.skills + if self.fallback_policy != "allow": + d["fallback_policy"] = self.fallback_policy return d @classmethod @@ -233,6 +247,7 @@ class PromptStep: plain_text=data.get("plain_text", False), max_tokens=max_tokens, skills=data.get("skills"), + fallback_policy=data.get("fallback_policy", "allow"), ) @@ -598,6 +613,11 @@ def get_tools_dir() -> Path: return TOOLS_DIR +def get_project_tools_dir(project_dir: Optional[Path] = None) -> Path: + """Return the canonical project-local tool root without creating it.""" + return (project_dir or Path.cwd()).resolve() / ".cmdforge" + + def ensure_settings(tool_dir: Path) -> Optional[Path]: """Ensure settings.yaml exists if defaults.yaml exists. @@ -747,12 +767,32 @@ def load_tool(name: str) -> Optional[Tool]: _REGISTRY_FIELDS = ("registry_hash", "registry_status", "registry_owner", "registry_feedback") -def save_tool(tool: Tool) -> Path: - """Save a tool to disk, preserving registry metadata from existing config.""" - tool_dir = get_tools_dir() / tool.name +def save_tool( + tool: Tool, + *, + tools_dir: Optional[Path] = None, + create_wrapper: Optional[bool] = None, +) -> Path: + """Save a tool globally or under an explicit project-local tool root. + + Global saves keep the historical wrapper behavior. Project-local saves are + invoked through ``cmdforge run`` so a global wrapper cannot accidentally + resolve a different project tool with the same name. + """ + valid, error = validate_tool_name(tool.name) + if not valid: + raise ValueError(error) + global_tools_dir = get_tools_dir() + target_root = Path(tools_dir).resolve() if tools_dir is not None else global_tools_dir + target_root.mkdir(parents=True, exist_ok=True) + tool_dir = target_root / tool.name + if tool_dir.is_symlink(): + raise ValueError(f"Refusing to save through symlinked tool directory: {tool_dir}") tool_dir.mkdir(parents=True, exist_ok=True) config_path = tool_dir / "config.yaml" + if config_path.is_symlink(): + raise ValueError(f"Refusing to overwrite symlinked tool config: {config_path}") # Preserve registry fields from existing config (not part of Tool model) preserved = {} @@ -769,8 +809,12 @@ def save_tool(tool: Tool) -> Path: new_data.update(preserved) config_path.write_text(yaml.dump(new_data, default_flow_style=False, sort_keys=False)) - # Create wrapper script - create_wrapper_script(tool.name) + # Global tools retain convenient shell wrappers. Project tools are + # intentionally cwd-scoped and must be resolved by ``cmdforge run``. + if create_wrapper is None: + create_wrapper = target_root == global_tools_dir.resolve() + if create_wrapper: + create_wrapper_script(tool.name) # Ensure settings.yaml exists if defaults.yaml exists ensure_settings(tool_dir) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9468af4..a95d2de 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 ( diff --git a/tests/test_providers.py b/tests/test_providers.py index 071cfd7..84d3215 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -8,7 +8,7 @@ import pytest import yaml from cmdforge.providers import ( - Provider, ProviderResult, + Provider, ProviderResult, ProviderExecutionPolicy, load_providers, save_providers, get_provider, add_provider, delete_provider, call_provider, discover_installed_providers, mock_provider, @@ -60,6 +60,19 @@ class TestProvider: provider = Provider.from_dict(data) assert provider.description == "" + def test_legacy_policy_field_aliases_are_canonicalized(self): + provider = Provider.from_dict({ + "name": "legacy-policy", + "command": "provider", + "model_id": "model-a", + "max_input_tokens": 8192, + }) + + assert provider.model == "model-a" + assert provider.max_context_tokens == 8192 + assert provider.to_dict()["model"] == "model-a" + assert provider.to_dict()["max_context_tokens"] == 8192 + def test_roundtrip(self): original = Provider( name="custom", @@ -90,6 +103,13 @@ class TestProvider: fallback_chain=["backup", "mock"], api_key_env="TEST_API_KEY", pty_config={"prompt_pattern": ">"}, + locality="remote", + capabilities=["text", "structured-json"], + model_digest="sha256:abc", + cost_class="low", + latency_class="fast", + max_context_tokens=131072, + data_policy="internal", ) assert Provider.from_dict(original.to_dict()) == original @@ -516,6 +536,200 @@ class TestProviderFallback: # Fallback to mock should succeed assert result.success is True assert "[MOCK]" in result.text + assert result.requested_provider == "primary" + assert result.actual_provider == "mock" + assert result.attempted_providers == ["primary", "mock"] + assert result.fallback_used is True + + def test_no_fallback_fails_closed(self, temp_providers_file): + save_providers([ + Provider("primary", "nonexistent-cmd", fallback="mock"), + Provider("mock", "mock"), + ]) + + result = call_provider( + "primary", "Test prompt", + execution_policy=ProviderExecutionPolicy(allow_fallback=False), + ) + + assert result.success is False + assert result.requested_provider == "primary" + assert result.actual_provider is None + assert result.attempted_providers == ["primary"] + assert result.fallback_used is False + + def test_private_data_requires_explicit_compatible_policy( + self, temp_providers_file + ): + save_providers([ + Provider( + "local", "unused", locality="local", + capabilities=["structured-json"], + ), + ]) + + result = call_provider( + "local", "private packet", + execution_policy=ProviderExecutionPolicy( + allow_fallback=False, + required_locality="local", + required_capabilities=("structured-json",), + data_classification="private", + ), + ) + + assert result.success is False + assert "data_policy is unspecified" in result.error + + def test_require_local_rejects_ollama_redirected_to_remote_host( + self, temp_providers_file, monkeypatch + ): + save_providers([ + Provider( + "ollama-private", "ollama run safe", locality="local", + data_policy="private", + ), + ]) + monkeypatch.setenv("OLLAMA_HOST", "gpu.example.test:11434") + + result = call_provider( + "ollama-private", "private packet", + execution_policy=ProviderExecutionPolicy( + allow_fallback=False, + required_locality="local", + data_classification="private", + ), + ) + + assert result.success is False + assert "locality is remote" in result.error + + def test_ollama_digest_is_observed_from_local_api( + self, temp_providers_file, monkeypatch + ): + save_providers([ + Provider( + "ollama-safe", "ollama run safe:latest", model="safe:latest", + locality="local", data_policy="private", + ), + ]) + monkeypatch.delenv("OLLAMA_HOST", raising=False) + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return ( + b'{"models":[{"name":"safe:latest","model":"safe:latest",' + b'"digest":"abcdef"}]}' + ) + + with ( + patch("urllib.request.urlopen", return_value=Response()), + patch( + "cmdforge.providers.call_provider_subprocess", + return_value=ProviderResult(text="safe", success=True), + ), + ): + result = call_provider( + "ollama-safe", "private", + execution_policy=ProviderExecutionPolicy( + allow_fallback=False, + required_locality="local", + data_classification="private", + require_model_identity=True, + require_model_digest=True, + ), + ) + + assert result.model == "safe:latest" + assert result.model_digest == "sha256:abcdef" + assert result.model_identity_source == "ollama-api" + + def test_policy_eligible_provider_reports_verified_metadata( + self, temp_providers_file + ): + provider = Provider( + "local", "local-ai", model="model-a", + model_digest="sha256:123", locality="local", + capabilities=["structured-json"], data_policy="private", + ) + save_providers([provider]) + low_level = ProviderResult(text='{"ok": true}', success=True) + + with patch( + "cmdforge.providers.call_provider_subprocess", + return_value=low_level, + ): + result = call_provider( + "local", "private packet", + execution_policy=ProviderExecutionPolicy( + allow_fallback=False, + required_locality="local", + required_capabilities=("structured-json",), + data_classification="private", + require_model_identity=True, + require_model_digest=True, + ), + ) + + assert result.success is True + assert result.provenance() == { + "success": True, + "requested_provider": "local", + "actual_provider": "local", + "attempted_providers": ["local"], + "fallback_used": False, + "model": "model-a", + "model_digest": "sha256:123", + "locality": "local", + "model_identity_source": "provider-config", + } + + def test_fallback_chain_skips_policy_ineligible_provider( + self, temp_providers_file + ): + save_providers([ + Provider( + "primary", "primary", fallback_chain=["remote", "local"], + locality="local", data_policy="private", + ), + Provider( + "remote", "remote", locality="remote", data_policy="private", + ), + Provider( + "local", "local", locality="local", data_policy="private", + model="safe-model", + ), + ]) + + def invoke(provider, prompt, timeout, max_tokens): + if provider.name == "primary": + return ProviderResult(text="", success=False, error="offline") + assert provider.name == "local" + return ProviderResult(text="safe", success=True) + + with patch( + "cmdforge.providers.call_provider_subprocess", side_effect=invoke + ) as low_level: + result = call_provider( + "primary", "private packet", + execution_policy=ProviderExecutionPolicy( + required_locality="local", + data_classification="private", + ), + ) + + assert result.success is True + assert result.actual_provider == "local" + assert result.attempted_providers == ["primary", "remote", "local"] + assert [call.args[0].name for call in low_level.call_args_list] == [ + "primary", "local" + ] def test_fallback_chain(self, temp_providers_file): """Fallback can chain to another provider with fallback.""" diff --git a/tests/test_runner.py b/tests/test_runner.py index 0bfdad8..55b1887 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -229,6 +229,23 @@ class TestExecutePromptStep: assert success is False assert output == "" + @patch('cmdforge.runner.call_provider') + def test_step_can_deny_fallback_independently_of_caller(self, mock_call): + mock_call.return_value = ProviderResult( + text="private", success=True, + ) + step = PromptStep( + prompt="Private", provider="local", output_var="out", + plain_text=True, fallback_policy="deny", + ) + + output, success = execute_prompt_step(step, {"input": ""}) + + assert success is True + assert output == "private" + policy = mock_call.call_args.kwargs["execution_policy"] + assert policy.allow_fallback is False + @patch('cmdforge.runner.mock_provider') def test_mock_provider_used(self, mock_mock): mock_mock.return_value = ProviderResult(text="mock response", success=True) @@ -988,6 +1005,58 @@ class TestCreateArgumentParser: assert args.count == "10" + def test_parser_enforces_typed_boolean_integer_and_enum_arguments(self): + tool = Tool( + name="typed", + arguments=[ + ToolArgument( + flag="--project", variable="project", + type="boolean", default=False, + ), + ToolArgument( + flag="--count", variable="count", + type="integer", required=True, + ), + ToolArgument( + flag="--mode", variable="mode", + enum=["safe", "fast"], default="safe", + ), + ], + ) + parser = create_argument_parser(tool) + + args = parser.parse_args(["--project", "--count", "3", "--mode", "fast"]) + + assert args.project is True + assert args.count == 3 + assert args.mode == "fast" + + def test_parser_reuses_matching_legacy_boolean_universal_flag(self): + tool = Tool( + name="legacy", + arguments=[ + ToolArgument( + flag="--verbose", variable="verbose", type="bool" + ) + ], + ) + + args = create_argument_parser(tool).parse_args(["--verbose"]) + + assert args.verbose is True + assert collect_custom_args(tool, args) == {"verbose": True} + + def test_parser_rejects_incompatible_universal_flag_collision(self): + tool = Tool( + name="conflict", + arguments=[ + ToolArgument(flag="--provider", variable="destination") + ], + ) + + with pytest.raises(ValueError, match="universal run options"): + create_argument_parser(tool) + def test_parser_input_output_flags(self): tool = Tool(name="test") parser = create_argument_parser(tool) diff --git a/tests/test_skills.py b/tests/test_skills.py index 3d975ef..6bc1408 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -378,7 +378,7 @@ class TestRunnerSkillIntegration: ): captured = {} - def fake_call(provider, prompt, max_tokens=None): + def fake_call(provider, prompt, max_tokens=None, **kwargs): captured.update(provider=provider, prompt=prompt) return ProviderResult(text="done", success=True) @@ -412,7 +412,7 @@ class TestRunnerSkillIntegration: ): captured = {} - def fake_call(provider, prompt, max_tokens=None): + def fake_call(provider, prompt, max_tokens=None, **kwargs): captured.update(provider=provider, prompt=prompt) return ProviderResult(text='{"output": "done"}', success=True) diff --git a/tests/test_tool.py b/tests/test_tool.py index b2282b7..c625b3e 100644 --- a/tests/test_tool.py +++ b/tests/test_tool.py @@ -105,6 +105,25 @@ class TestToolArgument: with pytest.raises(ValueError): ToolArgument(flag="--value", variable="value", **values) + @pytest.mark.parametrize( + ("legacy_type", "canonical_type"), + [ + ("str", "string"), + ("int", "integer"), + ("float", "number"), + ("bool", "boolean"), + ], + ) + def test_legacy_scalar_type_aliases_are_normalized( + self, legacy_type, canonical_type + ): + argument = ToolArgument( + flag="--value", variable="value", type=legacy_type + ) + + assert argument.type == canonical_type + assert argument.to_dict().get("type", "string") == canonical_type + class TestPromptStep: """Tests for PromptStep dataclass.""" @@ -686,6 +705,23 @@ class TestAgentContext: restored = PromptStep.from_dict(step.to_dict()) assert restored.skills == ["python"] + def test_promptstep_deny_fallback_roundtrip(self): + step = PromptStep( + prompt="Private", provider="local", output_var="out", + fallback_policy="deny", + ) + + restored = PromptStep.from_dict(step.to_dict()) + + assert restored.fallback_policy == "deny" + + def test_promptstep_rejects_invalid_fallback_policy(self): + with pytest.raises(ValueError, match="fallback_policy"): + PromptStep( + prompt="Test", provider="local", output_var="out", + fallback_policy="sometimes", + ) + class TestDeprecationFields: def test_round_trip_and_warning(self):