From b44e60f4f7e8dca119bd51b721eeaef454ca562b Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 00:24:26 +0300 Subject: [PATCH 01/14] Refactor tests for manifest generation and schema translation - Updated assertions in `test_generate_manifest_agents.py` to reflect the expected existence of TOML files for OpenAI agents. - Enhanced test descriptions to clarify the behavior of OpenAI manifest agents regarding TOML file generation and content. - Modified `test_schema_translation.py` to correct the expected values for the `skip` and `skip_reason` fields in the translated schema. - Added tests in `test_spec_coverage.py` to handle cases where mixed known and unknown systems are specified, ensuring proper failure responses. - Introduced a new test in `test_workspace.py` to validate rejection of unreachable local paths when adding workspaces. Signed-off-by: ainetx --- skills/studio/scripts/studio/cli.py | 29 +- .../studio/scripts/studio/commands/agents.py | 308 +- skills/studio/scripts/studio/commands/init.py | 48 +- skills/studio/scripts/studio/commands/kit.py | 157 +- .../scripts/studio/commands/spec_coverage.py | 29 + .../scripts/studio/commands/workspace_add.py | 21 + skills/studio/scripts/studio/utils/context.py | 97 +- .../studio/scripts/studio/utils/kit_model.py | 104 +- tests/e2e-analysis-report.md | 71 + .../fixtures/kits/example-legacy/agents.toml | 9 + .../kits/example-legacy/agents/planner.md | 3 + .../kits/example-legacy/agents/reviewer.md | 3 + .../artifacts/FEATURE/example.md | 5 + .../artifacts/FEATURE/template.md | 6 + tests/fixtures/kits/example-legacy/conf.toml | 2 + .../kits/example-legacy/constraints.toml | 5 + tests/fixtures/kits/example-legacy/core.toml | 5 + .../kits/example-legacy/manifest.toml | 23 + .../kits/example-legacy/skills/guide.md | 8 + .../kits/example-legacy/workflows/release.md | 8 + .../kits/example-mixed/.cf-studio-kit.toml | 85 + .../example-mixed/agents/planner-helper.md | 3 + .../kits/example-mixed/agents/planner.md | 3 + .../example-mixed/agents/reviewer-helper.md | 3 + .../kits/example-mixed/agents/reviewer.md | 3 + .../example-mixed/artifacts/ADR/example.md | 5 + .../example-mixed/artifacts/ADR/template.md | 6 + tests/fixtures/kits/example-mixed/conf.toml | 2 + .../kits/example-mixed/constraints.toml | 5 + tests/fixtures/kits/example-mixed/core.toml | 5 + .../fixtures/kits/example-mixed/manifest.toml | 12 + .../example-mixed/skills/discovery/SKILL.md | 8 + .../kits/example-mixed/skills/review/SKILL.md | 8 + .../kits/example-v2/.cf-studio-kit.toml | 99 + .../kits/example-v2/agents/planner-helper.md | 3 + .../kits/example-v2/agents/planner.md | 3 + .../kits/example-v2/agents/reviewer-helper.md | 3 + .../kits/example-v2/agents/reviewer.md | 3 + .../example-v2/artifacts/FEATURE/example.md | 5 + .../example-v2/artifacts/FEATURE/template.md | 6 + .../kits/example-v2/artifacts/PRD/example.md | 5 + .../kits/example-v2/artifacts/PRD/template.md | 6 + .../fixtures/kits/example-v2/constraints.toml | 11 + .../kits/example-v2/skills/discovery/SKILL.md | 8 + .../kits/example-v2/skills/review/SKILL.md | 8 + tests/test_agents_coverage.py | 152 +- tests/test_cli_agents_e2e.py | 215 ++ tests/test_cli_artifact_tools_e2e.py | 379 +++ tests/test_cli_example_kits_e2e.py | 2851 +++++++++++++++++ tests/test_cli_gitignore_e2e.py | 518 +++ tests/test_cli_kit_utility_e2e.py | 792 +++++ tests/test_cli_map_public_e2e.py | 257 ++ tests/test_cli_navigation_e2e.py | 527 +++ tests/test_cli_router_e2e.py | 75 + tests/test_cli_setup_e2e.py | 582 ++++ tests/test_cli_update_e2e.py | 147 + tests/test_cli_validate_e2e.py | 557 ++++ tests/test_cli_validation_e2e.py | 772 +++++ tests/test_cli_workspace_diag_e2e.py | 1085 +++++++ tests/test_generate_manifest_agents.py | 49 +- tests/test_kit.py | 13 + tests/test_schema_translation.py | 9 +- tests/test_spec_coverage.py | 24 +- tests/test_workspace.py | 12 + 64 files changed, 10142 insertions(+), 123 deletions(-) create mode 100644 tests/e2e-analysis-report.md create mode 100644 tests/fixtures/kits/example-legacy/agents.toml create mode 100644 tests/fixtures/kits/example-legacy/agents/planner.md create mode 100644 tests/fixtures/kits/example-legacy/agents/reviewer.md create mode 100644 tests/fixtures/kits/example-legacy/artifacts/FEATURE/example.md create mode 100644 tests/fixtures/kits/example-legacy/artifacts/FEATURE/template.md create mode 100644 tests/fixtures/kits/example-legacy/conf.toml create mode 100644 tests/fixtures/kits/example-legacy/constraints.toml create mode 100644 tests/fixtures/kits/example-legacy/core.toml create mode 100644 tests/fixtures/kits/example-legacy/manifest.toml create mode 100644 tests/fixtures/kits/example-legacy/skills/guide.md create mode 100644 tests/fixtures/kits/example-legacy/workflows/release.md create mode 100644 tests/fixtures/kits/example-mixed/.cf-studio-kit.toml create mode 100644 tests/fixtures/kits/example-mixed/agents/planner-helper.md create mode 100644 tests/fixtures/kits/example-mixed/agents/planner.md create mode 100644 tests/fixtures/kits/example-mixed/agents/reviewer-helper.md create mode 100644 tests/fixtures/kits/example-mixed/agents/reviewer.md create mode 100644 tests/fixtures/kits/example-mixed/artifacts/ADR/example.md create mode 100644 tests/fixtures/kits/example-mixed/artifacts/ADR/template.md create mode 100644 tests/fixtures/kits/example-mixed/conf.toml create mode 100644 tests/fixtures/kits/example-mixed/constraints.toml create mode 100644 tests/fixtures/kits/example-mixed/core.toml create mode 100644 tests/fixtures/kits/example-mixed/manifest.toml create mode 100644 tests/fixtures/kits/example-mixed/skills/discovery/SKILL.md create mode 100644 tests/fixtures/kits/example-mixed/skills/review/SKILL.md create mode 100644 tests/fixtures/kits/example-v2/.cf-studio-kit.toml create mode 100644 tests/fixtures/kits/example-v2/agents/planner-helper.md create mode 100644 tests/fixtures/kits/example-v2/agents/planner.md create mode 100644 tests/fixtures/kits/example-v2/agents/reviewer-helper.md create mode 100644 tests/fixtures/kits/example-v2/agents/reviewer.md create mode 100644 tests/fixtures/kits/example-v2/artifacts/FEATURE/example.md create mode 100644 tests/fixtures/kits/example-v2/artifacts/FEATURE/template.md create mode 100644 tests/fixtures/kits/example-v2/artifacts/PRD/example.md create mode 100644 tests/fixtures/kits/example-v2/artifacts/PRD/template.md create mode 100644 tests/fixtures/kits/example-v2/constraints.toml create mode 100644 tests/fixtures/kits/example-v2/skills/discovery/SKILL.md create mode 100644 tests/fixtures/kits/example-v2/skills/review/SKILL.md create mode 100644 tests/test_cli_agents_e2e.py create mode 100644 tests/test_cli_artifact_tools_e2e.py create mode 100644 tests/test_cli_example_kits_e2e.py create mode 100644 tests/test_cli_gitignore_e2e.py create mode 100644 tests/test_cli_kit_utility_e2e.py create mode 100644 tests/test_cli_map_public_e2e.py create mode 100644 tests/test_cli_navigation_e2e.py create mode 100644 tests/test_cli_router_e2e.py create mode 100644 tests/test_cli_setup_e2e.py create mode 100644 tests/test_cli_update_e2e.py create mode 100644 tests/test_cli_validate_e2e.py create mode 100644 tests/test_cli_validation_e2e.py create mode 100644 tests/test_cli_workspace_diag_e2e.py diff --git a/skills/studio/scripts/studio/cli.py b/skills/studio/scripts/studio/cli.py index 0ea442f8..68948e7a 100644 --- a/skills/studio/scripts/studio/cli.py +++ b/skills/studio/scripts/studio/cli.py @@ -190,12 +190,12 @@ def main(argv: Optional[List[str]] = None) -> int: def _main_impl(argv_list: List[str]) -> int: """Dispatch a command after global flags have been handled.""" - # Load base Studio context on startup (templates, systems, etc.) - # Workspace upgrade is deferred — get_context() will lazily attempt it - # on first access, so commands like --help and init avoid network I/O. - from .utils.context import StudioContext, set_context - ctx = StudioContext.load() - set_context(ctx) + # Load best-effort context on startup. This may resolve to a direct + # StudioContext, a WorkspaceContext discovered from a workspace root, or + # remain None when the current directory is not initialized. + from .utils.context import ensure_context, set_context + set_context(None) + ctx = ensure_context(Path.cwd()) # Context may be None if Constructor Studio not initialized - that's OK for some commands like init # Define all available commands @@ -303,23 +303,6 @@ def _main_impl(argv_list: List[str]) -> int: rest = argv_list[1:] # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-parse-command - # @cpt-dod:cpt-studio-dod-core-infra-agents-integrity:p1 - # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-verify-agents - # Verify root AGENTS.md and CLAUDE.md integrity on every invocation (silent re-inject if stale) - if ctx is not None and cmd not in ("init", "update"): - try: - from .commands.init import _inject_root_agents, _inject_root_claude - from .utils.files import find_project_root, _read_studio_var - project_root = find_project_root(Path.cwd()) - if project_root is not None: - install_rel = _read_studio_var(project_root) - if install_rel: - _inject_root_agents(project_root, install_rel) - _inject_root_claude(project_root, install_rel) - except (OSError, ValueError, KeyError): - pass # Non-fatal: don't block command execution - # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-verify-agents - # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-lookup-handler # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-parse-args # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-execute-handler diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 4aaa8e5a..6394b9e1 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -29,6 +29,7 @@ # order. Imports inside function bodies (deferred / lazy imports) remain in # place — see search for " import " / " from " inside this file. import argparse +from dataclasses import dataclass import functools import json import re @@ -415,6 +416,47 @@ def _is_pure_studio_generated_toml(content: str, expected_content: Optional[str] # @cpt-end:cpt-studio-algo-agent-integration-generate-shims:p1:inst-is-pure-studio-generated-toml +def _is_studio_managed_toml_output(content: str) -> bool: + """Return True when *content* matches the generated Codex TOML output shape. + + Unlike ``_is_pure_studio_generated_toml`` this is used only for ownership + enumeration (for managed ``.gitignore`` entries), not stale cleanup. It is + intentionally looser: the file must carry the generated marker and parse as + the known generated TOML top-level shape, but it does not need byte-for-byte + reconstruction. + """ + if _GENERATED_MARKER_TOML not in content: + return False + try: + data = tomllib.loads(content) + except tomllib.TOMLDecodeError: + return False + if not isinstance(data, dict) or not data: + return False + required_keys = {"name", "description", "developer_instructions"} + allowed_keys = { + "name", + "description", + "sandbox_mode", + "developer_instructions", + "model", + "model_reasoning_effort", + "model_context_window", + } + keys = set(data.keys()) + if not required_keys.issubset(keys): + return False + if not keys.issubset(allowed_keys): + return False + if not isinstance(data.get("name"), str) or not str(data.get("name")).strip(): + return False + if not isinstance(data.get("description"), str): + return False + if not isinstance(data.get("developer_instructions"), str): + return False + return True + + # @cpt-begin:cpt-studio-algo-agent-integration-generate-shims:p1:inst-expected-stale-studio-generated-toml def _expected_stale_studio_generated_toml( toml_file: Path, @@ -2052,12 +2094,10 @@ def _resolve_registered_legacy_studio_path( ) -> Optional[Path]: normalized = PurePosixPath(raw_path.strip().replace("\\", "/")).as_posix() path_obj = PurePosixPath(normalized) - has_parent_traversal = ".." in path_obj.parts if ( not normalized or path_obj.is_absolute() or PureWindowsPath(normalized).is_absolute() - or (has_parent_traversal and normalized != "..") ): return None resolved = (studio_root / Path(normalized)).resolve() @@ -3796,16 +3836,142 @@ def _collect_managed_result_paths( return {path for path in paths if path} -def list_managed_agent_output_paths( +@dataclass(frozen=True) +class ManagedOutput: + """Normalized metadata for one Constructor Studio-managed generated output.""" + + path: str + provider: str + owner_kind: str + owner_id: str = "" + bundle_id: str = "" + managed: bool = True + gitignore: bool = True + + +def _normalize_managed_output_path(project_root: Path, raw_path: str) -> str: + candidate_path = Path(raw_path) + if candidate_path.is_absolute(): + return _safe_relpath(candidate_path, project_root) + return raw_path.replace("\\", "/").strip("/") + + +def _provider_for_output_path(path: str) -> str: + normalized = path.replace("\\", "/").strip("/") + if normalized.startswith(".claude/"): + return "claude" + if normalized.startswith(".cursor/"): + return "cursor" + if normalized.startswith(".github/"): + return "copilot" + if normalized.startswith(".codex/"): + return "openai" + if normalized.startswith(".windsurf/"): + return "windsurf" + if normalized.startswith(".agents/"): + return "studio" + return "unknown" + + +def _bundle_id_for_output_path(path: str) -> str: + normalized = path.replace("\\", "/").strip("/") + name = Path(normalized).name + for suffix in (".agent.md", ".prompt.md", ".toml", ".md"): + if name.endswith(suffix): + return name[: -len(suffix)] + return name + + +def _managed_outputs_from_section( + project_root: Path, + section: Dict[str, Any], + *, + owner_kind: str, +) -> List[ManagedOutput]: + outputs: List[ManagedOutput] = [] + for path in sorted(_collect_managed_result_paths(project_root, section)): + provider = _provider_for_output_path(path) + outputs.append( + ManagedOutput( + path=path, + provider=provider, + owner_kind=owner_kind, + owner_id=path, + bundle_id=_bundle_id_for_output_path(path), + ) + ) + return outputs + + +def _scan_owned_generated_outputs(project_root: Path) -> List[ManagedOutput]: + """Return generated outputs provably owned by Constructor Studio on disk.""" + scan_roots = { + "claude": [project_root / ".claude" / "agents", project_root / ".claude" / "skills"], + "cursor": [project_root / ".cursor" / "agents", project_root / ".cursor" / "commands"], + "copilot": [project_root / ".github" / "agents", project_root / ".github" / "prompts"], + "openai": [project_root / ".codex" / "agents"], + "windsurf": [project_root / ".windsurf" / "workflows"], + "studio": [project_root / ".agents" / "skills"], + } + outputs: List[ManagedOutput] = [] + for provider, roots in scan_roots.items(): + for root in roots: + if not root.is_dir(): + continue + for path in sorted(p for p in root.rglob("*") if p.is_file()): + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + rel = _safe_relpath(path, project_root) + owner_kind = "generated" + owned = False + if rel.endswith(".toml"): + owned = _is_studio_managed_toml_output(content) + owner_kind = "agent" + else: + owned = bool( + _extract_studio_follow_target(content) + or _extract_studio_endpoint_target(content) + or _is_pure_studio_generated(content) + ) + if "/agents/" in rel: + owner_kind = "agent" + elif "/skills/" in rel: + owner_kind = "skill" + elif "/workflows/" in rel or "/commands/" in rel or "/prompts/" in rel: + owner_kind = "workflow" + if not owned: + continue + outputs.append( + ManagedOutput( + path=rel, + provider=provider, + owner_kind=owner_kind, + owner_id=rel, + bundle_id=_bundle_id_for_output_path(rel), + ) + ) + return outputs + + +def collect_managed_outputs( project_root: Path, studio_root: Path, -) -> List[str]: - """Return exact CFS-managed agent integration paths for the current install.""" +) -> List[ManagedOutput]: + """Return the normalized set of Constructor Studio-managed generated outputs.""" cfg = _default_agents_config() - managed_paths: Set[str] = set() + managed: Dict[str, ManagedOutput] = {} for marker_path, _marker_content in _INSTALL_MARKERS.values(): - managed_paths.add(marker_path) + normalized = marker_path.replace("\\", "/").strip("/") + managed[normalized] = ManagedOutput( + path=normalized, + provider=_provider_for_output_path(normalized), + owner_kind="marker", + owner_id=normalized, + bundle_id=_bundle_id_for_output_path(normalized), + ) for agent in _ALL_RECOGNIZED_AGENTS: agent_cfg = cfg.get("agents", {}).get(agent, {}) @@ -3816,26 +3982,47 @@ def list_managed_agent_output_paths( if not isinstance(output, dict): continue rel_path = output.get("path") - if isinstance(rel_path, str) and rel_path.strip(): - managed_paths.add(rel_path.replace("\\", "/").strip("/")) - sections: List[Dict[str, Any]] = [] - sections.append(_process_workflows(agent, project_root, studio_root, cfg, None, dry_run=True)) - sections.append(_process_skills(agent, project_root, studio_root, cfg, None, dry_run=True)) - sections.append(_process_kit_workflow_skills(agent, project_root, studio_root, cfg, None, dry_run=True)) - sections.append(_process_subagents(agent, project_root, studio_root, cfg, None, dry_run=True)) + if not isinstance(rel_path, str) or not rel_path.strip(): + continue + normalized = rel_path.replace("\\", "/").strip("/") + managed[normalized] = ManagedOutput( + path=normalized, + provider=_provider_for_output_path(normalized), + owner_kind="configured", + owner_id=normalized, + bundle_id=_bundle_id_for_output_path(normalized), + ) + + sections: List[Tuple[str, Dict[str, Any]]] = [ + ("workflow", _process_workflows(agent, project_root, studio_root, cfg, None, dry_run=True)), + ("skill", _process_skills(agent, project_root, studio_root, cfg, None, dry_run=True)), + ("skill", _process_kit_workflow_skills(agent, project_root, studio_root, cfg, None, dry_run=True)), + ("subagent", _process_subagents(agent, project_root, studio_root, cfg, None, dry_run=True)), + ] public_sections = _process_kit_public_agents_and_rules( agent, project_root, studio_root, dry_run=True, ) - sections.append(public_sections.get("agents", {})) - sections.append(public_sections.get("rules", {})) - for section in sections: - if isinstance(section, dict): - managed_paths.update(_collect_managed_result_paths(project_root, section)) + sections.append(("agent", public_sections.get("agents", {}))) + sections.append(("rule", public_sections.get("rules", {}))) + for owner_kind, section in sections: + for output in _managed_outputs_from_section(project_root, section, owner_kind=owner_kind): + managed[output.path] = output + + for output in _scan_owned_generated_outputs(project_root): + managed[output.path] = output - return sorted(path for path in managed_paths if path) + return sorted(managed.values(), key=lambda item: item.path) + + +def list_managed_agent_output_paths( + project_root: Path, + studio_root: Path, +) -> List[str]: + """Return exact CFS-managed agent integration paths for the current install.""" + return [output.path for output in collect_managed_outputs(project_root, studio_root) if output.gitignore] def _process_legacy_cleanup( @@ -4654,8 +4841,11 @@ def _run_v2_pipeline( remove_cypilot=remove_cypilot, ) if agent in results: + results[agent]["status"] = legacy_result.get("status", "PASS") results[agent]["workflows"] = legacy_result.get("workflows", {}) results[agent]["subagents"] = legacy_result.get("subagents", {}) + results[agent]["rules"] = legacy_result.get("rules", {}) + results[agent]["errors"] = legacy_result.get("errors") legacy_skills = legacy_result.get("skills", {}) v2_skill_ids = {e.get("path", "") for e in results[agent].get("skills", {}).get("outputs", [])} if not any(agent in str(sk_path) for sk_path in v2_skill_ids): @@ -5107,6 +5297,63 @@ def _result_has_fatal_errors(result: Dict[str, Any]) -> bool: # Any other error (including non-string structured errors) is fatal. return True return False + + +def _output_actions_with_reason(section: Dict[str, Any], action: str) -> List[Dict[str, str]]: + """Return normalized output items from *section* matching *action*.""" + outputs = section.get("outputs") if isinstance(section, dict) else None + if not isinstance(outputs, list): + return [] + collected: List[Dict[str, str]] = [] + for item in outputs: + if not isinstance(item, dict) or item.get("action") != action: + continue + path = item.get("path") + reason = item.get("reason") + if not isinstance(path, str) or not path: + continue + entry: Dict[str, str] = {"path": path} + if isinstance(reason, str) and reason: + entry["reason"] = reason + collected.append(entry) + return collected + + +def _collect_partial_reasons(results: Dict[str, Any]) -> List[Dict[str, Any]]: + """Summarize why a generate-agents run ended in PARTIAL state.""" + partials: List[Dict[str, Any]] = [] + for agent_name, result in results.items(): + if str(result.get("status", "PASS")).upper() == "PASS": + continue + categories: List[str] = [] + errors = result.get("errors") or [] + error_messages = [str(err) for err in errors] + if error_messages: + categories.append("errors") + preserved_outputs: List[Dict[str, str]] = [] + for section_name in ("skills", "legacy_skills", "subagents", "rules", "v2_agents"): + preserved_outputs.extend(_output_actions_with_reason(result.get(section_name, {}), "preserved")) + if preserved_outputs: + categories.append("preserved_outputs") + skipped: List[str] = [] + for label, section_name in (("subagents", "subagents"), ("rules", "rules")): + section = result.get(section_name, {}) + if isinstance(section, dict) and section.get("skipped") and section.get("skip_reason"): + skipped.append(f"{label}: {section.get('skip_reason')}") + if skipped: + categories.append("skipped_components") + partial_entry: Dict[str, Any] = { + "agent": agent_name, + "categories": categories or ["unspecified"], + } + if error_messages: + partial_entry["errors"] = error_messages + if preserved_outputs: + partial_entry["preserved_outputs"] = preserved_outputs + if skipped: + partial_entry["skipped"] = skipped + partials.append(partial_entry) + return partials # @cpt-end:cpt-studio-flow-agent-integration-generate:p1:inst-return-exit-code @@ -5122,6 +5369,7 @@ def _build_result( gitignore_action: Optional[str] = None, ) -> Dict[str, Any]: has_errors = any(r.get("status") != "PASS" for r in results.values()) + partial_reasons = _collect_partial_reasons(results) if has_errors else [] result = { "status": "PASS" if not has_errors else "PARTIAL", "agents": list(agents_to_process), @@ -5134,6 +5382,8 @@ def _build_result( } if gitignore_action: result["gitignore"] = gitignore_action + if partial_reasons: + result["partial_reasons"] = partial_reasons return result # @cpt-end:cpt-studio-algo-agent-integration-generate-shims:p1:inst-format-output @@ -5436,6 +5686,15 @@ def _human_generate_agents_ok( ui.hint("• Recognize the Constructor Studio skill in chat") else: ui.warn("Agent setup finished with some errors (see above).") + partial_reasons = data.get("partial_reasons") + if isinstance(partial_reasons, list): + for item in partial_reasons: + if not isinstance(item, dict): + continue + agent_name = str(item.get("agent") or "agent") + categories = item.get("categories") or [] + category_text = ", ".join(str(category) for category in categories) if categories else "unspecified" + ui.warn(f" partial reason for {agent_name}: {category_text}") ui.blank() # @cpt-end:cpt-studio-algo-agent-integration-generate-shims:p1:inst-format-output @@ -5626,17 +5885,14 @@ def _translate_copilot_schema(agent: "_AgentEntry") -> Dict[str, Any]: def _translate_codex_schema(agent: "_AgentEntry") -> Dict[str, Any]: - """Return an explicit skip for manifest OpenAI agent generation.""" + """Translate an agent entry to the OpenAI/Codex TOML schema.""" # @cpt-begin:cpt-studio-algo-project-extensibility-translate-agent-schema:p1:inst-step-codex sandbox_mode = "read-only" if agent.mode == "readonly" else "workspace-write" result: Dict[str, Any] = { "frontmatter": [], "body_prefix": "", - "skip": True, - "skip_reason": ( - "OpenAI/Codex manifest-agent generation is unsupported; OpenAI uses shared " - ".agents/skills/ outputs only" - ), + "skip": False, + "skip_reason": "", "sandbox_mode": sandbox_mode, "developer_instructions": agent.description or "", } diff --git a/skills/studio/scripts/studio/commands/init.py b/skills/studio/scripts/studio/commands/init.py index a60f6eba..875a0996 100644 --- a/skills/studio/scripts/studio/commands/init.py +++ b/skills/studio/scripts/studio/commands/init.py @@ -349,14 +349,46 @@ def _read_kit_tracking_state( return default_policy, kit_tracking, kit_paths +def _normalize_ignored_kit_path( + project_root: Path, + studio_root: Path, + raw_path: str, +) -> Optional[str]: + """Return a clean project-relative gitignore path for an ignored kit.""" + normalized = raw_path.strip().replace("\\", "/") + if not normalized: + return None + candidate = Path(normalized) + if not candidate.is_absolute(): + candidate = studio_root / candidate + try: + resolved = candidate.resolve() + project_resolved = project_root.resolve() + except OSError: + resolved = candidate + project_resolved = project_root + try: + return resolved.relative_to(project_resolved).as_posix() + except ValueError: + return normalized.strip("/") + + # @cpt-begin:cpt-studio-algo-core-infra-gitignore-footprint:p1:inst-ignore-kits-by-policy -def _ignored_kit_paths(core_toml_path: Path, default: str = "tracked") -> List[str]: +def _ignored_kit_paths( + project_root: Path, + core_toml_path: Path, + default: str = "tracked", +) -> List[str]: _, kit_tracking, kit_paths = _read_kit_tracking_state(core_toml_path, default=default) - return [ - kit_paths[slug] - for slug, tracking in sorted(kit_tracking.items()) - if tracking == "ignored" - ] + studio_root = core_toml_path.parent.parent + ignored: List[str] = [] + for slug, tracking in sorted(kit_tracking.items()): + if tracking != "ignored": + continue + normalized = _normalize_ignored_kit_path(project_root, studio_root, kit_paths[slug]) + if normalized: + ignored.append(normalized) + return ignored # @cpt-end:cpt-studio-algo-core-infra-gitignore-footprint:p1:inst-ignore-kits-by-policy @@ -381,7 +413,7 @@ def _gitignore_patterns( for kit_path in ignored_kit_paths: kit_rel = kit_path.strip().replace("\\", "/").strip("/") if kit_rel: - patterns.append(f"{install_rel}/{kit_rel}/") + patterns.append(kit_rel if kit_rel.endswith("/") else f"{kit_rel}/") return patterns @@ -421,7 +453,7 @@ def _write_gitignore_block( expected_block = _compute_gitignore_block( project_root, install_dir, - _ignored_kit_paths(core_toml_path, default=default_kit_tracking), + _ignored_kit_paths(project_root, core_toml_path, default=default_kit_tracking), runtime_tracking=_read_install_tracking(core_toml_path, "runtime_tracking", default="ignored"), agent_tracking=_read_install_tracking(core_toml_path, "agent_tracking", default="ignored"), ) diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index 62e41fb2..06d0195c 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -1205,6 +1205,66 @@ def _manifest_resource_binding_entry( if isinstance(artifact_bindings, dict) and artifact_bindings: entry["artifacts"] = artifact_bindings entry["public"] = bool(getattr(res, "public", False)) + description = str(getattr(res, "description", "") or "").strip() + if description: + entry["description"] = description + if not bool(getattr(res, "user_modifiable", True)): + entry["user_modifiable"] = False + aliases = list(getattr(res, "aliases", []) or []) + if aliases: + entry["aliases"] = aliases + generated_targets = list(getattr(res, "generated_targets", []) or []) + if generated_targets: + entry["generated_targets"] = generated_targets + origin = str(getattr(res, "origin", "") or "").strip() + if origin: + entry["origin"] = origin + if not bool(getattr(res, "prefix_generated_name", True)): + entry["prefix_generated_name"] = False + tools = list(getattr(res, "tools", []) or []) + if tools: + entry["tools"] = tools + disallowed_tools = list(getattr(res, "disallowed_tools", []) or []) + if disallowed_tools: + entry["disallowed_tools"] = disallowed_tools + mode = str(getattr(res, "mode", "readwrite") or "readwrite").strip() + if mode and mode != "readwrite": + entry["mode"] = mode + if bool(getattr(res, "isolation", False)): + entry["isolation"] = True + model = str(getattr(res, "model", "") or "").strip() + if model: + entry["model"] = model + skills = list(getattr(res, "skills", []) or []) + if skills: + entry["skills"] = skills + color = str(getattr(res, "color", "") or "").strip() + if color: + entry["color"] = color + memory_dir = str(getattr(res, "memory_dir", "") or "").strip() + if memory_dir: + entry["memory_dir"] = memory_dir + role = str(getattr(res, "role", "any") or "any").strip() + if role and role != "any": + entry["role"] = role + target = str(getattr(res, "target", "any") or "any").strip() + if target and target != "any": + entry["target"] = target + provider = str(getattr(res, "provider", "anthropic") or "anthropic").strip() + if provider and provider != "anthropic": + entry["provider"] = provider + reasoning_effort = getattr(res, "reasoning_effort", None) + if reasoning_effort: + entry["reasoning_effort"] = reasoning_effort + context_window = getattr(res, "context_window", None) + if context_window: + entry["context_window"] = context_window + subagents = list(getattr(res, "subagents", []) or []) + if subagents: + entry["subagents"] = subagents + targets = dict(getattr(res, "target_configs", {}) or {}) + if targets: + entry["targets"] = targets return entry # @cpt-end:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-register-bindings @@ -1250,6 +1310,30 @@ def _manifest_register_resource_bindings( # @cpt-end:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-register-bindings +def _manifest_public_subagent_sources(resources: List[Any]) -> List[str]: + """Return unique relative prompt sources declared by canonical public subagents.""" + sources: List[str] = [] + seen: set[str] = set() + for res in resources: + if str(getattr(res, "kind", "") or "") != "agent": + continue + for subagent in getattr(res, "subagents", []) or []: + if not isinstance(subagent, dict): + continue + raw_source = str(subagent.get("source", "") or "").strip().replace("\\", "/") + if not raw_source: + continue + source_path = PurePosixPath(raw_source) + if source_path.is_absolute() or ".." in source_path.parts: + continue + normalized = source_path.as_posix() + if normalized in seen: + continue + seen.add(normalized) + sources.append(normalized) + return sources + + # @cpt-begin:cpt-studio-algo-kit-model-normalize:p1:inst-kitmodel-hashes # @cpt-begin:cpt-studio-algo-kit-public-component-generation:p1:inst-public-prefix def _kit_model_public_component_names(model: Any) -> Dict[str, str]: @@ -1833,6 +1917,7 @@ def install_kit_with_manifest( "errors": [str(exc)], } model_resources = list(getattr(kit_model, "resources", [])) + extra_subagent_sources = _manifest_public_subagent_sources(model_resources) risk_errors = _tool_risk_approval_errors( kit_model, interactive=interactive, @@ -1998,6 +2083,11 @@ def install_kit_with_manifest( _copy_manifest_resource(kit_source, res, target_abs) # @cpt-end:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-copy-resource files_copied += 1 + for subagent_source in extra_subagent_sources: + target_abs = (kit_root / Path(PurePosixPath(subagent_source))).resolve() + target_abs.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(kit_source / Path(PurePosixPath(subagent_source)), target_abs) + files_copied += 1 # @cpt-end:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-foreach-resource # @cpt-end:cpt-studio-algo-kit-local-path-install-mode:p1:inst-local-copy-resources @@ -3226,6 +3316,43 @@ def _build_kit_update_result(kit_slug: str, kit_r: Dict[str, Any]) -> Dict[str, # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result +def _collect_kit_update_partial_reasons(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Summarize non-pass kit update outcomes for JSON/human output.""" + partials: List[Dict[str, Any]] = [] + for result in results: + action = _normalize_kit_update_action(result.get("action")) + if action in {"current", "updated", "created", "dry_run"}: + continue + kit_slug = str(result.get("kit") or "?") + categories: List[str] = [] + entry: Dict[str, Any] = {"kit": kit_slug} + errors = result.get("errors") or [] + if errors: + categories.append("errors") + entry["errors"] = [str(err) for err in errors] + declined = result.get("declined") or [] + if declined: + categories.append("declined_files") + entry["declined"] = list(declined) + prune_required = result.get("prune_required") or [] + if prune_required: + categories.append("declined_prunes") + entry["declined_prunes"] = [ + str(item.get("path") or "") + for item in prune_required + if isinstance(item, dict) and item.get("path") + ] + if action == "aborted": + categories.append("aborted") + elif action == "failed": + categories.append("failed") + elif action == "partial": + categories.append("partial_update") + entry["categories"] = categories or ["unspecified"] + partials.append(entry) + return partials + + # @cpt-flow:cpt-studio-flow-kit-update-cli:p1 def cmd_kit_update(argv: List[str]) -> int: """Update installed kits from their registered sources or a local path. @@ -3496,8 +3623,17 @@ def cmd_kit_update(argv: List[str]) -> int: not in ("current", "dry_run", "aborted", "failed") ) command_failed = has_failed_updates + command_incomplete = any( + _normalize_kit_update_action(r.get("action")) == "partial" + for r in all_results + ) + interactive_partial_success = bool(interactive and command_incomplete and not command_failed) if command_failed: status = "FAIL" + elif interactive_partial_success: + status = "PASS" + elif command_incomplete: + status = "WARN" elif not errors: status = "PASS" else: @@ -3507,13 +3643,16 @@ def cmd_kit_update(argv: List[str]) -> int: "kits_updated": n_updated, "results": all_results, } + partial_reasons = _collect_kit_update_partial_reasons(all_results) + if partial_reasons: + output["partial_reasons"] = partial_reasons if errors: output["errors"] = errors if not n_updated and not errors: output["message"] = "All kits are up to date" ui.result(output, human_fn=_human_kit_update) - return 2 if command_failed else 0 + return 2 if (command_failed or (command_incomplete and not interactive_partial_success)) else 0 # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-format-output # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-human-output @@ -3565,6 +3704,16 @@ def _human_kit_update(data: dict) -> None: ui.blank() for e in errs: ui.warn(str(e)) + partial_reasons = data.get("partial_reasons", []) + if partial_reasons: + ui.blank() + for item in partial_reasons: + if not isinstance(item, dict): + continue + kit_slug = str(item.get("kit") or "?") + categories = item.get("categories") or [] + category_text = ", ".join(str(category) for category in categories) if categories else "unspecified" + ui.warn(f" partial reason for {kit_slug}: {category_text}") if status == "PASS": ui.success("Kit update complete.") @@ -4788,10 +4937,10 @@ def update_kit( accepted = report.get("accepted", []) declined = report.get("declined", []) - if accepted: - ver_status = "updated" - elif declined: + if declined: ver_status = "partial" + elif accepted: + ver_status = "updated" else: ver_status = "current" diff --git a/skills/studio/scripts/studio/commands/spec_coverage.py b/skills/studio/scripts/studio/commands/spec_coverage.py index d0c82a56..31b7f6ad 100644 --- a/skills/studio/scripts/studio/commands/spec_coverage.py +++ b/skills/studio/scripts/studio/commands/spec_coverage.py @@ -16,6 +16,22 @@ from ..utils.ui import ui # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-coverage-imports + +def _collect_system_slugs(nodes: List[object]) -> set[str]: + """Return all known system slugs, including nested children.""" + slugs: set[str] = set() + + def _visit(node: object) -> None: + slug = getattr(node, "slug", "") + if slug: + slugs.add(slug) + for child in getattr(node, "children", []): + _visit(child) + + for node in nodes: + _visit(node) + return slugs + def cmd_spec_coverage(argv: List[str]) -> int: """Run spec coverage analysis on registered codebase files.""" from ..utils.context import get_context @@ -70,6 +86,19 @@ def collect_codebase_files(system_node: object) -> None: collect_codebase_files(child) system_slugs = set(args.systems) if args.systems else None + known_system_slugs = _collect_system_slugs(list(meta.systems)) + if system_slugs is not None: + unknown_systems = sorted(system_slugs - known_system_slugs) + if unknown_systems: + _output( + { + "status": "FAIL", + "message": "Unknown system selector(s)", + "unknown_systems": unknown_systems, + }, + args, + ) + return 2 def _matches_system_filter(node: object) -> bool: """Check if a system node (or any ancestor) matches the --system filter.""" diff --git a/skills/studio/scripts/studio/commands/workspace_add.py b/skills/studio/scripts/studio/commands/workspace_add.py index 5000e52d..75b6e4ec 100644 --- a/skills/studio/scripts/studio/commands/workspace_add.py +++ b/skills/studio/scripts/studio/commands/workspace_add.py @@ -74,8 +74,24 @@ def _emit_add_result(args: argparse.Namespace, replaced: bool, config_path: str, return 0 +def _validate_local_source_path(raw_path: str, base_dir: Path) -> str | None: + """Validate that a local workspace source path resolves to an existing directory.""" + resolved = (base_dir / raw_path).resolve() + if not resolved.exists(): + return f"Source path not reachable: {raw_path} (resolved to {resolved})" + if not resolved.is_dir(): + return f"Source path is not a directory: {raw_path} (resolved to {resolved})" + return None + + def _add_to_standalone(args: argparse.Namespace, ws_cfg: WorkspaceConfig) -> int: """Add source to an existing standalone .cf-workspace.toml.""" + if args.path: + base_dir = ws_cfg.workspace_file.parent if ws_cfg.workspace_file is not None else Path.cwd() + path_err = _validate_local_source_path(args.path, base_dir) + if path_err: + ui.result({"status": "ERROR", "message": path_err}) + return 1 # @cpt-begin:cpt-studio-flow-workspace-add:p1:inst-add-check-collision replaced = args.name in ws_cfg.sources if replaced and not args.force: @@ -108,6 +124,11 @@ def _add_to_inline(args: argparse.Namespace, project_root: Path) -> int: if getattr(args, "url", None): ui.result({"status": "ERROR", "message": "Git URL sources are not supported in inline workspace config."}) return 1 + if args.path: + path_err = _validate_local_source_path(args.path, project_root) + if path_err: + ui.result({"status": "ERROR", "message": path_err}) + return 1 from ..utils.workspace import load_inline_config from ..utils import toml_utils diff --git a/skills/studio/scripts/studio/utils/context.py b/skills/studio/scripts/studio/utils/context.py index b72552e0..6a28c531 100644 --- a/skills/studio/scripts/studio/utils/context.py +++ b/skills/studio/scripts/studio/utils/context.py @@ -515,6 +515,7 @@ class WorkspaceContext: primary: StudioContext sources: Dict[str, SourceContext] = field(default_factory=dict) workspace_file: Optional[Path] = None + primary_source_name: Optional[str] = None cross_repo: bool = True # From traceability.cross_repo in workspace config resolve_remote_ids: bool = True # From traceability.resolve_remote_ids @@ -665,9 +666,51 @@ def load(cls, primary_ctx: StudioContext) -> Optional["WorkspaceContext"]: return result # @cpt-end:cpt-studio-algo-workspace-load-context:p1:inst-ctx-return + @classmethod + def load_from_workspace_root(cls, project_root: Path) -> Optional["WorkspaceContext"]: + """Load workspace context from a standalone workspace root. + + This path is used when cwd has a workspace config but does not contain + a local Constructor Studio adapter. The first reachable source with a + loadable adapter becomes the primary context; all configured sources + remain addressable through ``sources``. + """ + from .workspace import find_workspace_config + + ws_cfg, ws_err = find_workspace_config(project_root) + if ws_cfg is None: + if ws_err: + print(f"Warning: workspace config error: {ws_err}", file=sys.stderr) + return None + + sources = {name: _load_source(name, src_entry, ws_cfg) for name, src_entry in ws_cfg.sources.items()} + + primary_ctx: Optional[StudioContext] = None + primary_source_name: Optional[str] = None + for sc in sources.values(): + primary_ctx = resolve_adapter_context(sc, emit_warnings=False) + if primary_ctx is not None: + primary_source_name = sc.name + break + if primary_ctx is None: + return None + + return cls( + primary=primary_ctx, + sources=sources, + workspace_file=ws_cfg.workspace_file, + primary_source_name=primary_source_name, + cross_repo=ws_cfg.traceability.cross_repo, + resolve_remote_ids=ws_cfg.traceability.resolve_remote_ids, + ) + # @cpt-dod:cpt-studio-dod-workspace-cross-repo-editing:p1 -def resolve_adapter_context(sc: "SourceContext") -> Optional["StudioContext"]: +def resolve_adapter_context( + sc: "SourceContext", + *, + emit_warnings: bool = True, +) -> Optional["StudioContext"]: """Load a source's own StudioContext from its adapter directory. Returns cached result on repeat calls. Returns None for unreachable @@ -689,7 +732,8 @@ def resolve_adapter_context(sc: "SourceContext") -> Optional["StudioContext"]: # @cpt-begin:cpt-studio-algo-workspace-resolve-adapter-context:p1:inst-adapter-compute-path # @cpt-begin:cpt-studio-algo-workspace-resolve-adapter-context:p1:inst-adapter-if-missing if not sc.adapter_dir.is_dir(): - print(f"Warning: adapter directory not found for source '{sc.name}': {sc.adapter_dir}", file=sys.stderr) + if emit_warnings: + print(f"Warning: adapter directory not found for source '{sc.name}': {sc.adapter_dir}", file=sys.stderr) sc._adapter_resolved = True # pylint: disable=protected-access # same impl scope as SourceContext return None # @cpt-end:cpt-studio-algo-workspace-resolve-adapter-context:p1:inst-adapter-if-missing @@ -699,11 +743,13 @@ def resolve_adapter_context(sc: "SourceContext") -> Optional["StudioContext"]: try: loaded = StudioContext.load_from_dir(sc.adapter_dir) except (OSError, ValueError) as e: - print(f"Warning: failed to load adapter context for source '{sc.name}': {e}", file=sys.stderr) + if emit_warnings: + print(f"Warning: failed to load adapter context for source '{sc.name}': {e}", file=sys.stderr) sc._adapter_resolved = True # pylint: disable=protected-access # same impl scope as SourceContext return None if loaded is None: - print(f"Warning: adapter context could not be loaded for source '{sc.name}'", file=sys.stderr) + if emit_warnings: + print(f"Warning: adapter context could not be loaded for source '{sc.name}'", file=sys.stderr) sc._adapter_resolved = True # pylint: disable=protected-access # same impl scope as SourceContext return None # @cpt-end:cpt-studio-algo-workspace-resolve-adapter-context:p1:inst-adapter-if-load-fail @@ -820,7 +866,23 @@ def _collect_source_definition_ids(sc: "SourceContext", ids: Set[str]) -> None: def _load_source(name: str, src_entry: "SourceEntry", ws_cfg: "WorkspaceConfig") -> "SourceContext": """Load a single workspace source, returning an unreachable stub or full context.""" # @cpt-begin:cpt-studio-algo-workspace-load-context:p1:inst-ctx-resolve-path - resolved_path = ws_cfg.resolve_source_path(name) + if src_entry.url: + from .git_utils import peek_git_source_path + from .workspace import ResolveConfig + + if ws_cfg.resolution_base is not None: + base = ws_cfg.resolution_base + elif ws_cfg.workspace_file is not None: + base = ws_cfg.workspace_file.parent + else: + base = None + resolved_path = ( + peek_git_source_path(src_entry, ws_cfg.resolve or ResolveConfig(), base) + if base is not None + else None + ) + else: + resolved_path = ws_cfg.resolve_source_path(name) # @cpt-end:cpt-studio-algo-workspace-load-context:p1:inst-ctx-resolve-path # @cpt-begin:cpt-studio-algo-workspace-load-context:p1:inst-ctx-if-unreachable # @cpt-begin:cpt-studio-state-workspace-source-reachability:p1:inst-source-becomes-unreachable @@ -966,9 +1028,14 @@ def ensure_context(start_path: Optional[Path] = None) -> Optional[Union[StudioCo _global_context = ws_ctx if ws_ctx is not None else base_ctx # @cpt-end:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return-workspace else: - # @cpt-begin:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return - _global_context = None - # @cpt-end:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return + project_root = _find_project_root_for_workspace(start_path or Path.cwd()) + if project_root is not None: + _workspace_upgrade_attempted = True + _global_context = WorkspaceContext.load_from_workspace_root(project_root) + else: + # @cpt-begin:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return + _global_context = None + # @cpt-end:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return return _global_context @@ -1010,8 +1077,11 @@ def collect_artifacts_to_scan( if artifact_path is not None and artifact_path.exists(): artifacts.append((artifact_path, str(artifact_meta.kind))) src_name = getattr(artifact_meta, "source", None) - if is_ws and src_name: - path_to_source[str(artifact_path)] = src_name + if is_ws: + if src_name: + path_to_source[str(artifact_path)] = src_name + elif getattr(ctx, "primary_source_name", None): + path_to_source[str(artifact_path)] = str(ctx.primary_source_name) # Remote source artifacts (workspace mode with cross-repo and remote ID resolution enabled) if is_ws and ctx.cross_repo and ctx.resolve_remote_ids: @@ -1114,6 +1184,13 @@ def resolve_target_and_artifacts( return target_id, ctx, artifacts_to_scan, path_to_source, None +def _find_project_root_for_workspace(start_path: Path) -> Optional[Path]: + """Find a git project root that may host a standalone workspace config.""" + from .files import find_project_root + + return find_project_root(start_path.resolve()) + + __all__ = [ "StudioContext", "LoadedKit", diff --git a/skills/studio/scripts/studio/utils/kit_model.py b/skills/studio/scripts/studio/utils/kit_model.py index 282428ba..fa8228cf 100644 --- a/skills/studio/scripts/studio/utils/kit_model.py +++ b/skills/studio/scripts/studio/utils/kit_model.py @@ -342,7 +342,12 @@ def _resolved_source_path_within_kit(kit_source: Path, source: str) -> Optional[ # @cpt-end:cpt-studio-algo-kit-canonical-manifest:p1:inst-canonical-any-layout -def _public_name_from_source(kit_source: Path, source: str, kind: str) -> str: +def _public_name_from_source( + kit_source: Path, + source: str, + kind: str, + resource_id: str = "", +) -> str: """Return an implicit public component name derived from source content.""" if kind not in _PUBLIC_KINDS: return "" @@ -351,7 +356,12 @@ def _public_name_from_source(kit_source: Path, source: str, kind: str) -> str: return "" if not source_path.is_file(): return "" - return _frontmatter_name(source_path) + frontmatter_name = _frontmatter_name(source_path) + if frontmatter_name: + return frontmatter_name + if kind == "agent": + return resource_id.strip() + return "" def _resource_generated_name( @@ -796,7 +806,7 @@ def _canonical_model_from_entry( slug, public, prefix_generated_name, - _public_name_from_source(kit_source, source, kind), + _public_name_from_source(kit_source, source, kind, resource_id), ), prefix_generated_name=prefix_generated_name, tools=_string_list( @@ -991,7 +1001,7 @@ def _from_manifest_resource( slug, kind in _PUBLIC_KINDS, True, - implicit_generated_name=_public_name_from_source(kit_source, res.source, kind), + implicit_generated_name=_public_name_from_source(kit_source, res.source, kind, res.id), ), ) # @cpt-end:cpt-studio-algo-kit-model-normalize:p1:inst-kitmodel-resource-id-vs-generated-name @@ -1045,7 +1055,7 @@ def _from_manifest_component( slug, True, True, - implicit_generated_name=_public_name_from_source(kit_source, source, kind), + implicit_generated_name=_public_name_from_source(kit_source, source, kind, component.id), ), **kwargs, ) @@ -1191,7 +1201,12 @@ def _load_layout_model(kit_source: Path) -> KitModel: slug, kind in _PUBLIC_KINDS, True, - implicit_generated_name=_public_name_from_source(kit_source, filename, kind), + implicit_generated_name=_public_name_from_source( + kit_source, + filename, + kind, + resource_id, + ), ), )) if not resources: @@ -1305,9 +1320,35 @@ def _load_core_model(kit_source: Path) -> KitModel: slug, kind in _PUBLIC_KINDS, bool(binding.get("prefix_generated_name", True)) if isinstance(binding, dict) else True, - _public_name_from_source(kit_source, source, kind), + _public_name_from_source(kit_source, source, kind, resource_id), ), prefix_generated_name=bool(binding.get("prefix_generated_name", True)) if isinstance(binding, dict) else True, + tools=_string_list(binding.get("tools") if isinstance(binding, dict) else None, f"resources.{resource_id}.tools"), + disallowed_tools=_string_list( + binding.get("disallowed_tools") if isinstance(binding, dict) else None, + f"resources.{resource_id}.disallowed_tools", + ), + mode=str(binding.get("mode", "readwrite") or "readwrite") if isinstance(binding, dict) else "readwrite", + isolation=bool(binding.get("isolation", False)) if isinstance(binding, dict) else False, + model=str(binding.get("model", "") or "") if isinstance(binding, dict) else "", + skills=_string_list(binding.get("skills") if isinstance(binding, dict) else None, f"resources.{resource_id}.skills"), + color=str(binding.get("color", "") or "") if isinstance(binding, dict) else "", + memory_dir=str(binding.get("memory_dir", "") or "") if isinstance(binding, dict) else "", + role=str(binding.get("role", "any") or "any") if isinstance(binding, dict) else "any", + target=str(binding.get("target", "any") or "any") if isinstance(binding, dict) else "any", + provider=str(binding.get("provider", "anthropic") or "anthropic") if isinstance(binding, dict) else "anthropic", + reasoning_effort=str(binding.get("reasoning_effort", "") or "") if isinstance(binding, dict) and binding.get("reasoning_effort") else None, + context_window=str(binding.get("context_window", "") or "") if isinstance(binding, dict) and binding.get("context_window") else None, + subagents=_config_subagents( + binding if isinstance(binding, dict) else {}, + {}, + f"resources.{resource_id}.subagents", + ) if isinstance(binding, dict) else [], + target_configs=_config_table( + binding if isinstance(binding, dict) else {}, + "targets", + f"resources.{resource_id}.targets", + ) if isinstance(binding, dict) else {}, # @cpt-begin:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-artifact-bindings artifact_bindings=artifact_bindings, # @cpt-end:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-artifact-bindings @@ -1371,6 +1412,19 @@ def _merge_core_authoritative_model(core_model: KitModel, source_model: KitModel merged_public = core_resource.public if not merged_public and merged_kind == source_resource.kind and source_resource.public: merged_public = True + merged_mode = source_resource.mode + if merged_mode == "readwrite" and core_resource.mode != "readwrite": + merged_mode = core_resource.mode + merged_isolation = source_resource.isolation or core_resource.isolation + merged_role = source_resource.role + if merged_role == "any" and core_resource.role != "any": + merged_role = core_resource.role + merged_target = source_resource.target + if merged_target == "any" and core_resource.target != "any": + merged_target = core_resource.target + merged_provider = source_resource.provider + if merged_provider == "anthropic" and core_resource.provider != "anthropic": + merged_provider = core_resource.provider merged_resources.append(KitResource( id=core_resource.id, kind=merged_kind, @@ -1378,29 +1432,29 @@ def _merge_core_authoritative_model(core_model: KitModel, source_model: KitModel install_path=core_resource.install_path, type=core_resource.type, public=merged_public, - description=source_resource.description, + description=source_resource.description or core_resource.description, user_modifiable=source_resource.user_modifiable, - aliases=list(source_resource.aliases), - generated_targets=list(source_resource.generated_targets), + aliases=list(source_resource.aliases or core_resource.aliases), + generated_targets=list(source_resource.generated_targets or core_resource.generated_targets), origin=core_resource.origin or source_resource.origin, generated_name=source_resource.generated_name or core_resource.generated_name, prefix_generated_name=source_resource.prefix_generated_name, content_hash=core_resource.content_hash, - tools=list(source_resource.tools), - disallowed_tools=list(source_resource.disallowed_tools), - mode=source_resource.mode, - isolation=source_resource.isolation, - model=source_resource.model, - skills=list(source_resource.skills), - color=source_resource.color, - memory_dir=source_resource.memory_dir, - role=source_resource.role, - target=source_resource.target, - provider=source_resource.provider, - reasoning_effort=source_resource.reasoning_effort, - context_window=source_resource.context_window, - subagents=list(source_resource.subagents), - target_configs=dict(source_resource.target_configs), + tools=list(source_resource.tools or core_resource.tools), + disallowed_tools=list(source_resource.disallowed_tools or core_resource.disallowed_tools), + mode=merged_mode, + isolation=merged_isolation, + model=source_resource.model or core_resource.model, + skills=list(source_resource.skills or core_resource.skills), + color=source_resource.color or core_resource.color, + memory_dir=source_resource.memory_dir or core_resource.memory_dir, + role=merged_role, + target=merged_target, + provider=merged_provider, + reasoning_effort=source_resource.reasoning_effort or core_resource.reasoning_effort, + context_window=source_resource.context_window or core_resource.context_window, + subagents=list(source_resource.subagents or core_resource.subagents), + target_configs=dict(source_resource.target_configs or core_resource.target_configs), artifact_bindings=core_resource.artifact_bindings or source_resource.artifact_bindings, )) diff --git a/tests/e2e-analysis-report.md b/tests/e2e-analysis-report.md new file mode 100644 index 00000000..d1c8139f --- /dev/null +++ b/tests/e2e-analysis-report.md @@ -0,0 +1,71 @@ +# E2E Test Analysis Report + +This report is synchronized with the current `*_e2e.py` suite state in `tests/`. + + + +- [Current Snapshot](#current-snapshot) +- [Fully Green Modules](#fully-green-modules) +- [Not Runnable In Current Workspace](#not-runnable-in-current-workspace) +- [Coverage Shape](#coverage-shape) +- [Notes](#notes) + + + +## Current Snapshot + +- Date: `2026-06-20` +- Command: `pytest tests/*_e2e.py -q --tb=no -rA` +- Result: `114 passed`, `1 skipped`, `3 subtests passed` +- Inventory: `14` e2e modules total +- Status split: + - `12` fully green runnable modules + - `1` skipped module + - `1` disabled / no runnable tests module + +Scope rules used for this report: + +- "Fully green" means every executed test in the module passed in the latest run. +- Skipped and disabled upgrade coverage is listed separately and is not counted as green. + +## Fully Green Modules + +| Module | Passing tests | Covered surface | +|---|---:|---| +| `tests/test_cli_agents_e2e.py` | 2 | Public `agents` CLI behavior, including read-only OpenAI inspection and missing-root error handling | +| `tests/test_cli_artifact_tools_e2e.py` | 5 | Artifact utility commands: deprecated `generate-resources`, `pdsl validate`, and TOC dry-run/write flows | +| `tests/test_cli_example_kits_e2e.py` | 48 | End-to-end kit lifecycle across canonical, mixed, and legacy fixtures: install, register, normalize, validate, generate, update, interactive overwrite/prune flows, and Git/GitHub provenance | +| `tests/test_cli_gitignore_e2e.py` | 5 | Managed `.gitignore` behavior for init, installed kits, generated public skills, and generated agent proxies | +| `tests/test_cli_kit_utility_e2e.py` | 7 | `chunk-input`, local kit install/update, deprecated `kit migrate`, and manifest-driven agent generation | +| `tests/test_cli_map_public_e2e.py` | 6 | Public `cfs map` JSON/HTML outputs, sidecar behavior, dangling IDs, federated workspace nodes, and invalid-config failure path | +| `tests/test_cli_navigation_e2e.py` | 2 | Navigation/read behavior for both single-project read-only mode and workspace-root source queries without a local adapter | +| `tests/test_cli_setup_e2e.py` | 9 | Setup/config surfaces: `info`, `resolve-vars`, `update` option validation, and `generate-agents` discovery/dry-run/show-layers flows | +| `tests/test_cli_update_e2e.py` | 3 | Exact `update` command error handling and dry-run non-mutation guarantees | +| `tests/test_cli_validate_e2e.py` | 6 | Exact `validate` public CLI scenarios, including artifact-not-in-registry, workspace-root source validation, and cross-artifact reference pass/fail paths | +| `tests/test_cli_validation_e2e.py` | 8 | `validate-toc`, `spec-coverage`, `check-language`, and alias forwarding behavior | +| `tests/test_cli_workspace_diag_e2e.py` | 13 | Workspace init/add/info/sync, delegate dry-run/non-dry-run/error paths, and `doctor` healthy/degraded/JSON exception/fail outputs | + +## Not Runnable In Current Workspace + +| Module | Current status | Reason | +|---|---|---| +| `tests/test_kit_upgrade_e2e.py` | Skipped | Raises `SkipTest` because local `kits/sdlc/` is not present; the kit moved to a separate repo | +| `tests/test_core_upgrade_e2e.py` | Disabled | `TestCoreUpgradeE2E` is decorated with `@unittest.skip(...)` for the unsupported `v3.x -> 4.0.0` breaking transition | + +## Coverage Shape + +The suite is still centered on public CLI behavior and observable filesystem effects: + +- Read-only validation and diagnostics paths +- Bounded-write setup and bootstrap flows +- Kit lifecycle management, including complex update and overwrite decisions +- Provider/agent generation and managed `.gitignore` integration +- Map generation and workspace/delegation diagnostics +- Error-path guarantees for missing roots, bad selectors, invalid configs, and unreachable workspace sources + +The heaviest area remains `tests/test_cli_example_kits_e2e.py`, and it is currently fully green with `48` passing tests. + +## Notes + +- The report previously reflected older mixed/failing snapshots; the current runnable `*_e2e.py` suite is fully green. +- This version is intentionally a runtime status report for the latest observed suite result, not a static catalog of intended behaviors. diff --git a/tests/fixtures/kits/example-legacy/agents.toml b/tests/fixtures/kits/example-legacy/agents.toml new file mode 100644 index 00000000..3dc1fd07 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/agents.toml @@ -0,0 +1,9 @@ +[agents.example-legacy-reviewer] +description = "Example legacy reviewer" +prompt_file = "agents/reviewer.md" +mode = "readonly" + +[agents.example-legacy-planner] +description = "Example legacy planner" +prompt_file = "agents/planner.md" +mode = "readonly" diff --git a/tests/fixtures/kits/example-legacy/agents/planner.md b/tests/fixtures/kits/example-legacy/agents/planner.md new file mode 100644 index 00000000..0b3c94a2 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/agents/planner.md @@ -0,0 +1,3 @@ +# Example Legacy Planner + +Plan the next action from the legacy fixture outputs. diff --git a/tests/fixtures/kits/example-legacy/agents/reviewer.md b/tests/fixtures/kits/example-legacy/agents/reviewer.md new file mode 100644 index 00000000..03c657c1 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/agents/reviewer.md @@ -0,0 +1,3 @@ +# Example Legacy Reviewer + +Review the legacy fixture outputs after generation. diff --git a/tests/fixtures/kits/example-legacy/artifacts/FEATURE/example.md b/tests/fixtures/kits/example-legacy/artifacts/FEATURE/example.md new file mode 100644 index 00000000..7022a582 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/artifacts/FEATURE/example.md @@ -0,0 +1,5 @@ +@cpt-example:cpt-example-legacy-feature-example:p1 + +# Example Legacy Feature Example + +- Flow: cpt-example-legacy-feature-flow diff --git a/tests/fixtures/kits/example-legacy/artifacts/FEATURE/template.md b/tests/fixtures/kits/example-legacy/artifacts/FEATURE/template.md new file mode 100644 index 00000000..776d1d2c --- /dev/null +++ b/tests/fixtures/kits/example-legacy/artifacts/FEATURE/template.md @@ -0,0 +1,6 @@ +@cpt-template:cpt-example-legacy-feature-template:p1 + +# Example Legacy Feature Template + +- Goal +- Flow diff --git a/tests/fixtures/kits/example-legacy/conf.toml b/tests/fixtures/kits/example-legacy/conf.toml new file mode 100644 index 00000000..b4ed66a0 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/conf.toml @@ -0,0 +1,2 @@ +version = "1.0.0" +slug = "example-legacy" diff --git a/tests/fixtures/kits/example-legacy/constraints.toml b/tests/fixtures/kits/example-legacy/constraints.toml new file mode 100644 index 00000000..40f14d2f --- /dev/null +++ b/tests/fixtures/kits/example-legacy/constraints.toml @@ -0,0 +1,5 @@ +[FEATURE.identifiers.cpt] +required = true + +[FEATURE.identifiers.flow] +required = true diff --git a/tests/fixtures/kits/example-legacy/core.toml b/tests/fixtures/kits/example-legacy/core.toml new file mode 100644 index 00000000..4a182680 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/core.toml @@ -0,0 +1,5 @@ +version = "1.0" + +[kit] +slug = "example-legacy" +layout = "legacy" diff --git a/tests/fixtures/kits/example-legacy/manifest.toml b/tests/fixtures/kits/example-legacy/manifest.toml new file mode 100644 index 00000000..5b4bc65f --- /dev/null +++ b/tests/fixtures/kits/example-legacy/manifest.toml @@ -0,0 +1,23 @@ +[manifest] +version = "1" + +[[resources]] + id = "feature_template" +source = "artifacts/FEATURE/template.md" +default_path = "artifacts/FEATURE/template.md" +type = "file" +user_modifiable = false + +[[resources]] + id = "feature_example" +source = "artifacts/FEATURE/example.md" +default_path = "artifacts/FEATURE/example.md" +type = "file" +user_modifiable = false + +[[resources]] +id = "constraints" +source = "constraints.toml" +default_path = "constraints.toml" +type = "file" +user_modifiable = false diff --git a/tests/fixtures/kits/example-legacy/skills/guide.md b/tests/fixtures/kits/example-legacy/skills/guide.md new file mode 100644 index 00000000..6e038b91 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/skills/guide.md @@ -0,0 +1,8 @@ +--- +name: cf-example-legacy-guide +description: Example legacy guide skill. +--- + +# Example Legacy Guide + +Guide the user through the legacy fixture. diff --git a/tests/fixtures/kits/example-legacy/workflows/release.md b/tests/fixtures/kits/example-legacy/workflows/release.md new file mode 100644 index 00000000..303ca101 --- /dev/null +++ b/tests/fixtures/kits/example-legacy/workflows/release.md @@ -0,0 +1,8 @@ +--- +name: cf-example-legacy-release +description: Example legacy release workflow. +--- + +# Example Legacy Release + +Walk the release process for the legacy fixture. diff --git a/tests/fixtures/kits/example-mixed/.cf-studio-kit.toml b/tests/fixtures/kits/example-mixed/.cf-studio-kit.toml new file mode 100644 index 00000000..3ecaa7c0 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/.cf-studio-kit.toml @@ -0,0 +1,85 @@ +manifest_version = "1.0" + +[[kits]] +slug = "example-mixed" +name = "Example Mixed" +version = "2.1.0" + +[[kits.resources]] +id = "adr-template" +kind = "template" +source = "artifacts/ADR/template.md" +install_path = "artifacts/ADR/template.md" +type = "file" + +[[kits.resources]] +id = "adr-example" +kind = "other" +source = "artifacts/ADR/example.md" +install_path = "artifacts/ADR/example.md" +type = "file" + +[[kits.resources]] +id = "constraints" +kind = "constraints" +source = "constraints.toml" +install_path = "constraints.toml" +type = "file" + +[[kits.resources]] +id = "discovery" +kind = "skill" +source = "skills/discovery/SKILL.md" +install_path = "skills/discovery/SKILL.md" +type = "file" +public = true +generated_targets = ["openai"] + +[[kits.resources]] +id = "review" +kind = "skill" +source = "skills/review/SKILL.md" +install_path = "skills/review/SKILL.md" +type = "file" +public = true +generated_targets = ["openai"] + +[[kits.resources]] +id = "reviewer" +kind = "agent" +source = "agents/reviewer.md" +install_path = "agents/reviewer.md" +type = "file" +public = true +generated_targets = ["openai"] +description = "Example mixed reviewer agent" + +[kits.resources.agent] +mode = "readonly" + +[[kits.resources.agent.subagents]] +id = "reviewer-helper" +source = "agents/reviewer-helper.md" +generated_targets = ["openai"] +description = "Mixed reviewer helper subagent" +mode = "readonly" + +[[kits.resources]] +id = "planner" +kind = "agent" +source = "agents/planner.md" +install_path = "agents/planner.md" +type = "file" +public = true +generated_targets = ["openai"] +description = "Example mixed planner agent" + +[kits.resources.agent] +mode = "readonly" + +[[kits.resources.agent.subagents]] +id = "planner-helper" +source = "agents/planner-helper.md" +generated_targets = ["openai"] +description = "Mixed planner helper subagent" +mode = "readonly" diff --git a/tests/fixtures/kits/example-mixed/agents/planner-helper.md b/tests/fixtures/kits/example-mixed/agents/planner-helper.md new file mode 100644 index 00000000..1f6c7aef --- /dev/null +++ b/tests/fixtures/kits/example-mixed/agents/planner-helper.md @@ -0,0 +1,3 @@ +# Example Mixed Planner Helper + +Collect prerequisites for the mixed planner. diff --git a/tests/fixtures/kits/example-mixed/agents/planner.md b/tests/fixtures/kits/example-mixed/agents/planner.md new file mode 100644 index 00000000..16a446d0 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/agents/planner.md @@ -0,0 +1,3 @@ +# Example Mixed Planner + +Plan the next action from the canonical mixed fixture. diff --git a/tests/fixtures/kits/example-mixed/agents/reviewer-helper.md b/tests/fixtures/kits/example-mixed/agents/reviewer-helper.md new file mode 100644 index 00000000..78e1aafc --- /dev/null +++ b/tests/fixtures/kits/example-mixed/agents/reviewer-helper.md @@ -0,0 +1,3 @@ +# Example Mixed Reviewer Helper + +Collect the legacy-vs-canonical deltas for review. diff --git a/tests/fixtures/kits/example-mixed/agents/reviewer.md b/tests/fixtures/kits/example-mixed/agents/reviewer.md new file mode 100644 index 00000000..804cd489 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/agents/reviewer.md @@ -0,0 +1,3 @@ +# Example Mixed Reviewer + +Review the canonical mixed fixture outputs. diff --git a/tests/fixtures/kits/example-mixed/artifacts/ADR/example.md b/tests/fixtures/kits/example-mixed/artifacts/ADR/example.md new file mode 100644 index 00000000..8bd8c79e --- /dev/null +++ b/tests/fixtures/kits/example-mixed/artifacts/ADR/example.md @@ -0,0 +1,5 @@ +@cpt-example:cpt-example-mixed-adr-example:p1 + +# Example Mixed ADR Example + +- ADR: cpt-example-mixed-adr-001 diff --git a/tests/fixtures/kits/example-mixed/artifacts/ADR/template.md b/tests/fixtures/kits/example-mixed/artifacts/ADR/template.md new file mode 100644 index 00000000..17c7cab4 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/artifacts/ADR/template.md @@ -0,0 +1,6 @@ +@cpt-template:cpt-example-mixed-adr-template:p1 + +# Example Mixed ADR Template + +- Context +- Decision diff --git a/tests/fixtures/kits/example-mixed/conf.toml b/tests/fixtures/kits/example-mixed/conf.toml new file mode 100644 index 00000000..0a891055 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/conf.toml @@ -0,0 +1,2 @@ +version = "2.1.0" +slug = "example-mixed" diff --git a/tests/fixtures/kits/example-mixed/constraints.toml b/tests/fixtures/kits/example-mixed/constraints.toml new file mode 100644 index 00000000..4db3dae6 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/constraints.toml @@ -0,0 +1,5 @@ +[ADR.identifiers.cpt] +required = true + +[ADR.identifiers.adr] +required = true diff --git a/tests/fixtures/kits/example-mixed/core.toml b/tests/fixtures/kits/example-mixed/core.toml new file mode 100644 index 00000000..64734cd2 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/core.toml @@ -0,0 +1,5 @@ +version = "1.0" + +[kit] +slug = "example-mixed" +layout = "mixed" diff --git a/tests/fixtures/kits/example-mixed/manifest.toml b/tests/fixtures/kits/example-mixed/manifest.toml new file mode 100644 index 00000000..3af38e24 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/manifest.toml @@ -0,0 +1,12 @@ +[manifest] +version = "2.0" + +[[skills]] +id = "legacy-discovery" +description = "Legacy discovery skill" +prompt_file = "skills/discovery/SKILL.md" + +[[workflows]] +id = "legacy-release" +description = "Legacy release workflow" +prompt_file = "skills/review/SKILL.md" diff --git a/tests/fixtures/kits/example-mixed/skills/discovery/SKILL.md b/tests/fixtures/kits/example-mixed/skills/discovery/SKILL.md new file mode 100644 index 00000000..7fb06d3e --- /dev/null +++ b/tests/fixtures/kits/example-mixed/skills/discovery/SKILL.md @@ -0,0 +1,8 @@ +--- +name: cf-example-mixed-discovery +description: Example mixed discovery skill. +--- + +# Example Mixed Discovery + +Inspect both canonical and legacy metadata surfaces. diff --git a/tests/fixtures/kits/example-mixed/skills/review/SKILL.md b/tests/fixtures/kits/example-mixed/skills/review/SKILL.md new file mode 100644 index 00000000..c394c902 --- /dev/null +++ b/tests/fixtures/kits/example-mixed/skills/review/SKILL.md @@ -0,0 +1,8 @@ +--- +name: cf-example-mixed-review +description: Example mixed review skill. +--- + +# Example Mixed Review + +Confirm canonical metadata wins over legacy metadata. diff --git a/tests/fixtures/kits/example-v2/.cf-studio-kit.toml b/tests/fixtures/kits/example-v2/.cf-studio-kit.toml new file mode 100644 index 00000000..439d95f2 --- /dev/null +++ b/tests/fixtures/kits/example-v2/.cf-studio-kit.toml @@ -0,0 +1,99 @@ +manifest_version = "1.0" + +[[kits]] +slug = "example-v2" +name = "Example V2" +version = "2.0.0" + +[[kits.resources]] +id = "prd-template" +kind = "template" +source = "artifacts/PRD/template.md" +install_path = "artifacts/PRD/template.md" +type = "file" + +[[kits.resources]] +id = "prd-example" +kind = "other" +source = "artifacts/PRD/example.md" +install_path = "artifacts/PRD/example.md" +type = "file" + +[[kits.resources]] +id = "feature-template" +kind = "template" +source = "artifacts/FEATURE/template.md" +install_path = "artifacts/FEATURE/template.md" +type = "file" + +[[kits.resources]] +id = "feature-example" +kind = "other" +source = "artifacts/FEATURE/example.md" +install_path = "artifacts/FEATURE/example.md" +type = "file" + +[[kits.resources]] +id = "constraints" +kind = "constraints" +source = "constraints.toml" +install_path = "constraints.toml" +type = "file" + +[[kits.resources]] +id = "discovery" +kind = "skill" +source = "skills/discovery/SKILL.md" +install_path = "skills/discovery/SKILL.md" +type = "file" +public = true +generated_targets = ["openai"] + +[[kits.resources]] +id = "review" +kind = "skill" +source = "skills/review/SKILL.md" +install_path = "skills/review/SKILL.md" +type = "file" +public = true +generated_targets = ["openai"] + +[[kits.resources]] +id = "reviewer" +kind = "agent" +source = "agents/reviewer.md" +install_path = "agents/reviewer.md" +type = "file" +public = true +generated_targets = ["openai"] +description = "Example V2 reviewer agent" + +[kits.resources.agent] +mode = "readonly" + +[[kits.resources.agent.subagents]] +id = "reviewer-helper" +source = "agents/reviewer-helper.md" +generated_targets = ["openai"] +description = "Reviewer helper subagent" +mode = "readonly" + +[[kits.resources]] +id = "planner" +kind = "agent" +source = "agents/planner.md" +install_path = "agents/planner.md" +type = "file" +public = true +generated_targets = ["openai"] +description = "Example V2 planner agent" + +[kits.resources.agent] +mode = "readonly" + +[[kits.resources.agent.subagents]] +id = "planner-helper" +source = "agents/planner-helper.md" +generated_targets = ["openai"] +description = "Planner helper subagent" +mode = "readonly" diff --git a/tests/fixtures/kits/example-v2/agents/planner-helper.md b/tests/fixtures/kits/example-v2/agents/planner-helper.md new file mode 100644 index 00000000..cc0759af --- /dev/null +++ b/tests/fixtures/kits/example-v2/agents/planner-helper.md @@ -0,0 +1,3 @@ +# Example V2 Planner Helper + +Collect prerequisites and missing context for the planner. diff --git a/tests/fixtures/kits/example-v2/agents/planner.md b/tests/fixtures/kits/example-v2/agents/planner.md new file mode 100644 index 00000000..c7c9676e --- /dev/null +++ b/tests/fixtures/kits/example-v2/agents/planner.md @@ -0,0 +1,3 @@ +# Example V2 Planner + +Plan the next implementation slice from the installed kit. diff --git a/tests/fixtures/kits/example-v2/agents/reviewer-helper.md b/tests/fixtures/kits/example-v2/agents/reviewer-helper.md new file mode 100644 index 00000000..253a5144 --- /dev/null +++ b/tests/fixtures/kits/example-v2/agents/reviewer-helper.md @@ -0,0 +1,3 @@ +# Example V2 Reviewer Helper + +Extract deltas and edge cases for the reviewer. diff --git a/tests/fixtures/kits/example-v2/agents/reviewer.md b/tests/fixtures/kits/example-v2/agents/reviewer.md new file mode 100644 index 00000000..5aca4bae --- /dev/null +++ b/tests/fixtures/kits/example-v2/agents/reviewer.md @@ -0,0 +1,3 @@ +# Example V2 Reviewer + +Review the change set against the PRD and feature examples. diff --git a/tests/fixtures/kits/example-v2/artifacts/FEATURE/example.md b/tests/fixtures/kits/example-v2/artifacts/FEATURE/example.md new file mode 100644 index 00000000..ceaac565 --- /dev/null +++ b/tests/fixtures/kits/example-v2/artifacts/FEATURE/example.md @@ -0,0 +1,5 @@ +@cpt-example:cpt-example-v2-feature-example:p1 + +# Example V2 Feature Example + +- Flow: cpt-example-v2-feature-flow diff --git a/tests/fixtures/kits/example-v2/artifacts/FEATURE/template.md b/tests/fixtures/kits/example-v2/artifacts/FEATURE/template.md new file mode 100644 index 00000000..ab31bf44 --- /dev/null +++ b/tests/fixtures/kits/example-v2/artifacts/FEATURE/template.md @@ -0,0 +1,6 @@ +@cpt-template:cpt-example-v2-feature-template:p1 + +# Example V2 Feature Template + +- Flow +- Acceptance diff --git a/tests/fixtures/kits/example-v2/artifacts/PRD/example.md b/tests/fixtures/kits/example-v2/artifacts/PRD/example.md new file mode 100644 index 00000000..22a1c2cf --- /dev/null +++ b/tests/fixtures/kits/example-v2/artifacts/PRD/example.md @@ -0,0 +1,5 @@ +@cpt-example:cpt-example-v2-prd-example:p1 + +# Example V2 PRD Example + +- FR: cpt-example-v2-prd-fr diff --git a/tests/fixtures/kits/example-v2/artifacts/PRD/template.md b/tests/fixtures/kits/example-v2/artifacts/PRD/template.md new file mode 100644 index 00000000..c062c3f2 --- /dev/null +++ b/tests/fixtures/kits/example-v2/artifacts/PRD/template.md @@ -0,0 +1,6 @@ +@cpt-template:cpt-example-v2-prd-template:p1 + +# Example V2 PRD Template + +- Scope +- Goals diff --git a/tests/fixtures/kits/example-v2/constraints.toml b/tests/fixtures/kits/example-v2/constraints.toml new file mode 100644 index 00000000..f83dd5b5 --- /dev/null +++ b/tests/fixtures/kits/example-v2/constraints.toml @@ -0,0 +1,11 @@ +[PRD.identifiers.cpt] +required = true + +[PRD.identifiers.fr] +required = true + +[FEATURE.identifiers.cpt] +required = true + +[FEATURE.identifiers.flow] +required = true diff --git a/tests/fixtures/kits/example-v2/skills/discovery/SKILL.md b/tests/fixtures/kits/example-v2/skills/discovery/SKILL.md new file mode 100644 index 00000000..197289ee --- /dev/null +++ b/tests/fixtures/kits/example-v2/skills/discovery/SKILL.md @@ -0,0 +1,8 @@ +--- +name: cf-example-v2-discovery +description: Example V2 discovery skill. +--- + +# Example V2 Discovery + +Inspect project context before implementation. diff --git a/tests/fixtures/kits/example-v2/skills/review/SKILL.md b/tests/fixtures/kits/example-v2/skills/review/SKILL.md new file mode 100644 index 00000000..e3e91270 --- /dev/null +++ b/tests/fixtures/kits/example-v2/skills/review/SKILL.md @@ -0,0 +1,8 @@ +--- +name: cf-example-v2-review +description: Example V2 review skill. +--- + +# Example V2 Review + +Review generated outputs against fixture expectations. diff --git a/tests/test_agents_coverage.py b/tests/test_agents_coverage.py index 4075bd94..558f1e8b 100644 --- a/tests/test_agents_coverage.py +++ b/tests/test_agents_coverage.py @@ -57,18 +57,36 @@ def test_target_path_outside_project_root_warns_and_returns_absolute(self): self.assertEqual(result, target.as_posix()) self.assertIn("outside project root", buf.getvalue()) - def test_registered_legacy_studio_path_rejects_parent_traversal(self): + def test_registered_legacy_studio_path_allows_parent_traversal_within_project(self): from studio.commands.agents import _resolve_registered_legacy_studio_path with TemporaryDirectory() as td: project_root = Path(td) / "project" studio_root = project_root / ".bootstrap" + local_kit = project_root / "local-kits" / "demo" studio_root.mkdir(parents=True) + local_kit.mkdir(parents=True) resolved = _resolve_registered_legacy_studio_path( studio_root, project_root, - "../escape.md", + "../local-kits/demo", + ) + + self.assertEqual(resolved, local_kit.resolve()) + + def test_registered_legacy_studio_path_rejects_parent_traversal_escape(self): + from studio.commands.agents import _resolve_registered_legacy_studio_path + + with TemporaryDirectory() as td: + project_root = Path(td) / "project" + studio_root = project_root / ".bootstrap" + studio_root.mkdir(parents=True) + + resolved = _resolve_registered_legacy_studio_path( + studio_root, + project_root, + "../../escape.md", ) self.assertIsNone(resolved) @@ -302,6 +320,43 @@ def test_no_change_preview_with_gitignore_update_still_returns_without_apply(sel self.assertEqual(rc, 0) self.assertEqual(process.call_count, 2) + def test_build_result_exposes_partial_reasons(self): + from studio.commands.agents import _build_result + + result = _build_result( + { + "openai": { + "status": "PARTIAL", + "errors": ["missing prompt target"], + "skills": {"outputs": []}, + "legacy_skills": {"outputs": []}, + "subagents": { + "outputs": [{"path": ".codex/agents/demo.toml", "action": "preserved", "reason": "user_modified"}], + "skipped": True, + "skip_reason": "one or more agents missing prompt target", + }, + "rules": {"outputs": []}, + "v2_agents": {"outputs": []}, + }, + }, + ["openai"], + Path("/tmp/project"), + Path("/tmp/project/.bootstrap"), + None, + {}, + dry_run=False, + ) + + self.assertEqual(result["status"], "PARTIAL") + self.assertIn("partial_reasons", result) + partial = result["partial_reasons"][0] + self.assertEqual(partial["agent"], "openai") + self.assertIn("errors", partial["categories"]) + self.assertIn("preserved_outputs", partial["categories"]) + self.assertIn("skipped_components", partial["categories"]) + self.assertEqual(partial["errors"], ["missing prompt target"]) + self.assertEqual(partial["preserved_outputs"][0]["path"], ".codex/agents/demo.toml") + def test_dry_run_fatal_preview_returns_one(self): from studio.commands.agents import cmd_generate_agents @@ -624,6 +679,99 @@ def test_generate_agents_uses_kitmodel_public_skill_and_agent_but_not_rule(self) self.assertFalse((root / ".cursor" / "agents" / "cf-pubkit-codexonly.mdc").exists()) self.assertFalse((root / ".cursor" / "agents" / "cf-pubkit-codex-auditor.mdc").exists()) + def test_generate_agents_public_agent_falls_back_to_resource_id_when_frontmatter_missing(self): + from studio.commands.agents import _default_agents_config, _process_single_agent + + with TemporaryDirectory() as td: + root, studio_root = self._make_project(td) + kit_root = studio_root / "config" / "kits" / "pubkit" + (kit_root / "agent.md").write_text( + "# Reviewer\nReview carefully without frontmatter.\n", + encoding="utf-8", + ) + + result = _process_single_agent( + "cursor", + root, + studio_root, + _default_agents_config(), + None, + dry_run=False, + ) + + self.assertEqual(result["status"], "PASS") + agent_path = root / ".cursor" / "agents" / "cf-pubkit-reviewer.mdc" + self.assertTrue(agent_path.is_file()) + self.assertIn("Review carefully without frontmatter.", agent_path.read_text(encoding="utf-8")) + + def test_generate_agents_openai_writes_public_agents_and_nested_subagents(self): + from studio.commands.agents import _default_agents_config, _process_single_agent + + with TemporaryDirectory() as td: + root, studio_root = self._make_project(td) + kit_root = studio_root / "config" / "kits" / "pubkit-openai" + kit_root.mkdir(parents=True) + (kit_root / "agent.md").write_text( + "---\nname: codexonly\ndescription: OpenAI only\n---\n# OpenAI only\n", + encoding="utf-8", + ) + (kit_root / "auditor.md").write_text( + "---\nname: codex-auditor\ndescription: OpenAI auditor\n---\n# OpenAI auditor\n", + encoding="utf-8", + ) + (kit_root / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + 'slug = "pubkit-openai"', + 'name = "Public OpenAI Kit"', + 'version = "1.0"', + "", + "[[kits.resources]]", + 'id = "codexonly"', + 'kind = "agent"', + 'source = "agent.md"', + 'type = "file"', + "public = true", + 'generated_targets = ["openai"]', + "", + "[kits.resources.agent]", + 'mode = "readonly"', + "", + "[[kits.resources.agent.subagents]]", + 'id = "codex-auditor"', + 'source = "auditor.md"', + 'generated_targets = ["openai"]', + ]) + "\n", + encoding="utf-8", + ) + core_toml = studio_root / "config" / "core.toml" + core_toml.write_text( + core_toml.read_text(encoding="utf-8") + + "\n[kits.pubkit-openai]\n" + + 'path = "config/kits/pubkit-openai"\n' + + 'version = "1.0"\n', + encoding="utf-8", + ) + + result = _process_single_agent( + "openai", + root, + studio_root, + _default_agents_config(), + None, + dry_run=False, + ) + + self.assertEqual(result["status"], "PASS") + openai_agent = root / ".codex" / "agents" / "cf-pubkit-openai-codexonly.toml" + openai_nested = root / ".codex" / "agents" / "cf-pubkit-openai-codex-auditor.toml" + self.assertTrue(openai_agent.is_file()) + self.assertTrue(openai_nested.is_file()) + self.assertIn("OpenAI only", openai_agent.read_text(encoding="utf-8")) + self.assertIn("OpenAI auditor", openai_nested.read_text(encoding="utf-8")) + def test_generate_agents_supports_register_mode_project_root_kit_path(self): from studio.commands.agents import _default_agents_config, _process_single_agent diff --git a/tests/test_cli_agents_e2e.py b/tests/test_cli_agents_e2e.py new file mode 100644 index 00000000..0ee61924 --- /dev/null +++ b/tests/test_cli_agents_e2e.py @@ -0,0 +1,215 @@ +"""Public CLI e2e coverage for exact `agents` command.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = main(argv) + return exit_code, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _bootstrap_generator_project(root: Path) -> None: + (root / ".git").mkdir(parents=True, exist_ok=True) + (root / "config").mkdir(parents=True, exist_ok=True) + (root / "skills" / "cypilot").mkdir(parents=True, exist_ok=True) + (root / "skills" / "cypilot" / "SKILL.md").write_text( + "---\nname: cypilot\ndescription: Test skill\n---\n# Cypilot\n", + encoding="utf-8", + ) + (root / "workflows").mkdir(parents=True, exist_ok=True) + (root / "workflows" / "generate.md").write_text( + "---\ntype: workflow\nname: cypilot-generate\ndescription: Generate\n---\n# Generate\n", + encoding="utf-8", + ) + (root / "workflows" / "analyze.md").write_text( + "---\ntype: workflow\nname: cypilot-analyze\ndescription: Analyze\n---\n# Analyze\n", + encoding="utf-8", + ) + + +class TestCLIAgentsE2E(unittest.TestCase): + def test_agents_specific_windsurf_flag_is_read_only(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "agents", "--agent", "windsurf", "--root", str(root), "--cf-constructor-root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["agents"], ["windsurf"]) + self.assertIn("windsurf", payload["results"]) + self.assertFalse((root / ".codex").exists()) + self.assertFalse((root / ".agents").exists()) + + def test_agents_default_targets_all_supported_agents_without_writing(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "agents", "--root", str(root), "--cf-constructor-root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["agents"], ["windsurf", "cursor", "claude", "copilot", "openai"]) + self.assertEqual(sorted(payload["results"].keys()), ["claude", "copilot", "cursor", "openai", "windsurf"]) + + def test_agents_openai_flag_is_read_only(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "agents", "--openai", "--root", str(root), "--cf-constructor-root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["agents"], ["openai"]) + self.assertIn("openai", payload["results"]) + self.assertFalse((root / ".codex").exists()) + self.assertFalse((root / ".agents").exists()) + + def test_agents_invalid_config_errors_without_writing(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + (root / "broken.json").write_text("{broken", encoding="utf-8") + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "agents", + "--openai", + "--root", + str(root), + "--cf-constructor-root", + str(root), + "--config", + str(root / "broken.json"), + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CONFIG_ERROR") + self.assertIn("Cannot read or parse config file", payload["message"]) + + def test_agents_cf_studio_root_override_is_reflected_in_output(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + external = Path(td) / "external-studio" + _bootstrap_generator_project(root) + (external / ".core").mkdir(parents=True, exist_ok=True) + (external / ".gen").mkdir(parents=True, exist_ok=True) + (external / "config").mkdir(parents=True, exist_ok=True) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "agents", + "--agent", + "openai", + "--root", + str(root), + "--cf-studio-root", + str(external), + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["studio_root"], external.resolve().as_posix()) + + def test_agents_missing_root_errors_without_writing(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir(parents=True, exist_ok=True) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "agents", "--openai", "--root", str(root / "missing"), "--cf-constructor-root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "NOT_FOUND") + self.assertIn("No project root found", payload["message"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_artifact_tools_e2e.py b/tests/test_cli_artifact_tools_e2e.py new file mode 100644 index 00000000..4fb37190 --- /dev/null +++ b/tests/test_cli_artifact_tools_e2e.py @@ -0,0 +1,379 @@ +"""Public CLI end-to-end coverage for artifact utility commands.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main +from studio.utils import toml_utils + + +VALID_PDSL = """UNIT Demo + +PURPOSE: + Validate a small block. + +DO: + - RUN Do something deterministic + +RULES: + - ALWAYS keep output stable +""" + + +def _run_main(argv: list[str], *, cwd: Path, stdin: str = "") -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with patch.object(sys, "stdin", io.StringIO(stdin)): + with redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = main(argv) + return exit_code, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _changed_paths( + before: dict[str, tuple[str, bytes | None]], + after: dict[str, tuple[str, bytes | None]], +) -> set[str]: + return {path for path in set(before) | set(after) if before.get(path) != after.get(path)} + + +def _write_root_agents(root: Path, adapter_rel: str) -> None: + (root / "AGENTS.md").write_text( + ( + "\n" + "```toml\n" + f'cf-studio-path = "{adapter_rel}"\n' + "```\n" + "\n" + ), + encoding="utf-8", + ) + + +def _bootstrap_content_project(root: Path, *, project_root_rel: str = "..") -> None: + (root / ".git").mkdir(parents=True, exist_ok=True) + _write_root_agents(root, "adapter") + + adapter = root / "adapter" + config_dir = adapter / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + + toml_utils.dump( + { + "version": "1.0", + "project_root": project_root_rel, + "kits": {"test": {"format": "CFS", "path": "kits/test"}}, + }, + config_dir / "core.toml", + ) + toml_utils.dump( + { + "version": "1.0", + "project_root": project_root_rel, + "kits": {"test": {"format": "CFS", "path": "kits/test"}}, + "systems": [ + { + "name": "Web", + "slug": "web", + "kit": "test", + "artifacts": [ + {"path": "architecture/FEATURE.md", "kind": "FEATURE", "traceability": "FULL"}, + ], + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + }, + config_dir / "artifacts.toml", + ) + toml_utils.dump( + { + "artifacts": { + "FEATURE": {"identifiers": {"item": {"template": "cpt-{system}-item-{slug}"}}}, + }, + }, + root / "kits" / "test" / "constraints.toml", + ) + + architecture = root / "architecture" + architecture.mkdir(parents=True, exist_ok=True) + (architecture / "FEATURE.md").write_text( + "# Feature\n\n" + "### cpt-web-item-scope\n" + "alpha\n" + "beta\n", + encoding="utf-8", + ) + + code_file = root / "src" / "web" / "handlers.py" + code_file.parent.mkdir(parents=True, exist_ok=True) + code_file.write_text( + "# @cpt-begin:cpt-web-flow-login:p1:inst-validate\n" + "def validate():\n" + " return True\n" + "# @cpt-end:cpt-web-flow-login:p1:inst-validate\n" + "# @cpt-flow:cpt-web-flow-scope:p2\n", + encoding="utf-8", + ) + + +class TestCLIArtifactToolsE2E(unittest.TestCase): + def test_get_content_code_mode_paths_are_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_content_project(root) + baseline = _snapshot_tree(root) + code_path = root / "src" / "web" / "handlers.py" + + exit_code, stdout, stderr = _run_main( + ["--json", "get-content", "--code", str(code_path), "--id", "cpt-web-flow-login"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FOUND") + self.assertEqual(payload["id"], "cpt-web-flow-login") + self.assertIsNone(payload["inst"]) + self.assertIn("def validate():", payload["text"]) + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "get-content", + "--code", + str(code_path), + "--id", + "cpt-web-flow-login", + "--inst", + "validate", + ], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FOUND") + self.assertEqual(payload["inst"], "validate") + self.assertIn("def validate():", payload["text"]) + + exit_code, stdout, stderr = _run_main( + ["--json", "get-content", "--code", str(code_path), "--id", "cpt-web-flow-missing"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "NOT_FOUND") + self.assertEqual(payload["id"], "cpt-web-flow-missing") + + def test_get_content_missing_selector_is_non_mutating_error(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "get-content", "--id", "cpt-web-flow-login"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["message"], "Either --artifact or --code must be specified") + + def test_get_content_artifact_not_registered_is_non_mutating_error(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_content_project(root) + unregistered = root / "architecture" / "UNREGISTERED.md" + unregistered.write_text("### cpt-web-item-untracked\nbody\n", encoding="utf-8") + baseline = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "get-content", "--artifact", str(unregistered), "--id", "cpt-web-item-untracked"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["message"], "Artifact not registered: architecture/UNREGISTERED.md") + + def test_get_content_artifact_outside_project_root_is_non_mutating_error(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + project_root = root / "project" + project_root.mkdir() + _bootstrap_content_project(project_root, project_root_rel="app") + outside_artifact = project_root / "architecture" / "FEATURE.md" + baseline = _snapshot_tree(project_root) + + exit_code, stdout, stderr = _run_main( + ["--json", "get-content", "--artifact", str(outside_artifact), "--id", "cpt-web-item-scope"], + cwd=project_root, + ) + after = _snapshot_tree(project_root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["message"], f"Artifact not under project root: {outside_artifact.resolve()}") + + def test_toc_write_mutates_only_target_file_and_validates_output(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + doc = root / "doc.md" + doc.write_text("# Title\n\n## Alpha\n\n### Beta\n", encoding="utf-8") + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main(["--json", "toc", str(doc)], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(_changed_paths(before, after), {"doc.md"}) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["files_processed"], 1) + self.assertEqual(payload["results"][0]["status"], "UPDATED") + self.assertEqual(payload["results"][0]["validation"]["status"], "PASS") + updated = doc.read_text(encoding="utf-8") + self.assertIn("", updated) + self.assertIn("- [Alpha](#alpha)", updated) + self.assertIn("", updated) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + + def test_toc_dry_run_is_read_only_and_reports_would_update(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + doc = root / "doc.md" + doc.write_text("# Title\n\n## Alpha\n\n### Beta\n", encoding="utf-8") + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "toc", "--dry-run", str(doc)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["results"][0]["status"], "WOULD_UPDATE") + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + + def test_pdsl_validate_text_json_pass_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "pdsl", "validate", "--text", VALID_PDSL], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + + payload = json.loads(stdout) + self.assertEqual(payload["command"], "pdsl validate") + self.assertTrue(payload["ok"]) + self.assertEqual(payload["summary"]["pass_count"], 1) + self.assertEqual(payload["summary"]["fail_count"], 0) + self.assertEqual(payload["summary"]["error_count"], 0) + self.assertEqual(payload["results"][0]["source"], "") + self.assertEqual(payload["results"][0]["status"], "PASS") + + def test_pdsl_validate_mixed_selectors_is_error_and_non_mutating(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "pdsl", "validate", "--text", VALID_PDSL, "-"], + cwd=root, + stdin=VALID_PDSL, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + + payload = json.loads(stdout) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["summary"]["error_count"], 1) + self.assertEqual(payload["results"][0]["status"], "ERROR") + self.assertEqual(payload["results"][0]["errors"][0]["kind"], "INVOCATION_ERROR") + + def test_generate_resources_is_deprecated_and_non_mutating(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main(["generate-resources"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout, "") + self.assertEqual(after, before) + self.assertIn("WARNING: 'generate-resources' is deprecated.", stderr) + self.assertIn("use 'cfs kit update ' instead", stderr.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_example_kits_e2e.py b/tests/test_cli_example_kits_e2e.py new file mode 100644 index 00000000..e6475f55 --- /dev/null +++ b/tests/test_cli_example_kits_e2e.py @@ -0,0 +1,2851 @@ +"""E2E coverage for technical kit fixtures under ``tests/fixtures/kits``.""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import subprocess +import sys +import tomllib +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch +from urllib.parse import quote + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +FIXTURE_KITS_DIR = Path(__file__).resolve().parent / "fixtures" / "kits" + + +@contextmanager +def _chdir(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _run_main_json(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(["--json", *argv]) + return rc, json.loads(stdout.getvalue()), stderr.getvalue() + + +def _make_cache(root: Path) -> Path: + cache = root / "cache" + for name in ("requirements", "schemas", "workflows", "skills"): + (cache / name).mkdir(parents=True, exist_ok=True) + (cache / name / "README.md").write_text(f"# {name}\n", encoding="utf-8") + (cache / "skills" / "studio").mkdir(parents=True, exist_ok=True) + (cache / "skills" / "studio" / "SKILL.md").write_text( + "---\nname: studio\ndescription: Test Studio skill\n---\n# Studio\n", + encoding="utf-8", + ) + for workflow_name in ("generate", "analyze", "plan", "explore", "workspace"): + (cache / "workflows" / f"{workflow_name}.md").write_text( + ( + "---\n" + "type: workflow\n" + f"name: {workflow_name}\n" + f"description: Test {workflow_name} workflow\n" + "---\n" + f"# {workflow_name.title()}\n" + ), + encoding="utf-8", + ) + for rel in ( + "architecture/specs/traceability.md", + "architecture/specs/CDSL.md", + "architecture/specs/PDSL.md", + "architecture/specs/cli.md", + "architecture/specs/CLISPEC.md", + "architecture/specs/artifacts-registry.md", + "architecture/specs/kit/constraints.md", + "architecture/specs/kit/kit.md", + ): + target = cache / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"# {rel}\n", encoding="utf-8") + (cache / "whatsnew.toml").write_text( + '[whatsnew."v1.0.0"]\nsummary = "Initial"\ndetails = ""\n', + encoding="utf-8", + ) + (cache / "version.toml").write_text( + '[cfs]\nversion = "v1.0.0"\n', + encoding="utf-8", + ) + return cache + + +def _init_project( + root: Path, + cache: Path, + *, + runtime_tracking: str = "ignored", + agent_tracking: str = "ignored", + kit_tracking: str = "ignored", +) -> None: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + with patch("studio.commands.init.CACHE_DIR", cache), patch( + "studio.commands.init._install_default_kit", + return_value={}, + ): + rc, out, stderr = _run_main_json( + [ + "init", + "--project-root", + str(root), + "--install-dir", + ".bootstrap", + "--runtime-tracking", + runtime_tracking, + "--agent-tracking", + agent_tracking, + "--kit-tracking", + kit_tracking, + "--yes", + ], + cwd=root, + ) + assert rc == 0, stderr + assert out["status"] == "PASS", out + + +def _copy_fixture(src_name: str, dst: Path) -> Path: + src = FIXTURE_KITS_DIR / src_name + shutil.copytree(src, dst) + return dst + + +def _read_core(project_root: Path) -> dict: + with (project_root / ".bootstrap" / "config" / "core.toml").open("rb") as fh: + return tomllib.load(fh) + + +def _run_git(repo: Path, *args: str) -> str: + proc = subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + raise AssertionError(proc.stderr or proc.stdout) + return proc.stdout.strip() + + +def _make_git_repo_from_fixture(root: Path, fixture_name: str) -> tuple[Path, str]: + root.mkdir(parents=True, exist_ok=True) + repo = root / "repo" + _copy_fixture(fixture_name, repo) + _run_git(repo, "init", "-q") + _run_git(repo, "config", "user.email", "test@example.com") + _run_git(repo, "config", "user.name", "Test User") + _run_git(repo, "add", ".") + _run_git(repo, "commit", "-q", "-m", "initial") + return repo, _run_git(repo, "rev-parse", "HEAD") + + +def _make_subdir_git_repo_from_fixture(root: Path, fixture_name: str, *, subdir: str) -> tuple[Path, str]: + root.mkdir(parents=True, exist_ok=True) + repo = root / "repo" + kit_dir = repo / subdir + kit_dir.parent.mkdir(parents=True, exist_ok=True) + _copy_fixture(fixture_name, kit_dir) + _run_git(repo, "init", "-q") + _run_git(repo, "config", "user.email", "test@example.com") + _run_git(repo, "config", "user.name", "Test User") + _run_git(repo, "add", ".") + _run_git(repo, "commit", "-q", "-m", "initial") + return repo, _run_git(repo, "rev-parse", "HEAD") + + +def _make_multi_canonical_git_repo(root: Path) -> tuple[Path, str]: + root.mkdir(parents=True, exist_ok=True) + repo = root / "repo" + repo.mkdir(parents=True, exist_ok=True) + (repo / "alpha.md").write_text("# Alpha v1\n", encoding="utf-8") + (repo / "beta.md").write_text("# Beta v1\n", encoding="utf-8") + (repo / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + 'slug = "alpha"', + 'name = "Alpha"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "alpha.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + "", + "[[kits]]", + 'slug = "beta"', + 'name = "Beta"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "beta.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + ]) + "\n", + encoding="utf-8", + ) + _run_git(repo, "init", "-q") + _run_git(repo, "config", "user.email", "test@example.com") + _run_git(repo, "config", "user.name", "Test User") + _run_git(repo, "add", ".") + _run_git(repo, "commit", "-q", "-m", "initial") + return repo, _run_git(repo, "rev-parse", "HEAD") + + +def _make_simple_canonical_kit(root: Path, slug: str = "canon-interactive") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "SKILL.md").write_text( + "---\nname: skill\ndescription: Canonical kit\n---\n# Canonical kit\n", + encoding="utf-8", + ) + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + f'name = "{slug}"', + 'version = "1.2.3"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "SKILL.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + "public = true", + ]) + "\n", + encoding="utf-8", + ) + return kit_src + + +def _make_multi_canonical_local_kit(root: Path, slug: str = "multi-local") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "alpha.md").write_text("# Alpha v1\n", encoding="utf-8") + (kit_src / "beta.md").write_text("# Beta v1\n", encoding="utf-8") + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + 'slug = "alpha"', + 'name = "Alpha"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "alpha.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + "", + "[[kits]]", + 'slug = "beta"', + 'name = "Beta"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "beta.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + ]) + "\n", + encoding="utf-8", + ) + return kit_src + + +def _make_two_file_canonical_kit(root: Path, slug: str = "multi-update") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "SKILL.md").write_text("# Skill v1\n", encoding="utf-8") + (kit_src / "guide.md").write_text("# Guide v1\n", encoding="utf-8") + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + f'name = "{slug}"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "SKILL.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + "public = true", + "", + "[[kits.resources]]", + 'id = "guide"', + 'kind = "other"', + 'source = "guide.md"', + 'install_path = "guide.md"', + 'type = "file"', + ]) + "\n", + encoding="utf-8", + ) + return kit_src + + +def _make_prune_canonical_kit(root: Path, slug: str = "prune-local") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "keep.md").write_text("# Keep\n", encoding="utf-8") + (kit_src / "remove.md").write_text("# Remove\n", encoding="utf-8") + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + f'name = "{slug}"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "keep"', + 'kind = "other"', + 'source = "keep.md"', + 'install_path = "keep.md"', + 'type = "file"', + "", + "[[kits.resources]]", + 'id = "remove"', + 'kind = "other"', + 'source = "remove.md"', + 'install_path = "remove.md"', + 'type = "file"', + ]) + "\n", + encoding="utf-8", + ) + return kit_src + + +def _make_public_skill_kit( + root: Path, + *, + slug: str, + generated_name: str, + prefix_generated_name: bool = False, +) -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "SKILL.md").write_text( + f"---\nname: {slug}\ndescription: {slug} skill\n---\n# {slug}\n", + encoding="utf-8", + ) + lines = [ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + f'name = "{slug}"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "SKILL.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + "public = true", + f'generated_name = "{generated_name}"', + ] + if not prefix_generated_name: + lines.append("prefix_generated_name = false") + (kit_src / ".cf-studio-kit.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + return kit_src + + +def _make_duplicate_public_component_kit(root: Path, slug: str = "conflict-kit") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "first.md").write_text("---\nname: first\ndescription: First\n---\n# First\n", encoding="utf-8") + (kit_src / "second.md").write_text("---\nname: second\ndescription: Second\n---\n# Second\n", encoding="utf-8") + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + f'name = "{slug}"', + 'version = "1.0.0"', + "", + "[[kits.resources]]", + 'id = "first"', + 'kind = "skill"', + 'source = "first.md"', + 'install_path = "first.md"', + 'type = "file"', + "public = true", + 'generated_name = "shared-public-skill"', + "prefix_generated_name = false", + "", + "[[kits.resources]]", + 'id = "second"', + 'kind = "skill"', + 'source = "second.md"', + 'install_path = "second.md"', + 'type = "file"', + "public = true", + 'generated_name = "shared-public-skill"', + "prefix_generated_name = false", + ]) + "\n", + encoding="utf-8", + ) + return kit_src + + +def _run_main_json_with_stdin(argv: list[str], *, cwd: Path, stdin_text: str) -> tuple[int, dict, str]: + stdout = io.StringIO() + stderr = io.StringIO() + stdin = io.StringIO(stdin_text) + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr), patch("sys.stdin", stdin), patch( + "sys.stdin.isatty", + return_value=True, + ): + rc = main(["--json", *argv]) + return rc, json.loads(stdout.getvalue()), stderr.getvalue() + + +class TestCliExampleKitsE2E(unittest.TestCase): + def _assert_shared_provider_outputs(self, project_root: Path, gitignore_text: str) -> None: + claude_skill = project_root / ".claude" / "skills" / "cf" / "SKILL.md" + cursor_command = project_root / ".cursor" / "commands" / "cf.md" + copilot_prompt = project_root / ".github" / "prompts" / "cf.prompt.md" + windsurf_workflow = project_root / ".windsurf" / "workflows" / "cf.md" + + self.assertIn(".claude/skills/cf/SKILL.md", gitignore_text) + self.assertIn(".cursor/commands/cf.md", gitignore_text) + self.assertIn(".github/prompts/cf.prompt.md", gitignore_text) + self.assertIn(".windsurf/workflows/cf.md", gitignore_text) + + self.assertTrue(claude_skill.is_file()) + self.assertTrue(cursor_command.is_file()) + self.assertTrue(copilot_prompt.is_file()) + self.assertTrue(windsurf_workflow.is_file()) + + self.assertIn('name: cf', claude_skill.read_text(encoding="utf-8")) + self.assertIn("# /cf", cursor_command.read_text(encoding="utf-8")) + self.assertIn('name: studio', copilot_prompt.read_text(encoding="utf-8")) + self.assertIn("# /cf", windsurf_workflow.read_text(encoding="utf-8")) + + def _assert_legacy_provider_agent_outputs( + self, + project_root: Path, + gitignore_text: str, + *, + prompt_source: str, + ) -> None: + claude_agent = project_root / ".claude" / "agents" / "example-legacy-reviewer.md" + cursor_agent = project_root / ".cursor" / "agents" / "example-legacy-reviewer.md" + copilot_agent = project_root / ".github" / "agents" / "example-legacy-reviewer.agent.md" + openai_agent = project_root / ".codex" / "agents" / "example-legacy-reviewer.toml" + + self.assertIn(".claude/agents/example-legacy-reviewer.md", gitignore_text) + self.assertIn(".cursor/agents/example-legacy-reviewer.md", gitignore_text) + self.assertIn(".github/agents/example-legacy-reviewer.agent.md", gitignore_text) + self.assertIn(".codex/agents/example-legacy-reviewer.toml", gitignore_text) + + self.assertTrue(claude_agent.is_file()) + self.assertTrue(cursor_agent.is_file()) + self.assertTrue(copilot_agent.is_file()) + self.assertTrue(openai_agent.is_file()) + + self.assertIn("Example legacy reviewer", claude_agent.read_text(encoding="utf-8")) + self.assertIn(prompt_source, claude_agent.read_text(encoding="utf-8")) + self.assertIn("Example legacy reviewer", cursor_agent.read_text(encoding="utf-8")) + self.assertIn("readonly: true", cursor_agent.read_text(encoding="utf-8")) + self.assertIn("Example legacy reviewer", copilot_agent.read_text(encoding="utf-8")) + self.assertIn(prompt_source, copilot_agent.read_text(encoding="utf-8")) + self.assertIn('name = "example-legacy-reviewer"', openai_agent.read_text(encoding="utf-8")) + self.assertIn(prompt_source, openai_agent.read_text(encoding="utf-8")) + + def _assert_no_non_openai_public_agent_outputs(self, project_root: Path, slug: str, gitignore_text: str) -> None: + for rel in ( + f".claude/agents/cf-{slug}-reviewer.md", + f".claude/agents/cf-{slug}-reviewer-helper.md", + f".cursor/agents/cf-{slug}-reviewer.mdc", + f".cursor/agents/cf-{slug}-reviewer-helper.mdc", + f".github/agents/cf-{slug}-reviewer.agent.md", + f".github/agents/cf-{slug}-reviewer-helper.agent.md", + ): + self.assertNotIn(rel, gitignore_text) + self.assertFalse((project_root / rel).exists()) + + def test_example_legacy_kit_normalize_dry_run_reports_canonical_manifest_without_writing(self): + with TemporaryDirectory() as td: + root = Path(td) + legacy = _copy_fixture("example-legacy", root / "example-legacy") + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(legacy), "--dry-run"], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["action"], "normalized") + self.assertTrue(out["dry_run"]) + self.assertEqual(out["kit"], "example-legacy") + self.assertEqual(out["kits_normalized"], 1) + self.assertEqual(out["report"]["manifest_source"], "legacy_manifest") + self.assertEqual(out["report"]["resources"], 3) + self.assertIn("feature_template", out["manifest"]) + self.assertIn('manifest_version = "1.0"', out["manifest"]) + self.assertFalse((legacy / ".cf-studio-kit.toml").exists()) + + def test_example_mixed_kit_normalize_stdout_emits_manifest_only_and_writes_nothing(self): + with TemporaryDirectory() as td: + root = Path(td) + mixed = _copy_fixture("example-mixed", root / "example-mixed") + before_snapshot = { + path.relative_to(mixed).as_posix(): path.read_bytes() + for path in sorted(mixed.rglob("*")) + if path.is_file() + } + + stdout = io.StringIO() + stderr = io.StringIO() + with _chdir(root), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(["kit", "normalize", str(mixed), "--stdout"]) + + self.assertEqual(rc, 0, stderr.getvalue()) + manifest_text = stdout.getvalue() + self.assertEqual(stderr.getvalue(), "") + self.assertIn('manifest_version = "1.0"', manifest_text) + self.assertIn('slug = "example-mixed"', manifest_text) + self.assertIn('id = "reviewer"', manifest_text) + self.assertIn('generated_targets = ["openai"]', manifest_text) + self.assertEqual( + { + path.relative_to(mixed).as_posix(): path.read_bytes() + for path in sorted(mixed.rglob("*")) + if path.is_file() + }, + before_snapshot, + ) + + def test_example_mixed_validate_kits_fails_for_invalid_constraints_toml(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + constraints_path = project_root / ".bootstrap" / "config" / "kits" / "example-mixed" / "constraints.toml" + constraints_path.write_text("not toml = [", encoding="utf-8") + + rc, out, stderr = _run_main_json(["validate-kits"], cwd=project_root) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertEqual(out["kits_validated"], 1) + self.assertEqual(out["error_count"], 1) + self.assertEqual(out["failed_kits"][0]["kit"], "example-mixed") + self.assertEqual(out["errors"][0]["type"], "constraints") + self.assertIn("Invalid constraints", out["errors"][0]["message"]) + self.assertIn("Failed to parse constraints.toml", out["errors"][0]["errors"][0]) + self.assertEqual(constraints_path.read_text(encoding="utf-8"), "not toml = [") + + def test_example_mixed_validate_kits_fails_for_missing_bound_resource_path(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + discovery_skill = ( + project_root / ".bootstrap" / "config" / "kits" / "example-mixed" / "skills" / "discovery" / "SKILL.md" + ) + discovery_skill.unlink() + + rc, out, stderr = _run_main_json(["validate-kits"], cwd=project_root) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertEqual(out["kits_validated"], 1) + self.assertEqual(out["failed_kits"][0]["kit"], "example-mixed") + self.assertEqual(out["errors"][0]["type"], "resources") + self.assertIn("Resource 'discovery' path not found", out["errors"][0]["message"]) + self.assertFalse(discovery_skill.exists()) + + def test_kit_normalize_multi_kit_dry_run_can_select_specific_kit_in_e2e(self): + with TemporaryDirectory() as td: + root = Path(td) + multi = _make_multi_canonical_local_kit(root) + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(multi), "--dry-run", "--kit", "beta"], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kit"], "beta") + self.assertEqual(out["kits"], ["beta"]) + self.assertEqual(out["kits_normalized"], 1) + self.assertIn('slug = "beta"', out["manifest"]) + self.assertNotIn('slug = "alpha"', out["manifest"]) + self.assertFalse((multi / ".cf-studio-kit.toml").read_text(encoding="utf-8") == out["manifest"]) + + def test_kit_normalize_multi_kit_subset_write_refusal_is_e2e_visible(self): + with TemporaryDirectory() as td: + root = Path(td) + multi = _make_multi_canonical_local_kit(root) + before = (multi / ".cf-studio-kit.toml").read_text(encoding="utf-8") + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(multi), "--kit", "alpha"], + cwd=root, + ) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertIn("Refusing to overwrite the source multi-kit manifest", out["message"]) + self.assertEqual((multi / ".cf-studio-kit.toml").read_text(encoding="utf-8"), before) + + def test_kit_normalize_multi_kit_writes_full_manifest_when_all_selected(self): + with TemporaryDirectory() as td: + root = Path(td) + multi = _make_multi_canonical_local_kit(root) + manifest_path = multi / ".cf-studio-kit.toml" + manifest_path.write_text( + "# temporary comment to be normalized away\n" + manifest_path.read_text(encoding="utf-8"), + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(multi), "--kit", "all"], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_normalized"], 2) + manifest_text = manifest_path.read_text(encoding="utf-8") + self.assertNotIn("temporary comment", manifest_text) + self.assertIn('slug = "alpha"', manifest_text) + self.assertIn('slug = "beta"', manifest_text) + self.assertIn('source = "alpha.md"', manifest_text) + self.assertIn('source = "beta.md"', manifest_text) + + def test_kit_normalize_multi_kit_unknown_selector_fails_cleanly(self): + with TemporaryDirectory() as td: + root = Path(td) + multi = _make_multi_canonical_local_kit(root) + before = (multi / ".cf-studio-kit.toml").read_text(encoding="utf-8") + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(multi), "--kit", "gamma"], + cwd=root, + ) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertIn("Unknown kit selection: gamma", out["message"]) + self.assertIn("alpha", out["message"]) + self.assertIn("beta", out["message"]) + self.assertEqual((multi / ".cf-studio-kit.toml").read_text(encoding="utf-8"), before) + + def test_kit_normalize_multi_kit_stdout_emits_selected_subset_without_writing(self): + with TemporaryDirectory() as td: + root = Path(td) + multi = _make_multi_canonical_local_kit(root) + before = (multi / ".cf-studio-kit.toml").read_text(encoding="utf-8") + + stdout = io.StringIO() + stderr = io.StringIO() + with _chdir(root), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(["kit", "normalize", str(multi), "--stdout", "--kit", "beta"]) + + self.assertEqual(rc, 0, stderr.getvalue()) + self.assertEqual(stderr.getvalue(), "") + manifest_text = stdout.getvalue() + self.assertIn('slug = "beta"', manifest_text) + self.assertNotIn('slug = "alpha"', manifest_text) + self.assertIn('generated_targets = ["installed"]', manifest_text) + self.assertEqual((multi / ".cf-studio-kit.toml").read_text(encoding="utf-8"), before) + + def test_kit_update_interactive_prune_accepts_removed_resource(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_prune_canonical_kit(project_root / "local-kits", "prune-local") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + manifest_path = local_kit / ".cf-studio-kit.toml" + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8").replace( + '\n[[kits.resources]]\nid = "remove"\nkind = "other"\nsource = "remove.md"\ninstall_path = "remove.md"\ntype = "file"\n', + "\n", + ), + encoding="utf-8", + ) + (local_kit / "remove.md").unlink() + + installed_removed = project_root / ".bootstrap" / "config" / "kits" / "prune-local" / "remove.md" + self.assertTrue(installed_removed.is_file()) + + rc, out, stderr = _run_main_json_with_stdin( + ["kit", "update", "--path", str(local_kit), "--force", "--prune"], + cwd=project_root, + stdin_text="a\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertIn("remove.md", result["accepted"]) + self.assertIn("1 removed", stderr) + self.assertIn("remove.md", stderr) + self.assertIn("deleted upstream", stderr) + self.assertIn("Reply with `a`, `d`, `A`, `D`, or `m`.", stderr) + self.assertFalse(installed_removed.exists()) + + def test_kit_update_interactive_multi_file_accept_all_updates_all_files(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_two_file_canonical_kit(project_root / "local-kits", "multi-update") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + (local_kit / "SKILL.md").write_text("# Skill v2\n", encoding="utf-8") + (local_kit / "guide.md").write_text("# Guide v2\n", encoding="utf-8") + + rc, out, stderr = _run_main_json_with_stdin( + ["kit", "update", "--path", str(local_kit), "--force"], + cwd=project_root, + stdin_text="A\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertIn("SKILL.md", result["accepted"]) + self.assertIn("guide.md", result["accepted"]) + self.assertEqual(result["declined"], []) + self.assertIn("Reply with `a`, `d`, `A`, `D`, or `m`.", stderr) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "multi-update" / "SKILL.md").read_text(encoding="utf-8"), + "# Skill v2\n", + ) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "multi-update" / "guide.md").read_text(encoding="utf-8"), + "# Guide v2\n", + ) + + def test_kit_update_interactive_decline_first_file_keeps_fs_and_accepts_second(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_two_file_canonical_kit(project_root / "local-kits", "mixed-decisions") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + (local_kit / "SKILL.md").write_text("# Skill v2\n", encoding="utf-8") + (local_kit / "guide.md").write_text("# Guide v2\n", encoding="utf-8") + + rc, out, stderr = _run_main_json_with_stdin( + ["kit", "update", "--path", str(local_kit), "--force"], + cwd=project_root, + stdin_text="d\na\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "partial") + self.assertEqual(result["accepted"], ["guide.md"]) + self.assertEqual(result["declined"], ["SKILL.md"]) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "mixed-decisions" / "SKILL.md").read_text(encoding="utf-8"), + "# Skill v1\n", + ) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "mixed-decisions" / "guide.md").read_text(encoding="utf-8"), + "# Guide v2\n", + ) + + def test_kit_update_interactive_modify_writes_editor_result_to_fs(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_simple_canonical_kit(project_root / "local-kits", "modify-local") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + (local_kit / "SKILL.md").write_text("# Skill upstream v2\n", encoding="utf-8") + + with patch("studio.utils.diff_engine._open_editor_for_file", return_value=b"# Skill merged\n"): + rc, out, stderr = _run_main_json_with_stdin( + ["kit", "update", "--path", str(local_kit), "--force"], + cwd=project_root, + stdin_text="m\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertEqual(result["accepted"], ["SKILL.md"]) + self.assertEqual(result["declined"], []) + self.assertIn("Reply with `a`, `d`, `A`, `D`, or `m`.", stderr) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "modify-local" / "SKILL.md").read_text(encoding="utf-8"), + "# Skill merged\n", + ) + + def test_kit_update_interactive_decline_all_keeps_all_files_unchanged(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_two_file_canonical_kit(project_root / "local-kits", "decline-all") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + (local_kit / "SKILL.md").write_text("# Skill v2\n", encoding="utf-8") + (local_kit / "guide.md").write_text("# Guide v2\n", encoding="utf-8") + + rc, out, stderr = _run_main_json_with_stdin( + ["kit", "update", "--path", str(local_kit), "--force"], + cwd=project_root, + stdin_text="D\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "partial") + self.assertEqual(result["accepted"], []) + self.assertEqual(result["declined"], ["SKILL.md", "guide.md"]) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "decline-all" / "SKILL.md").read_text(encoding="utf-8"), + "# Skill v1\n", + ) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "decline-all" / "guide.md").read_text(encoding="utf-8"), + "# Guide v1\n", + ) + + def test_kit_update_interactive_accept_then_decline_all_preserves_remaining_files(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_two_file_canonical_kit(project_root / "local-kits", "accept-then-decline") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + (local_kit / "SKILL.md").write_text("# Skill v2\n", encoding="utf-8") + (local_kit / "guide.md").write_text("# Guide v2\n", encoding="utf-8") + + rc, out, stderr = _run_main_json_with_stdin( + ["kit", "update", "--path", str(local_kit), "--force"], + cwd=project_root, + stdin_text="a\nD\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "partial") + self.assertEqual(result["accepted"], ["SKILL.md"]) + self.assertEqual(result["declined"], ["guide.md"]) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "accept-then-decline" / "SKILL.md").read_text(encoding="utf-8"), + "# Skill v2\n", + ) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "accept-then-decline" / "guide.md").read_text(encoding="utf-8"), + "# Guide v1\n", + ) + + def test_kit_update_interactive_prune_decline_keeps_existing_file(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_prune_canonical_kit(project_root / "local-kits", "prune-decline") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + manifest_path = local_kit / ".cf-studio-kit.toml" + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8").replace( + '\n[[kits.resources]]\nid = "remove"\nkind = "other"\nsource = "remove.md"\ninstall_path = "remove.md"\ntype = "file"\n', + "\n", + ), + encoding="utf-8", + ) + (local_kit / "remove.md").unlink() + + installed_removed = project_root / ".bootstrap" / "config" / "kits" / "prune-decline" / "remove.md" + self.assertTrue(installed_removed.is_file()) + + rc, out, stderr = _run_main_json_with_stdin( + ["kit", "update", "--path", str(local_kit), "--force", "--prune"], + cwd=project_root, + stdin_text="d\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "partial") + self.assertEqual(result["accepted"], []) + self.assertEqual(result["declined"], ["remove.md"]) + self.assertIn("deleted upstream", stderr) + self.assertTrue(installed_removed.is_file()) + self.assertEqual(installed_removed.read_text(encoding="utf-8"), "# Remove\n") + + def test_kit_validate_public_command_runs_end_to_end_for_installed_example_kit(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + rc, out, stderr = _run_main_json(["kit", "validate"], cwd=project_root) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_validated"], 1) + self.assertEqual(out["templates_checked"], 1) + self.assertEqual(out["error_count"], 0) + + def test_kit_normalize_writes_canonical_manifest_for_legacy_fixture(self): + with TemporaryDirectory() as td: + root = Path(td) + legacy = _copy_fixture("example-legacy", root / "example-legacy") + manifest_path = legacy / ".cf-studio-kit.toml" + + self.assertFalse(manifest_path.exists()) + before_manifest = (legacy / "manifest.toml").read_text(encoding="utf-8") + before_conf = (legacy / "conf.toml").read_text(encoding="utf-8") + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(legacy)], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["action"], "normalized") + self.assertEqual(out["kit"], "example-legacy") + self.assertTrue(manifest_path.is_file()) + manifest_text = manifest_path.read_text(encoding="utf-8") + self.assertIn('manifest_version = "1.0"', manifest_text) + self.assertIn('slug = "example-legacy"', manifest_text) + self.assertIn('id = "feature_template"', manifest_text) + self.assertIn('id = "feature_example"', manifest_text) + self.assertIn('id = "constraints"', manifest_text) + self.assertNotIn('id = "reviewer"', manifest_text) + self.assertEqual((legacy / "manifest.toml").read_text(encoding="utf-8"), before_manifest) + self.assertEqual((legacy / "conf.toml").read_text(encoding="utf-8"), before_conf) + + def test_manifest_backed_update_refuses_when_source_manifest_loses_all_resources(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + installed_template = ( + project_root / ".bootstrap" / "config" / "kits" / "example-mixed" / "artifacts" / "ADR" / "template.md" + ) + before_installed = installed_template.read_text(encoding="utf-8") + (local_kit / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + 'slug = "example-mixed"', + 'name = "Example Mixed"', + 'version = "2.1.0"', + ]) + "\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--force", "--no-interactive", "-y"], + cwd=project_root, + ) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + result = out["results"][0] + self.assertEqual(result["action"], "failed") + self.assertIn("could not resolve resource bindings", result["errors"][0]) + self.assertIn("refusing to treat all files as deleted upstream", result["errors"][0]) + self.assertEqual(installed_template.read_text(encoding="utf-8"), before_installed) + + def test_example_v2_copy_install_and_generate_agents_asserts_fs_and_gitignore(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + kit_src = FIXTURE_KITS_DIR / "example-v2" + _init_project(project_root, cache) + + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["install_mode"], "copy") + + installed_root = project_root / ".bootstrap" / "config" / "kits" / "example-v2" + self.assertTrue((installed_root / "artifacts" / "PRD" / "template.md").is_file()) + self.assertTrue((installed_root / "artifacts" / "PRD" / "example.md").is_file()) + self.assertTrue((installed_root / "artifacts" / "FEATURE" / "template.md").is_file()) + self.assertTrue((installed_root / "artifacts" / "FEATURE" / "example.md").is_file()) + self.assertTrue((installed_root / "constraints.toml").is_file()) + self.assertTrue((installed_root / "skills" / "discovery" / "SKILL.md").is_file()) + self.assertTrue((installed_root / "skills" / "review" / "SKILL.md").is_file()) + self.assertTrue((installed_root / "agents" / "reviewer.md").is_file()) + self.assertTrue((installed_root / "agents" / "planner.md").is_file()) + self.assertFalse((installed_root / ".cf-studio-kit.toml").exists()) + self.assertEqual( + (installed_root / "constraints.toml").read_text(encoding="utf-8"), + "[PRD.identifiers.cpt]\nrequired = true\n\n[PRD.identifiers.fr]\nrequired = true\n\n[FEATURE.identifiers.cpt]\nrequired = true\n\n[FEATURE.identifiers.flow]\nrequired = true\n", + ) + self.assertIn( + "Example V2 PRD Template", + (installed_root / "artifacts" / "PRD" / "template.md").read_text(encoding="utf-8"), + ) + self.assertIn( + "@cpt-template:cpt-example-v2-prd-template:p1", + (installed_root / "artifacts" / "PRD" / "template.md").read_text(encoding="utf-8"), + ) + self.assertIn( + "cpt-example-v2-feature-flow", + (installed_root / "artifacts" / "FEATURE" / "example.md").read_text(encoding="utf-8"), + ) + + core = _read_core(project_root) + entry = core["kits"]["example-v2"] + self.assertEqual(entry["install_mode"], "copy") + self.assertEqual(entry["path"], "config/kits/example-v2") + self.assertEqual(entry["resources"]["discovery"]["path"], "config/kits/example-v2/skills/discovery/SKILL.md") + self.assertEqual(entry["resources"]["review"]["path"], "config/kits/example-v2/skills/review/SKILL.md") + self.assertEqual(entry["resources"]["reviewer"]["mode"], "readonly") + self.assertEqual(entry["resources"]["reviewer"]["subagents"][0]["id"], "reviewer-helper") + self.assertEqual(entry["resources"]["planner"]["subagents"][0]["id"], "planner-helper") + + rc, out, stderr = _run_main_json( + [ + "generate-agents", + "--root", + str(project_root), + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".bootstrap/.core/", gitignore_text) + self.assertIn(".bootstrap/.gen/", gitignore_text) + self.assertIn(".bootstrap/config/kits/example-v2/", gitignore_text) + self.assertIn(".agents/skills/cf-example-v2-discovery/SKILL.md", gitignore_text) + self.assertIn(".agents/skills/cf-example-v2-review/SKILL.md", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-reviewer.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-reviewer-helper.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-planner.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-planner-helper.toml", gitignore_text) + self.assertNotIn(".bootstrap/config/kits/\n", gitignore_text) + self._assert_shared_provider_outputs(project_root, gitignore_text) + self._assert_no_non_openai_public_agent_outputs(project_root, "example-v2", gitignore_text) + + generated_discovery = project_root / ".agents" / "skills" / "cf-example-v2-discovery" / "SKILL.md" + generated_review = project_root / ".agents" / "skills" / "cf-example-v2-review" / "SKILL.md" + self.assertTrue(generated_discovery.is_file()) + self.assertTrue(generated_review.is_file()) + self.assertIn( + "{cf-studio-path}/config/kits/example-v2/skills/discovery/SKILL.md", + generated_discovery.read_text(encoding="utf-8"), + ) + self.assertIn( + "{cf-studio-path}/config/kits/example-v2/skills/review/SKILL.md", + generated_review.read_text(encoding="utf-8"), + ) + + reviewer_agent = project_root / ".codex" / "agents" / "cf-example-v2-reviewer.toml" + reviewer_helper = project_root / ".codex" / "agents" / "cf-example-v2-reviewer-helper.toml" + planner_agent = project_root / ".codex" / "agents" / "cf-example-v2-planner.toml" + planner_helper = project_root / ".codex" / "agents" / "cf-example-v2-planner-helper.toml" + self.assertTrue(reviewer_agent.is_file()) + self.assertTrue(reviewer_helper.is_file()) + self.assertTrue(planner_agent.is_file()) + self.assertTrue(planner_helper.is_file()) + self.assertIn('name = "cf-example-v2-reviewer"', reviewer_agent.read_text(encoding="utf-8")) + self.assertIn('sandbox_mode = "read-only"', reviewer_agent.read_text(encoding="utf-8")) + self.assertIn("# Example V2 Reviewer", reviewer_agent.read_text(encoding="utf-8")) + self.assertIn("edge cases for the reviewer", reviewer_helper.read_text(encoding="utf-8")) + self.assertIn("# Example V2 Planner", planner_agent.read_text(encoding="utf-8")) + self.assertIn("missing context for the planner", planner_helper.read_text(encoding="utf-8")) + + def test_example_v2_register_install_keeps_source_in_place_and_generates_from_registered_paths(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", project_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(local_kit), + "--install-mode", + "register", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["install_mode"], "register") + self.assertEqual(out["files_written"], 0) + self.assertEqual(out["files_registered"], 9) + + core = _read_core(project_root) + entry = core["kits"]["example-v2"] + self.assertEqual(entry["install_mode"], "register") + self.assertEqual(entry["path"], "../local-kits/example-v2") + self.assertNotIn("resources", entry) + self.assertEqual(entry["source_provenance"]["source_type"], "local_path") + self.assertEqual(entry["source_provenance"]["resolver_mode"], "register") + self.assertEqual( + entry["source_provenance"]["effective_source"], + "../local-kits/example-v2", + ) + self.assertFalse((project_root / ".bootstrap" / "config" / "kits" / "example-v2").exists()) + + rc, out, stderr = _run_main_json( + [ + "generate-agents", + "--root", + str(project_root), + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn("local-kits/example-v2/", gitignore_text) + self.assertIn(".agents/skills/cf-example-v2-discovery/SKILL.md", gitignore_text) + self.assertIn(".agents/skills/cf-example-v2-review/SKILL.md", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-reviewer.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-reviewer-helper.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-planner.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-planner-helper.toml", gitignore_text) + self.assertNotIn(".bootstrap/../local-kits/example-v2/", gitignore_text) + self.assertNotIn(".bootstrap/config/kits/example-v2/", gitignore_text) + self._assert_shared_provider_outputs(project_root, gitignore_text) + self._assert_no_non_openai_public_agent_outputs(project_root, "example-v2", gitignore_text) + + generated_discovery = project_root / ".agents" / "skills" / "cf-example-v2-discovery" / "SKILL.md" + generated_review = project_root / ".agents" / "skills" / "cf-example-v2-review" / "SKILL.md" + self.assertTrue(generated_discovery.is_file()) + self.assertTrue(generated_review.is_file()) + self.assertIn( + "@/local-kits/example-v2/skills/discovery/SKILL.md", + generated_discovery.read_text(encoding="utf-8"), + ) + self.assertIn( + "@/local-kits/example-v2/skills/review/SKILL.md", + generated_review.read_text(encoding="utf-8"), + ) + reviewer_agent = project_root / ".codex" / "agents" / "cf-example-v2-reviewer.toml" + reviewer_helper = project_root / ".codex" / "agents" / "cf-example-v2-reviewer-helper.toml" + planner_agent = project_root / ".codex" / "agents" / "cf-example-v2-planner.toml" + planner_helper = project_root / ".codex" / "agents" / "cf-example-v2-planner-helper.toml" + self.assertTrue(reviewer_agent.is_file()) + self.assertTrue(reviewer_helper.is_file()) + self.assertTrue(planner_agent.is_file()) + self.assertTrue(planner_helper.is_file()) + self.assertIn('name = "cf-example-v2-reviewer"', reviewer_agent.read_text(encoding="utf-8")) + self.assertIn("# Example V2 Reviewer", reviewer_agent.read_text(encoding="utf-8")) + self.assertIn("Reviewer helper", reviewer_helper.read_text(encoding="utf-8")) + self.assertIn("# Example V2 Planner", planner_agent.read_text(encoding="utf-8")) + self.assertIn("missing context for the planner", planner_helper.read_text(encoding="utf-8")) + self.assertTrue((local_kit / "skills" / "discovery" / "SKILL.md").is_file()) + self.assertTrue((local_kit / "agents" / "planner-helper.md").is_file()) + + def test_example_mixed_github_install_uses_canonical_manifest_and_ignores_legacy_metadata_files(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + + github_tmp = temp_root / "github-source" + kit_src = _copy_fixture("example-mixed", github_tmp / "example-mixed") + authority = { + "source_type": "github", + "canonical_source": "github:acme/example-mixed", + "effective_source": "github:acme/example-mixed", + "resolved_ref": "v2.1.0", + "freshness": "fresh", + } + + with patch( + "studio.commands.kit._download_kit_from_github_with_authority", + return_value=(kit_src, "v2.1.0", authority), + ): + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "acme/example-mixed", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["install_mode"], "copy") + self.assertEqual(out["source"], "github:acme/example-mixed") + + installed_root = project_root / ".bootstrap" / "config" / "kits" / "example-mixed" + self.assertTrue((installed_root / "artifacts" / "ADR" / "template.md").is_file()) + self.assertTrue((installed_root / "artifacts" / "ADR" / "example.md").is_file()) + self.assertTrue((installed_root / "skills" / "discovery" / "SKILL.md").is_file()) + self.assertTrue((installed_root / "skills" / "review" / "SKILL.md").is_file()) + self.assertTrue((installed_root / "agents" / "reviewer.md").is_file()) + self.assertTrue((installed_root / "agents" / "planner.md").is_file()) + self.assertFalse((installed_root / ".cf-studio-kit.toml").exists()) + self.assertFalse((installed_root / "manifest.toml").exists()) + self.assertFalse((installed_root / "core.toml").exists()) + self.assertEqual( + (installed_root / "constraints.toml").read_text(encoding="utf-8"), + "[ADR.identifiers.cpt]\nrequired = true\n\n[ADR.identifiers.adr]\nrequired = true\n", + ) + self.assertIn( + "@cpt-template:cpt-example-mixed-adr-template:p1", + (installed_root / "artifacts" / "ADR" / "template.md").read_text(encoding="utf-8"), + ) + + core = _read_core(project_root) + entry = core["kits"]["example-mixed"] + self.assertEqual(entry["install_mode"], "copy") + self.assertEqual(entry["source"], "github:acme/example-mixed") + self.assertEqual(entry["resources"]["discovery"]["path"], "config/kits/example-mixed/skills/discovery/SKILL.md") + self.assertEqual(entry["resources"]["review"]["path"], "config/kits/example-mixed/skills/review/SKILL.md") + self.assertEqual(entry["resources"]["reviewer"]["path"], "config/kits/example-mixed/agents/reviewer.md") + self.assertEqual(entry["resources"]["planner"]["path"], "config/kits/example-mixed/agents/planner.md") + self.assertEqual(entry["resources"]["reviewer"]["subagents"][0]["id"], "reviewer-helper") + self.assertEqual(entry["resources"]["planner"]["subagents"][0]["id"], "planner-helper") + + rc, out, stderr = _run_main_json( + [ + "generate-agents", + "--root", + str(project_root), + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".bootstrap/config/kits/example-mixed/", gitignore_text) + self.assertIn(".agents/skills/cf-example-mixed-discovery/SKILL.md", gitignore_text) + self.assertIn(".agents/skills/cf-example-mixed-review/SKILL.md", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer-helper.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-planner.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-planner-helper.toml", gitignore_text) + self._assert_shared_provider_outputs(project_root, gitignore_text) + self._assert_no_non_openai_public_agent_outputs(project_root, "example-mixed", gitignore_text) + + generated_discovery = project_root / ".agents" / "skills" / "cf-example-mixed-discovery" / "SKILL.md" + generated_review = project_root / ".agents" / "skills" / "cf-example-mixed-review" / "SKILL.md" + self.assertTrue(generated_discovery.is_file()) + self.assertTrue(generated_review.is_file()) + self.assertIn( + "{cf-studio-path}/config/kits/example-mixed/skills/discovery/SKILL.md", + generated_discovery.read_text(encoding="utf-8"), + ) + self.assertIn( + "{cf-studio-path}/config/kits/example-mixed/skills/review/SKILL.md", + generated_review.read_text(encoding="utf-8"), + ) + reviewer_agent = project_root / ".codex" / "agents" / "cf-example-mixed-reviewer.toml" + reviewer_helper = project_root / ".codex" / "agents" / "cf-example-mixed-reviewer-helper.toml" + planner_agent = project_root / ".codex" / "agents" / "cf-example-mixed-planner.toml" + planner_helper = project_root / ".codex" / "agents" / "cf-example-mixed-planner-helper.toml" + self.assertTrue(reviewer_agent.is_file()) + self.assertTrue(reviewer_helper.is_file()) + self.assertTrue(planner_agent.is_file()) + self.assertTrue(planner_helper.is_file()) + self.assertIn("# Example Mixed Reviewer", reviewer_agent.read_text(encoding="utf-8")) + self.assertIn("legacy-vs-canonical deltas", reviewer_helper.read_text(encoding="utf-8")) + self.assertIn("# Example Mixed Planner", planner_agent.read_text(encoding="utf-8")) + self.assertIn("prerequisites for the mixed planner", planner_helper.read_text(encoding="utf-8")) + + def test_example_legacy_git_install_generates_proxy_agents_and_records_git_provenance(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + repo, commit_sha = _make_git_repo_from_fixture(temp_root / "fixture-root", "example-legacy") + source = "git/" + quote(repo.as_uri(), safe="") + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + source, + "--version", + "HEAD", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kit"], "example-legacy") + self.assertEqual(out["install_mode"], "copy") + + installed_root = project_root / ".bootstrap" / "config" / "kits" / "example-legacy" + self.assertTrue((installed_root / "artifacts" / "FEATURE" / "template.md").is_file()) + self.assertTrue((installed_root / "artifacts" / "FEATURE" / "example.md").is_file()) + self.assertTrue((installed_root / "constraints.toml").is_file()) + self.assertFalse((installed_root / "agents.toml").exists()) + self.assertFalse((installed_root / "core.toml").exists()) + self.assertIn( + "@cpt-template:cpt-example-legacy-feature-template:p1", + (installed_root / "artifacts" / "FEATURE" / "template.md").read_text(encoding="utf-8"), + ) + self.assertIn( + "cpt-example-legacy-feature-flow", + (installed_root / "artifacts" / "FEATURE" / "example.md").read_text(encoding="utf-8"), + ) + self.assertEqual( + (installed_root / "constraints.toml").read_text(encoding="utf-8"), + "[FEATURE.identifiers.cpt]\nrequired = true\n\n[FEATURE.identifiers.flow]\nrequired = true\n", + ) + + core = _read_core(project_root) + entry = core["kits"]["example-legacy"] + self.assertEqual(entry["install_mode"], "copy") + self.assertTrue(str(entry["source"]).startswith("git:")) + self.assertEqual(entry["source_provenance"]["source_type"], "git") + self.assertEqual(entry["source_provenance"]["requested_ref"], "HEAD") + self.assertEqual(entry["source_provenance"]["commit_sha"], commit_sha) + + rc, out, stderr = _run_main_json( + [ + "generate-agents", + "--root", + str(project_root), + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".bootstrap/config/kits/example-legacy/", gitignore_text) + self.assertNotIn(".codex/agents/example-legacy-reviewer.toml", gitignore_text) + self.assertNotIn(".codex/agents/example-legacy-planner.toml", gitignore_text) + self.assertFalse((project_root / ".codex" / "agents" / "example-legacy-reviewer.toml").exists()) + self.assertFalse((project_root / ".codex" / "agents" / "example-legacy-planner.toml").exists()) + self._assert_shared_provider_outputs(project_root, gitignore_text) + + def test_example_mixed_local_copy_install_supports_info_update_validate_and_all_provider_outputs(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(local_kit), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["files_written"], 9) + + rc, out, stderr = _run_main_json(["info", "--root", str(project_root)], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "FOUND") + self.assertEqual(out["kit_models"]["example-mixed"]["install_mode"], "copy") + self.assertEqual(out["kit_details"]["example-mixed"]["resources"]["reviewer"]["mode"], "readonly") + + rc, out, stderr = _run_main_json(["validate-kits"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_validated"], 1) + self.assertEqual(out["templates_checked"], 1) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".bootstrap/config/kits/example-mixed/", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer-helper.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-planner-helper.toml", gitignore_text) + self._assert_shared_provider_outputs(project_root, gitignore_text) + + def test_example_mixed_register_local_install_generates_registered_outputs_and_keeps_source(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(local_kit), + "--install-mode", + "register", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["files_written"], 0) + self.assertEqual(out["files_registered"], 7) + + core = _read_core(project_root) + entry = core["kits"]["example-mixed"] + self.assertEqual(entry["install_mode"], "register") + self.assertEqual(entry["path"], "../local-kits/example-mixed") + self.assertNotIn("resources", entry) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn("local-kits/example-mixed/", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer-helper.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-planner.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-planner-helper.toml", gitignore_text) + self.assertNotIn(".bootstrap/../local-kits/example-mixed/", gitignore_text) + self._assert_shared_provider_outputs(project_root, gitignore_text) + self._assert_no_non_openai_public_agent_outputs(project_root, "example-mixed", gitignore_text) + self.assertTrue((local_kit / "agents" / "reviewer-helper.md").is_file()) + + rc, out, stderr = _run_main_json(["info", "--root", str(project_root)], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "FOUND") + self.assertEqual(out["kit_models"]["example-mixed"]["install_mode"], "register") + self.assertEqual(out["kit_models"]["example-mixed"]["drift"]["status"], "drifted") + self.assertEqual(out["kit_details"]["example-mixed"]["resources"]["reviewer"]["path"], "../local-kits/example-mixed/agents/reviewer.md") + + rc, out, stderr = _run_main_json(["validate-kits"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_validated"], 1) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + def test_example_legacy_local_copy_install_runs_info_update_validate_without_provider_agents(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-legacy", project_root / "local-kits" / "example-legacy") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["files_written"], 3) + + rc, out, stderr = _run_main_json(["info", "--root", str(project_root)], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "FOUND") + self.assertEqual(out["kit_models"]["example-legacy"]["install_mode"], "copy") + + rc, out, stderr = _run_main_json(["validate-kits"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["templates_checked"], 1) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self._assert_shared_provider_outputs(project_root, gitignore_text) + self.assertNotIn(".claude/agents/example-legacy-reviewer.md", gitignore_text) + self.assertNotIn(".cursor/agents/example-legacy-reviewer.md", gitignore_text) + self.assertNotIn(".github/agents/example-legacy-reviewer.agent.md", gitignore_text) + self.assertNotIn(".codex/agents/example-legacy-reviewer.toml", gitignore_text) + + def test_example_legacy_register_local_install_generates_provider_agents_for_all_targets(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-legacy", project_root / "local-kits" / "example-legacy") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "register"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["files_written"], 0) + self.assertEqual(out["files_registered"], 3) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self._assert_shared_provider_outputs(project_root, gitignore_text) + self._assert_legacy_provider_agent_outputs( + project_root, + gitignore_text, + prompt_source="@/local-kits/example-legacy/agents/reviewer.md", + ) + + rc, out, stderr = _run_main_json(["info", "--root", str(project_root)], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "FOUND") + self.assertEqual(out["kit_models"]["example-legacy"]["install_mode"], "register") + self.assertEqual(out["kit_models"]["example-legacy"]["manifest_source"], "legacy_manifest") + + rc, out, stderr = _run_main_json(["validate-kits"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_validated"], 1) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + def test_example_v2_register_supports_info_validate_and_update_current(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", project_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "register"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + rc, out, stderr = _run_main_json(["info", "--root", str(project_root)], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "FOUND") + self.assertEqual(out["kit_models"]["example-v2"]["install_mode"], "register") + self.assertEqual(out["kit_models"]["example-v2"]["drift"]["status"], "drifted") + self.assertEqual(out["kit_details"]["example-v2"]["resources"]["reviewer"]["path"], "../local-kits/example-v2/agents/reviewer.md") + + rc, out, stderr = _run_main_json(["validate-kits"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_validated"], 1) + self.assertEqual(out["templates_checked"], 2) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + def test_example_mixed_interactive_manifest_install_allows_resource_path_override(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json_with_stdin( + [ + "kit", + "install", + "--path", + str(local_kit), + "--install-mode", + "copy", + ], + cwd=project_root, + stdin_text="i\ny\n2\nartifacts/ADR/template-override.md\ny\n9\n", + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + overridden = project_root / ".bootstrap" / "config" / "kits" / "example-mixed" / "artifacts" / "ADR" / "template-override.md" + default_template = project_root / ".bootstrap" / "config" / "kits" / "example-mixed" / "artifacts" / "ADR" / "template.md" + self.assertTrue(overridden.is_file()) + self.assertFalse(default_template.exists()) + self.assertIn("Change kit install paths?", stderr) + + core = _read_core(project_root) + self.assertEqual( + core["kits"]["example-mixed"]["resources"]["adr-template"]["path"], + "config/kits/example-mixed/artifacts/ADR/template-override.md", + ) + + def test_example_v2_reinstall_requires_explicit_overwrite_approval_and_restores_modified_files(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", project_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + installed_template = project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "artifacts" / "PRD" / "template.md" + installed_template.write_text("mutated\n", encoding="utf-8") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertIn("already installed", out["message"]) + self.assertIn("kit update", out["hint"]) + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy", "--force"], + cwd=project_root, + ) + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertIn("approve-overwrite prd-template", out["errors"][0]) + + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(local_kit), + "--install-mode", + "copy", + "--force", + "--approve-overwrite", + "prd-template", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertIn("Example V2 PRD Template", installed_template.read_text(encoding="utf-8")) + + def test_example_mixed_github_install_rejects_register_mode(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + kit_src = _copy_fixture("example-mixed", temp_root / "github-source" / "example-mixed") + authority = { + "source_type": "github", + "canonical_source": "github:acme/example-mixed", + "effective_source": "github:acme/example-mixed", + "resolved_ref": "v2.1.0", + "freshness": "fresh", + } + + with patch( + "studio.commands.kit._download_kit_from_github_with_authority", + return_value=(kit_src, "v2.1.0", authority), + ): + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "acme/example-mixed", + "--install-mode", + "register", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertEqual(out["message"], "--install-mode is only valid with local --path installs") + self.assertIn("Remote GitHub and generic Git installs always copy managed artifacts", out["hint"]) + self.assertFalse((project_root / ".bootstrap" / "config" / "kits" / "example-mixed").exists()) + + def test_example_legacy_generic_git_install_rejects_register_mode(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + repo, _commit_sha = _make_git_repo_from_fixture(temp_root / "fixture-root", "example-legacy") + source = "git/" + quote(repo.as_uri(), safe="") + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + source, + "--version", + "HEAD", + "--install-mode", + "register", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "FAIL") + self.assertEqual(out["message"], "--install-mode is only valid with local --path installs") + self.assertIn("Remote GitHub and generic Git installs always copy managed artifacts", out["hint"]) + self.assertFalse((project_root / ".bootstrap" / "config" / "kits" / "example-legacy").exists()) + + def test_example_legacy_generic_git_copy_update_applies_upstream_drift(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + repo, initial_commit = _make_git_repo_from_fixture(temp_root / "fixture-root", "example-legacy") + source = "git/" + quote(repo.as_uri(), safe="") + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + source, + "--version", + "HEAD", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + installed_example = ( + project_root / ".bootstrap" / "config" / "kits" / "example-legacy" / "artifacts" / "FEATURE" / "example.md" + ) + self.assertIn("cpt-example-legacy-feature-flow", installed_example.read_text(encoding="utf-8")) + + upstream_example = repo / "artifacts" / "FEATURE" / "example.md" + upstream_example.write_text("UPDATED FROM GIT SOURCE\n", encoding="utf-8") + _run_git(repo, "add", "artifacts/FEATURE/example.md") + _run_git(repo, "commit", "-q", "-m", "update feature example") + updated_commit = _run_git(repo, "rev-parse", "HEAD") + self.assertNotEqual(initial_commit, updated_commit) + + rc, out, stderr = _run_main_json( + ["kit", "update", "example-legacy", "--no-interactive", "-y"], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_updated"], 1) + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertEqual(result["accepted"], ["artifacts/FEATURE/example.md"]) + self.assertEqual(result["declined"], []) + self.assertEqual(result["files_written"], 1) + self.assertEqual(result["unchanged"], 2) + self.assertEqual(result["authority"]["commit_sha"], updated_commit) + self.assertEqual(installed_example.read_text(encoding="utf-8"), "UPDATED FROM GIT SOURCE\n") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".bootstrap/config/kits/example-legacy/", gitignore_text) + self._assert_shared_provider_outputs(project_root, gitignore_text) + + def test_example_mixed_register_reads_live_source_after_upstream_edit(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(local_kit), + "--install-mode", + "register", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + reviewer_agent = project_root / ".codex" / "agents" / "cf-example-mixed-reviewer.toml" + initial_text = reviewer_agent.read_text(encoding="utf-8") + self.assertIn("# Example Mixed Reviewer", initial_text) + + upstream_reviewer = local_kit / "agents" / "reviewer.md" + upstream_reviewer.write_text( + upstream_reviewer.read_text(encoding="utf-8") + "\nUpdated upstream body.\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + updated_text = reviewer_agent.read_text(encoding="utf-8") + self.assertIn("Updated upstream body.", updated_text) + self.assertNotEqual(initial_text, updated_text) + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn("local-kits/example-mixed/", gitignore_text) + self.assertNotIn(".bootstrap/../local-kits/example-mixed/", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer.toml", gitignore_text) + + def test_example_mixed_register_regenerate_refreshes_openai_planner_and_skill_outputs(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "register"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + generated_discovery = project_root / ".agents" / "skills" / "cf-example-mixed-discovery" / "SKILL.md" + planner_agent = project_root / ".codex" / "agents" / "cf-example-mixed-planner.toml" + planner_helper = project_root / ".codex" / "agents" / "cf-example-mixed-planner-helper.toml" + before_discovery = generated_discovery.read_text(encoding="utf-8") + before_planner = planner_agent.read_text(encoding="utf-8") + before_helper = planner_helper.read_text(encoding="utf-8") + + (local_kit / "skills" / "discovery" / "SKILL.md").write_text( + ( + "---\nname: discovery\ndescription: Example mixed discovery skill\n---\n" + "# Example Mixed Discovery\nDiscovery wrapper refresh body.\n" + ), + encoding="utf-8", + ) + (local_kit / "agents" / "planner.md").write_text( + ( + "---\nname: planner\ndescription: Example mixed planner agent\n---\n" + "# Example Mixed Planner\nPlanner register refresh body.\n" + ), + encoding="utf-8", + ) + (local_kit / "agents" / "planner-helper.md").write_text( + ( + "---\nname: planner-helper\ndescription: Mixed planner helper subagent\n---\n" + "# Planner helper\nPlanner helper register refresh body.\n" + ), + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + after_discovery = generated_discovery.read_text(encoding="utf-8") + after_planner = planner_agent.read_text(encoding="utf-8") + after_helper = planner_helper.read_text(encoding="utf-8") + self.assertIn("@/local-kits/example-mixed/skills/discovery/SKILL.md", after_discovery) + self.assertNotEqual(before_discovery, after_discovery) + self.assertIn("Planner register refresh body.", after_planner) + self.assertIn("Planner helper register refresh body.", after_helper) + self.assertNotEqual(before_planner, after_planner) + self.assertNotEqual(before_helper, after_helper) + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn("local-kits/example-mixed/", gitignore_text) + self.assertNotIn(".bootstrap/../local-kits/example-mixed/", gitignore_text) + self.assertIn(".agents/skills/cf-example-mixed-discovery/SKILL.md", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-planner.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-planner-helper.toml", gitignore_text) + + def test_example_v2_register_regenerate_refreshes_openai_planner_outputs(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", project_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "register"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + planner_agent = project_root / ".codex" / "agents" / "cf-example-v2-planner.toml" + planner_helper = project_root / ".codex" / "agents" / "cf-example-v2-planner-helper.toml" + before_planner = planner_agent.read_text(encoding="utf-8") + before_helper = planner_helper.read_text(encoding="utf-8") + + upstream_planner = local_kit / "agents" / "planner.md" + upstream_planner.write_text( + upstream_planner.read_text(encoding="utf-8") + "\nPlanner refresh body.\n", + encoding="utf-8", + ) + upstream_helper = local_kit / "agents" / "planner-helper.md" + upstream_helper.write_text( + upstream_helper.read_text(encoding="utf-8") + "\nPlanner helper refresh body.\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + after_planner = planner_agent.read_text(encoding="utf-8") + after_helper = planner_helper.read_text(encoding="utf-8") + self.assertIn("Planner refresh body.", after_planner) + self.assertIn("Planner helper refresh body.", after_helper) + self.assertNotEqual(before_planner, after_planner) + self.assertNotEqual(before_helper, after_helper) + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".codex/agents/cf-example-v2-planner.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-v2-planner-helper.toml", gitignore_text) + + def test_example_legacy_register_regenerate_keeps_all_provider_proxy_agents_stable(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-legacy", project_root / "local-kits" / "example-legacy") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "register"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + claude_agent = project_root / ".claude" / "agents" / "example-legacy-reviewer.md" + cursor_agent = project_root / ".cursor" / "agents" / "example-legacy-reviewer.md" + copilot_agent = project_root / ".github" / "agents" / "example-legacy-reviewer.agent.md" + openai_agent = project_root / ".codex" / "agents" / "example-legacy-reviewer.toml" + before = { + "claude": claude_agent.read_text(encoding="utf-8"), + "cursor": cursor_agent.read_text(encoding="utf-8"), + "copilot": copilot_agent.read_text(encoding="utf-8"), + "openai": openai_agent.read_text(encoding="utf-8"), + } + + upstream_reviewer = local_kit / "agents" / "reviewer.md" + upstream_reviewer.write_text( + upstream_reviewer.read_text(encoding="utf-8") + "\nLegacy provider refresh body.\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "current") + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + after = { + "claude": claude_agent.read_text(encoding="utf-8"), + "cursor": cursor_agent.read_text(encoding="utf-8"), + "copilot": copilot_agent.read_text(encoding="utf-8"), + "openai": openai_agent.read_text(encoding="utf-8"), + } + for provider, content in after.items(): + self.assertIn("@/local-kits/example-legacy/agents/reviewer.md", content, provider) + self.assertEqual(before[provider], content, provider) + + def test_example_mixed_copy_update_refreshes_openai_agents_and_preserves_skill_wrapper_after_regenerate(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + generated_review = project_root / ".agents" / "skills" / "cf-example-mixed-review" / "SKILL.md" + reviewer_agent = project_root / ".codex" / "agents" / "cf-example-mixed-reviewer.toml" + reviewer_helper = project_root / ".codex" / "agents" / "cf-example-mixed-reviewer-helper.toml" + before_review = generated_review.read_text(encoding="utf-8") + before_reviewer = reviewer_agent.read_text(encoding="utf-8") + before_helper = reviewer_helper.read_text(encoding="utf-8") + + (local_kit / "skills" / "review" / "SKILL.md").write_text( + ( + "---\nname: review\ndescription: Example mixed review skill\n---\n" + "# Example Mixed Review\nRefreshed review skill body.\n" + ), + encoding="utf-8", + ) + (local_kit / "agents" / "reviewer.md").write_text( + ( + "---\nname: reviewer\ndescription: Example mixed reviewer agent\n---\n" + "# Example Mixed Reviewer\nRefreshed reviewer body.\n" + ), + encoding="utf-8", + ) + (local_kit / "agents" / "reviewer-helper.md").write_text( + ( + "---\nname: reviewer-helper\ndescription: Mixed reviewer helper subagent\n---\n" + "# Reviewer helper\nRefreshed helper body.\n" + ), + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + [ + "kit", + "update", + "--path", + str(local_kit), + "--force", + "--no-interactive", + "-y", + "--approve-overwrite", + "review", + "--approve-overwrite", + "reviewer", + "--approve-overwrite", + "config/kits/example-mixed/agents/reviewer-helper.md", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertIn("skills/review/SKILL.md", out["results"][0]["accepted"]) + self.assertIn("agents/reviewer.md", out["results"][0]["accepted"]) + self.assertIn("agents/reviewer-helper.md", out["results"][0]["accepted"]) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + + after_review = generated_review.read_text(encoding="utf-8") + after_reviewer = reviewer_agent.read_text(encoding="utf-8") + after_helper = reviewer_helper.read_text(encoding="utf-8") + self.assertIn("{cf-studio-path}/config/kits/example-mixed/skills/review/SKILL.md", after_review) + self.assertIn("Refreshed reviewer body.", after_reviewer) + self.assertIn('name = "cf-example-mixed-reviewer-helper"', after_helper) + self.assertNotEqual(before_review, after_review) + self.assertNotEqual(before_reviewer, after_reviewer) + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".agents/skills/cf-example-mixed-review/SKILL.md", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer.toml", gitignore_text) + self.assertIn(".codex/agents/cf-example-mixed-reviewer-helper.toml", gitignore_text) + + def test_example_legacy_generic_git_check_updates_reports_available_then_current(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + repo, initial_commit = _make_git_repo_from_fixture(temp_root / "fixture-root", "example-legacy") + source = "git/" + quote(repo.as_uri(), safe="") + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + ["kit", "install", source, "--version", "HEAD"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + feature_example = repo / "artifacts" / "FEATURE" / "example.md" + feature_example.write_text("update check source drift\n", encoding="utf-8") + _run_git(repo, "add", "artifacts/FEATURE/example.md") + _run_git(repo, "commit", "-q", "-m", "drift for check updates") + updated_commit = _run_git(repo, "rev-parse", "HEAD") + self.assertNotEqual(initial_commit, updated_commit) + + rc, out, stderr = _run_main_json(["kit", "check-updates"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["updates_available"], 1) + self.assertEqual(out["message"], "Kit updates available") + self.assertEqual(out["commands"], ["cfs kit update example-legacy"]) + result = out["results"][0] + self.assertEqual(result["kit"], "example-legacy") + self.assertEqual(result["action"], "update_available") + self.assertEqual(result["installed_commit"], initial_commit) + self.assertEqual(result["latest_commit"], updated_commit) + self.assertEqual(result["command"], "cfs kit update example-legacy") + + rc, out, stderr = _run_main_json( + ["kit", "update", "example-legacy", "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["results"][0]["action"], "updated") + + rc, out, stderr = _run_main_json(["kit", "check-updates"], cwd=project_root) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["updates_available"], 0) + self.assertEqual(out["message"], "All checked kits are up to date") + self.assertEqual(out["results"][0]["kit"], "example-legacy") + self.assertEqual(out["results"][0]["action"], "current") + self.assertEqual(out["results"][0]["installed_commit"], updated_commit) + self.assertEqual(out["results"][0]["latest_commit"], updated_commit) + + def test_kit_check_updates_batch_reports_mixed_current_and_update_available_results(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + legacy_repo, legacy_initial = _make_git_repo_from_fixture(temp_root / "legacy-root", "example-legacy") + v2_repo, v2_initial = _make_subdir_git_repo_from_fixture( + temp_root / "v2-root", + "example-v2", + subdir="kits/example-v2", + ) + legacy_source = "git/" + quote(legacy_repo.as_uri(), safe="") + v2_source = "git/" + quote(v2_repo.as_uri(), safe="") + "//kits/example-v2" + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + ["kit", "install", legacy_source, "--version", "HEAD"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + rc, out, stderr = _run_main_json( + ["kit", "install", v2_source, "--version", "HEAD"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + (legacy_repo / "artifacts" / "FEATURE" / "example.md").write_text( + "batch check update available\n", + encoding="utf-8", + ) + _run_git(legacy_repo, "add", "artifacts/FEATURE/example.md") + _run_git(legacy_repo, "commit", "-q", "-m", "legacy batch drift") + legacy_updated = _run_git(legacy_repo, "rev-parse", "HEAD") + self.assertNotEqual(legacy_initial, legacy_updated) + + rc, out, stderr = _run_main_json(["kit", "check-updates"], cwd=project_root) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["updates_available"], 1) + self.assertEqual(out["message"], "Kit updates available") + self.assertEqual(out["commands"], ["cfs kit update example-legacy"]) + results = {result["kit"]: result for result in out["results"]} + self.assertEqual(results["example-legacy"]["action"], "update_available") + self.assertEqual(results["example-legacy"]["installed_commit"], legacy_initial) + self.assertEqual(results["example-legacy"]["latest_commit"], legacy_updated) + self.assertEqual(results["example-legacy"]["command"], "cfs kit update example-legacy") + self.assertEqual(results["example-v2"]["action"], "current") + self.assertEqual(results["example-v2"]["installed_commit"], v2_initial) + self.assertEqual(results["example-v2"]["latest_commit"], v2_initial) + + def test_example_v2_copy_update_applies_upstream_changes_and_refreshes_generated_outputs(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", project_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + installed_agent = project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "agents" / "reviewer.md" + self.assertIn("Review the change set", installed_agent.read_text(encoding="utf-8")) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + reviewer_proxy = project_root / ".codex" / "agents" / "cf-example-v2-reviewer.toml" + initial_proxy = reviewer_proxy.read_text(encoding="utf-8") + + (local_kit / "agents" / "reviewer.md").write_text( + "---\nname: reviewer\ndescription: Example V2 reviewer agent\n---\n# Example V2 Reviewer\nUpdated reviewer body approved.\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + [ + "kit", + "update", + "--path", + str(local_kit), + "--force", + "--no-interactive", + "-y", + "--approve-overwrite", + "reviewer", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertIn("agents/reviewer.md", result["accepted"]) + self.assertEqual(result["declined"], []) + self.assertGreaterEqual(result["files_written"], 1) + self.assertIn("Updated reviewer body approved.", installed_agent.read_text(encoding="utf-8")) + + rc, out, stderr = _run_main_json(["generate-agents", "--root", str(project_root), "--yes"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + updated_proxy = reviewer_proxy.read_text(encoding="utf-8") + self.assertIn("Updated reviewer body approved.", updated_proxy) + self.assertNotEqual(initial_proxy, updated_proxy) + + def test_example_v2_copy_update_requires_approve_overwrite_for_modified_template(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", project_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + installed_template = project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "artifacts" / "PRD" / "template.md" + installed_template.write_text("USER MODIFIED TEMPLATE\n", encoding="utf-8") + (local_kit / "artifacts" / "PRD" / "template.md").write_text( + "@cpt-template:cpt-example-v2-prd-template:p1\n# Upstream changed template\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--force", "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "WARN") + result = out["results"][0] + self.assertEqual(result["action"], "partial") + self.assertEqual(result["declined"], ["artifacts/PRD/template.md"]) + self.assertIn("USER MODIFIED TEMPLATE\n", installed_template.read_text(encoding="utf-8")) + + rc, out, stderr = _run_main_json( + [ + "kit", + "update", + "--path", + str(local_kit), + "--force", + "--no-interactive", + "-y", + "--approve-overwrite", + "prd-template", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertIn("artifacts/PRD/template.md", result["accepted"]) + self.assertEqual(installed_template.read_text(encoding="utf-8"), "@cpt-template:cpt-example-v2-prd-template:p1\n# Upstream changed template\n") + + def test_example_mixed_copy_update_requires_prune_then_removes_deleted_resource(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-mixed", project_root / "local-kits" / "example-mixed") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + installed_example = project_root / ".bootstrap" / "config" / "kits" / "example-mixed" / "artifacts" / "ADR" / "example.md" + self.assertTrue(installed_example.is_file()) + + manifest_path = local_kit / ".cf-studio-kit.toml" + manifest_text = manifest_path.read_text(encoding="utf-8") + manifest_path.write_text( + manifest_text.replace( + '[[kits.resources]]\nid = "adr-example"\nkind = "other"\nsource = "artifacts/ADR/example.md"\ninstall_path = "artifacts/ADR/example.md"\ntype = "file"\n\n', + "", + ), + encoding="utf-8", + ) + (local_kit / "artifacts" / "ADR" / "example.md").unlink() + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--force", "--no-interactive", "-y"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertIn("artifacts/ADR/example.md", result["accepted"]) + self.assertFalse(installed_example.exists()) + + core = _read_core(project_root) + self.assertNotIn("adr-example", core["kits"]["example-mixed"]["resources"]) + + def test_kit_update_without_selector_updates_multiple_registered_git_kits(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + legacy_repo, legacy_initial = _make_git_repo_from_fixture(temp_root / "legacy-root", "example-legacy") + subdir_repo, subdir_initial = _make_subdir_git_repo_from_fixture( + temp_root / "subdir-root", + "example-v2", + subdir="kits/example-v2", + ) + legacy_source = "git/" + quote(legacy_repo.as_uri(), safe="") + subdir_source = "git/" + quote(subdir_repo.as_uri(), safe="") + "//kits/example-v2" + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + ["kit", "install", legacy_source, "--version", "HEAD"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + rc, out, stderr = _run_main_json( + ["kit", "install", subdir_source, "--version", "HEAD"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + (legacy_repo / "artifacts" / "FEATURE" / "example.md").write_text( + "UPDATED LEGACY FROM AGGREGATE\n", + encoding="utf-8", + ) + _run_git(legacy_repo, "add", "artifacts/FEATURE/example.md") + _run_git(legacy_repo, "commit", "-q", "-m", "legacy update") + legacy_updated = _run_git(legacy_repo, "rev-parse", "HEAD") + self.assertNotEqual(legacy_initial, legacy_updated) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--no-interactive", "-y"], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_updated"], 1) + results = {result["kit"]: result for result in out["results"]} + self.assertEqual(results["example-legacy"]["action"], "updated") + self.assertEqual(results["example-v2"]["action"], "current") + self.assertEqual(results["example-legacy"]["authority"]["commit_sha"], legacy_updated) + self.assertEqual(results["example-v2"]["authority"]["commit_sha"], subdir_initial) + self.assertEqual( + ( + project_root / ".bootstrap" / "config" / "kits" / "example-legacy" / "artifacts" / "FEATURE" / "example.md" + ).read_text(encoding="utf-8"), + "UPDATED LEGACY FROM AGGREGATE\n", + ) + self.assertIn( + "cpt-example-v2-prd-fr", + ( + project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "artifacts" / "PRD" / "example.md" + ).read_text(encoding="utf-8"), + ) + + def test_example_v2_generic_git_subdir_install_and_update_preserves_subdirectory_provenance(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + repo, initial_commit = _make_subdir_git_repo_from_fixture( + temp_root / "subdir-root", + "example-v2", + subdir="kits/example-v2", + ) + source = "git/" + quote(repo.as_uri(), safe="") + "//kits/example-v2" + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + ["kit", "install", source, "--version", "HEAD"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kit"], "example-v2") + + core = _read_core(project_root) + entry = core["kits"]["example-v2"] + self.assertTrue(str(entry["source"]).endswith("//kits/example-v2")) + self.assertEqual(entry["source_provenance"]["source_type"], "git") + self.assertEqual(entry["source_provenance"]["selected_subdirectory"], "kits/example-v2") + self.assertEqual(entry["source_provenance"]["commit_sha"], initial_commit) + + installed_template = ( + project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "artifacts" / "PRD" / "template.md" + ) + self.assertIn("Example V2 PRD Template", installed_template.read_text(encoding="utf-8")) + + (repo / "kits" / "example-v2" / "artifacts" / "FEATURE" / "example.md").write_text( + "UPDATED FROM SUBDIR GIT\n", + encoding="utf-8", + ) + _run_git(repo, "add", "kits/example-v2/artifacts/FEATURE/example.md") + _run_git(repo, "commit", "-q", "-m", "subdir update") + updated_commit = _run_git(repo, "rev-parse", "HEAD") + + rc, out, stderr = _run_main_json( + ["kit", "update", "example-v2", "--force", "--no-interactive", "-y"], + cwd=project_root, + ) + + self.assertEqual(rc, 2, stderr) + self.assertEqual(out["status"], "WARN") + result = out["results"][0] + self.assertEqual(result["action"], "partial") + self.assertEqual(result["accepted"], ["agents/planner-helper.md", "agents/reviewer-helper.md"]) + self.assertEqual(result["declined"], ["artifacts/FEATURE/example.md"]) + self.assertEqual(result["authority"]["commit_sha"], updated_commit) + self.assertIn( + "cpt-example-v2-feature-flow", + ( + project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "artifacts" / "FEATURE" / "example.md" + ).read_text(encoding="utf-8"), + ) + core = _read_core(project_root) + self.assertEqual(core["kits"]["example-v2"]["source_provenance"]["selected_subdirectory"], "kits/example-v2") + + def test_generic_git_multi_kit_selector_installs_and_updates_only_selected_kit(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + repo, _initial_commit = _make_multi_canonical_git_repo(temp_root / "multi-root") + source = "git/" + quote(repo.as_uri(), safe="") + + with patch.dict(os.environ, {"CFS_GIT_KIT_CACHE_DIR": str(temp_root / "git-cache")}): + rc, out, stderr = _run_main_json( + ["kit", "install", source + "@beta", "--version", "HEAD"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kit"], "beta") + + beta_skill = project_root / ".bootstrap" / "config" / "kits" / "beta" / "SKILL.md" + self.assertEqual(beta_skill.read_text(encoding="utf-8"), "# Beta v1\n") + self.assertFalse((project_root / ".bootstrap" / "config" / "kits" / "alpha").exists()) + + core = _read_core(project_root) + self.assertTrue(str(core["kits"]["beta"]["source"]).endswith("@beta")) + + (repo / "beta.md").write_text("# Beta v2\n", encoding="utf-8") + (repo / "alpha.md").write_text("# Alpha v2\n", encoding="utf-8") + _run_git(repo, "add", "alpha.md", "beta.md") + _run_git(repo, "commit", "-q", "-m", "update both kits") + + rc, out, stderr = _run_main_json( + [ + "kit", + "update", + "beta", + "--force", + "--no-interactive", + "-y", + "--approve-overwrite", + "skill", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertEqual(beta_skill.read_text(encoding="utf-8"), "# Beta v2\n") + self.assertFalse((project_root / ".bootstrap" / "config" / "kits" / "alpha").exists()) + + def test_example_mixed_github_ref_install_check_updates_and_update_flow(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + + github_v1 = _copy_fixture("example-mixed", temp_root / "github-v1" / "example-mixed") + github_v2 = _copy_fixture("example-mixed", temp_root / "github-v2" / "example-mixed") + (github_v2 / "artifacts" / "ADR" / "template.md").write_text( + "@cpt-template:cpt-example-mixed-adr-template:p1\n# Example Mixed ADR Template Updated\n", + encoding="utf-8", + ) + + install_authority = { + "source_type": "github", + "canonical_source": "github:acme/example-mixed", + "effective_source": "github:acme/example-mixed", + "requested_ref": "v2.0.0", + "resolved_ref": "v2.0.0", + "installed_version": "v2.0.0", + "resolver_mode": "explicit", + "resolution_basis": "github_ref", + "verified": "verified", + "freshness": "fresh", + "commit_sha": "1111111111111111111111111111111111111111", + } + update_authority = { + "source_type": "github", + "canonical_source": "github:acme/example-mixed", + "effective_source": "github:acme/example-mixed", + "requested_ref": "v2.1.0", + "resolved_ref": "v2.1.0", + "installed_version": "v2.1.0", + "resolver_mode": "explicit", + "resolution_basis": "github_ref", + "verified": "verified", + "freshness": "fresh", + "commit_sha": "2222222222222222222222222222222222222222", + } + + with patch( + "studio.commands.kit._download_kit_from_github_with_authority", + return_value=(github_v1, "v2.0.0", install_authority), + ): + rc, out, stderr = _run_main_json( + ["kit", "install", "acme/example-mixed@v2.0.0"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["source"], "github:acme/example-mixed") + + installed_template = ( + project_root / ".bootstrap" / "config" / "kits" / "example-mixed" / "artifacts" / "ADR" / "template.md" + ) + self.assertIn("cpt-example-mixed-adr-template", installed_template.read_text(encoding="utf-8")) + + core = _read_core(project_root) + self.assertEqual(core["kits"]["example-mixed"]["source_provenance"]["resolved_ref"], "v2.0.0") + self.assertEqual(core["kits"]["example-mixed"]["source_provenance"]["resolution_basis"], "github_ref") + + with patch( + "studio.commands.kit._download_kit_from_github_with_authority", + return_value=(github_v2, "v2.1.0", update_authority), + ): + rc, out, stderr = _run_main_json(["kit", "check-updates", "example-mixed"], cwd=project_root) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["updates_available"], 1) + result = out["results"][0] + self.assertEqual(result["action"], "update_available") + self.assertEqual(result["installed_ref"], "v2.0.0") + self.assertEqual(result["latest_ref"], "v2.1.0") + self.assertEqual(out["commands"], ["cfs kit update example-mixed"]) + + rc, out, stderr = _run_main_json( + [ + "kit", + "update", + "example-mixed", + "--force", + "--no-interactive", + "-y", + "--approve-overwrite", + "adr-template", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + update_result = out["results"][0] + self.assertEqual(update_result["action"], "updated") + self.assertEqual(update_result["authority"]["resolved_ref"], "v2.1.0") + self.assertEqual(update_result["authority"]["resolution_basis"], "github_ref") + self.assertIn("artifacts/ADR/template.md", update_result["accepted"]) + + core = _read_core(project_root) + self.assertEqual(core["kits"]["example-mixed"]["source_provenance"]["resolved_ref"], "v2.1.0") + self.assertEqual(core["kits"]["example-mixed"]["source_provenance"]["commit_sha"], update_authority["commit_sha"]) + + with patch( + "studio.commands.kit._download_kit_from_github_with_authority", + return_value=(github_v2, "v2.1.0", update_authority), + ): + rc, out, stderr = _run_main_json(["kit", "check-updates", "example-mixed"], cwd=project_root) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["updates_available"], 0) + self.assertEqual(out["results"][0]["action"], "current") + self.assertEqual(out["results"][0]["installed_ref"], "v2.1.0") + self.assertEqual(out["results"][0]["latest_ref"], "v2.1.0") + + def test_example_v2_interactive_update_accepts_overwrite_prompt(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _make_simple_canonical_kit(project_root / "local-kits", "canon-interactive") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + installed_template = ( + project_root / ".bootstrap" / "config" / "kits" / "canon-interactive" / "SKILL.md" + ) + installed_template.write_text("INTERACTIVE USER EDIT\n", encoding="utf-8") + (local_kit / "SKILL.md").write_text( + "---\nname: skill\ndescription: Canonical kit\n---\n# Interactive overwrite accepted\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json_with_stdin( + [ + "kit", + "update", + "--path", + str(local_kit), + "--force", + ], + cwd=project_root, + stdin_text="a\na\ny\n", + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + result = out["results"][0] + self.assertEqual(result["action"], "updated") + self.assertIn("SKILL.md", result["accepted"]) + self.assertIn("Reply with `a`, `d`, `A`, `D`, or `m`.", stderr) + self.assertEqual( + installed_template.read_text(encoding="utf-8"), + "---\nname: skill\ndescription: Canonical kit\n---\n# Interactive overwrite accepted\n", + ) diff --git a/tests/test_cli_gitignore_e2e.py b/tests/test_cli_gitignore_e2e.py new file mode 100644 index 00000000..062c2647 --- /dev/null +++ b/tests/test_cli_gitignore_e2e.py @@ -0,0 +1,518 @@ +""" +Focused public-CLI e2e coverage for managed ``.gitignore`` content. + +These tests stay at the public ``studio.cli.main([...])`` layer and assert the +exact managed ignore entries written for runtime files, ignored kit installs, +and generated host integration outputs. +""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +@contextmanager +def _chdir(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(argv) + return rc, json.loads(stdout.getvalue()), stderr.getvalue() + + +def _make_cache(root: Path) -> Path: + cache = root / "cache" + for name in ("requirements", "schemas", "workflows", "skills"): + (cache / name).mkdir(parents=True, exist_ok=True) + (cache / name / "README.md").write_text(f"# {name}\n", encoding="utf-8") + (cache / "skills" / "studio").mkdir(parents=True, exist_ok=True) + (cache / "skills" / "studio" / "SKILL.md").write_text( + "---\nname: studio\ndescription: Test Studio skill\n---\n# Studio\n", + encoding="utf-8", + ) + for workflow_name in ("generate", "analyze", "plan", "explore", "workspace"): + (cache / "workflows" / f"{workflow_name}.md").write_text( + ( + "---\n" + "type: workflow\n" + f"name: {workflow_name}\n" + f"description: Test {workflow_name} workflow\n" + "---\n" + f"# {workflow_name.title()}\n" + ), + encoding="utf-8", + ) + arch = cache / "architecture" / "specs" / "kit" + arch.mkdir(parents=True, exist_ok=True) + for rel in ( + "specs/traceability.md", + "specs/CDSL.md", + "specs/PDSL.md", + "specs/cli.md", + "specs/CLISPEC.md", + "specs/artifacts-registry.md", + "specs/kit/constraints.md", + "specs/kit/kit.md", + ): + target = cache / "architecture" / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"# {rel}\n", encoding="utf-8") + (cache / "whatsnew.toml").write_text( + '[whatsnew."v1.0.0"]\nsummary = "Initial"\ndetails = ""\n', + encoding="utf-8", + ) + (cache / "version.toml").write_text( + '[cfs]\nversion = "v1.0.0"\n', + encoding="utf-8", + ) + return cache + + +def _make_local_kit_source(root: Path, slug: str = "demo") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "artifacts" / "FEATURE").mkdir(parents=True, exist_ok=True) + (kit_src / "artifacts" / "FEATURE" / "template.md").write_text( + "# Feature\n", + encoding="utf-8", + ) + (kit_src / "SKILL.md").write_text( + f"---\nname: {slug}\ndescription: Test kit\n---\n# {slug}\n", + encoding="utf-8", + ) + (kit_src / "constraints.toml").write_text( + "[naming]\npattern = 'demo-*'\n", + encoding="utf-8", + ) + (kit_src / "conf.toml").write_text( + f'version = "1.2.3"\nslug = "{slug}"\n', + encoding="utf-8", + ) + return kit_src + + +def _make_public_components_kit_source(root: Path, slug: str = "kitpub") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + + (kit_src / "SKILL.md").write_text( + "---\nname: skill\ndescription: Public kit skill\n---\n# Public kit skill\n", + encoding="utf-8", + ) + (kit_src / "agent.md").write_text( + "# Public agent prompt\nPublic agent body.\n", + encoding="utf-8", + ) + (kit_src / "helper.md").write_text( + "# Public helper prompt\nPublic helper body.\n", + encoding="utf-8", + ) + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join( + [ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + f'name = "{slug}"', + 'version = "1.2.3"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "skill"', + 'source = "SKILL.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + "public = true", + 'generated_targets = ["openai"]', + "", + "[[kits.resources]]", + 'id = "agent"', + 'kind = "agent"', + 'source = "agent.md"', + 'install_path = "agent.md"', + 'type = "file"', + "public = true", + 'generated_targets = ["openai"]', + 'description = "Public kit agent"', + "", + "[kits.resources.agent]", + 'mode = "readonly"', + "", + "[[kits.resources.agent.subagents]]", + 'id = "helper"', + 'source = "helper.md"', + 'generated_targets = ["openai"]', + 'description = "Public helper agent"', + 'mode = "readonly"', + ] + ) + + "\n", + encoding="utf-8", + ) + return kit_src + + +def _make_agent_proxy_kit_source(root: Path, slug: str = "proxykit") -> Path: + kit_src = root / slug + (kit_src / "agents").mkdir(parents=True, exist_ok=True) + (kit_src / "conf.toml").write_text( + f'version = "1.2.3"\nslug = "{slug}"\n', + encoding="utf-8", + ) + (kit_src / "agents.toml").write_text( + '[agents.kitproxy]\n' + 'description = "Kit proxy agent"\n' + 'prompt_file = "agents/kitproxy.md"\n' + 'mode = "readonly"\n', + encoding="utf-8", + ) + (kit_src / "agents" / "kitproxy.md").write_text( + "# Kit proxy prompt\nLegacy kit proxy body.\n", + encoding="utf-8", + ) + (kit_src / "manifest.toml").write_text( + "\n".join( + [ + "[manifest]", + 'version = "1.0"', + f'root = "{{cf-studio-path}}/config/kits/{slug}"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "agents_toml"', + 'source = "agents.toml"', + 'default_path = "agents.toml"', + 'type = "file"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "agent_prompt"', + 'source = "agents/kitproxy.md"', + 'default_path = "agents/kitproxy.md"', + 'type = "file"', + "user_modifiable = false", + ] + ) + + "\n", + encoding="utf-8", + ) + return kit_src + + +def _init_project( + root: Path, + cache: Path, + *, + runtime_tracking: str = "ignored", + agent_tracking: str = "ignored", + kit_tracking: str = "tracked", +) -> tuple[int, dict, str]: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + with patch("studio.commands.init.CACHE_DIR", cache), patch( + "studio.commands.init._install_default_kit", + return_value={}, + ): + return _run_main( + [ + "--json", + "init", + "--project-root", + str(root), + "--install-dir", + ".bootstrap", + "--runtime-tracking", + runtime_tracking, + "--agent-tracking", + agent_tracking, + "--kit-tracking", + kit_tracking, + "--yes", + ], + cwd=root, + ) + + +class TestCliGitignoreE2E(unittest.TestCase): + def test_init_writes_only_managed_runtime_entries(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + + rc, out, _stderr = _init_project( + project_root, + cache, + runtime_tracking="ignored", + agent_tracking="tracked", + kit_tracking="tracked", + ) + + self.assertEqual(rc, 0) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn("# BEGIN Constructor Studio", gitignore_text) + self.assertIn(".bootstrap/.core/", gitignore_text) + self.assertIn(".bootstrap/.gen/", gitignore_text) + self.assertNotIn(".bootstrap/\n", gitignore_text) + self.assertNotIn(".bootstrap/whatsnew.toml", gitignore_text) + self.assertNotIn(".bootstrap/version.toml", gitignore_text) + self.assertNotIn(".github/prompts/cf.prompt.md", gitignore_text) + self.assertNotIn(".claude/skills/cf/SKILL.md", gitignore_text) + + self.assertTrue((project_root / ".bootstrap" / ".core").is_dir()) + self.assertTrue((project_root / ".bootstrap" / ".gen").is_dir()) + self.assertTrue((project_root / ".bootstrap" / "whatsnew.toml").is_file()) + self.assertTrue((project_root / ".bootstrap" / "version.toml").is_file()) + self.assertFalse((project_root / ".github").exists()) + self.assertFalse((project_root / ".claude").exists()) + + def test_kit_install_with_ignored_tracking_adds_only_specific_kit_path(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + kit_src = _make_local_kit_source(temp_root, "demo") + project_root = temp_root / "proj" + + init_rc, init_out, _stderr = _init_project( + project_root, + cache, + runtime_tracking="tracked", + agent_tracking="tracked", + kit_tracking="tracked", + ) + self.assertEqual(init_rc, 0) + self.assertEqual(init_out["status"], "PASS") + + with patch("studio.commands.kit._prompt_git_tracking_for_installed_kit", return_value="ignored"), patch( + "sys.stdin.isatty", + return_value=True, + ): + rc, out, _stderr = _run_main( + [ + "--json", + "kit", + "install", + "--path", + str(kit_src), + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".bootstrap/config/kits/demo/", gitignore_text) + self.assertNotIn(".bootstrap/config/kits/\n", gitignore_text) + self.assertNotIn(".bootstrap/.core/", gitignore_text) + self.assertNotIn(".bootstrap/.gen/", gitignore_text) + self.assertNotIn(".github/prompts/cf.prompt.md", gitignore_text) + + installed_skill = project_root / ".bootstrap" / "config" / "kits" / "demo" / "SKILL.md" + installed_template = ( + project_root / ".bootstrap" / "config" / "kits" / "demo" / "artifacts" / "FEATURE" / "template.md" + ) + self.assertTrue(installed_skill.is_file()) + self.assertTrue(installed_template.is_file()) + self.assertFalse((project_root / ".bootstrap" / "config" / "kits" / "missing").exists()) + self.assertFalse((project_root / ".github").exists()) + self.assertFalse((project_root / ".claude").exists()) + + def test_generate_agents_outputs_match_managed_host_gitignore_entries(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + + init_rc, init_out, _stderr = _init_project( + project_root, + cache, + runtime_tracking="tracked", + agent_tracking="ignored", + kit_tracking="tracked", + ) + self.assertEqual(init_rc, 0) + self.assertEqual(init_out["status"], "PASS") + + gitignore_before = (project_root / ".gitignore").read_text(encoding="utf-8") + for expected in ( + ".claude/skills/cf/SKILL.md", + ".cursor/commands/cf.md", + ".github/prompts/cf.prompt.md", + ".windsurf/workflows/cf.md", + ".codex/.cf-installed", + ): + self.assertIn(expected, gitignore_before) + for forbidden in ( + ".claude/\n", + ".cursor/\n", + ".github/\n", + ".windsurf/\n", + ".codex/\n", + ): + self.assertNotIn(forbidden, gitignore_before) + + rc, out, _stderr = _run_main( + [ + "--json", + "generate-agents", + "--root", + str(project_root), + "--yes", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0) + self.assertEqual(out["status"], "PASS") + + gitignore_after = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertEqual(gitignore_after, gitignore_before) + + self.assertTrue((project_root / ".claude" / "skills" / "cf" / "SKILL.md").is_file()) + self.assertTrue((project_root / ".cursor" / "commands" / "cf.md").is_file()) + self.assertTrue((project_root / ".github" / "prompts" / "cf.prompt.md").is_file()) + self.assertTrue((project_root / ".windsurf" / "workflows" / "cf.md").is_file()) + self.assertTrue((project_root / ".codex" / ".cf-installed").is_file()) + self.assertTrue((project_root / ".agents" / "skills" / "cf" / "SKILL.md").is_file()) + + def test_generate_agents_adds_gitignore_entry_for_kit_public_skill(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + kit_src = _make_public_components_kit_source(temp_root, "kitpub") + project_root = temp_root / "proj" + + init_rc, init_out, _stderr = _init_project( + project_root, + cache, + runtime_tracking="tracked", + agent_tracking="ignored", + kit_tracking="tracked", + ) + self.assertEqual(init_rc, 0) + self.assertEqual(init_out["status"], "PASS") + + install_rc, install_out, _stderr = _run_main( + [ + "--json", + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(install_rc, 0) + self.assertEqual(install_out["status"], "PASS") + + rc, out, _stderr = _run_main( + [ + "--json", + "generate-agents", + "--root", + str(project_root), + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".agents/skills/cf-kitpub-skill/SKILL.md", gitignore_text) + self.assertNotIn(".agents/skills/cf-kitpub-skill/\n", gitignore_text) + + installed_skill = project_root / ".bootstrap" / "config" / "kits" / "kitpub" / "SKILL.md" + generated_skill = project_root / ".agents" / "skills" / "cf-kitpub-skill" / "SKILL.md" + + self.assertTrue(installed_skill.is_file()) + self.assertTrue(generated_skill.is_file()) + self.assertIn( + "{cf-studio-path}/config/kits/kitpub/SKILL.md", + generated_skill.read_text(encoding="utf-8"), + ) + + def test_generate_agents_adds_gitignore_entry_for_kit_agent_proxy(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + kit_src = _make_agent_proxy_kit_source(temp_root, "proxykit") + project_root = temp_root / "proj" + + init_rc, init_out, _stderr = _init_project( + project_root, + cache, + runtime_tracking="tracked", + agent_tracking="ignored", + kit_tracking="tracked", + ) + self.assertEqual(init_rc, 0) + self.assertEqual(init_out["status"], "PASS") + + install_rc, install_out, _stderr = _run_main( + [ + "--json", + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(install_rc, 0) + self.assertEqual(install_out["status"], "PASS") + + rc, out, _stderr = _run_main( + [ + "--json", + "generate-agents", + "--root", + str(project_root), + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(rc, 0) + self.assertEqual(out["status"], "PASS") + + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".codex/agents/kitproxy.toml", gitignore_text) + self.assertNotIn(".codex/agents/\n", gitignore_text) + + installed_agents_toml = project_root / ".bootstrap" / "config" / "kits" / "proxykit" / "agents.toml" + installed_prompt = project_root / ".bootstrap" / "config" / "kits" / "proxykit" / "agents" / "kitproxy.md" + generated_proxy = project_root / ".codex" / "agents" / "kitproxy.toml" + + self.assertTrue(installed_agents_toml.is_file()) + self.assertTrue(installed_prompt.is_file()) + self.assertTrue(generated_proxy.is_file()) + self.assertIn("{cf-studio-path}/config/kits/proxykit/agents/kitproxy.md", generated_proxy.read_text(encoding="utf-8")) diff --git a/tests/test_cli_kit_utility_e2e.py b/tests/test_cli_kit_utility_e2e.py new file mode 100644 index 00000000..7619be73 --- /dev/null +++ b/tests/test_cli_kit_utility_e2e.py @@ -0,0 +1,792 @@ +"""Public CLI e2e coverage for chunk-input and kit utility commands.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +VALID_PDSL = """UNIT Demo + +PURPOSE: + Validate a small block. + +DO: + - RUN Do something deterministic + +RULES: + - ALWAYS keep output stable +""" + + +@contextmanager +def _chdir(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _run_main(argv: list[str], *, cwd: Path, stdin_text: str | None = None) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + stdin = io.StringIO(stdin_text) if stdin_text is not None else None + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + if stdin is None: + rc = main(argv) + else: + with patch("sys.stdin", stdin): + rc = main(argv) + return rc, stdout.getvalue(), stderr.getvalue() + + +def _run_main_json(argv: list[str], *, cwd: Path, stdin_text: str | None = None) -> tuple[int, dict, str]: + rc, stdout, stderr = _run_main(["--json", *argv], cwd=cwd, stdin_text=stdin_text) + return rc, json.loads(stdout), stderr + + +def _snapshot_tree(root: Path) -> dict[str, bytes]: + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def _make_cache(root: Path) -> Path: + cache = root / "cache" + for name in ("requirements", "schemas", "workflows", "skills"): + (cache / name).mkdir(parents=True, exist_ok=True) + (cache / name / "README.md").write_text(f"# {name}\n", encoding="utf-8") + (cache / "skills" / "studio").mkdir(parents=True, exist_ok=True) + (cache / "skills" / "studio" / "SKILL.md").write_text( + "---\nname: studio\ndescription: Test Studio skill\n---\n# Studio\n", + encoding="utf-8", + ) + for workflow_name in ("generate", "analyze", "plan", "explore", "workspace"): + (cache / "workflows" / f"{workflow_name}.md").write_text( + ( + "---\n" + "type: workflow\n" + f"name: {workflow_name}\n" + f"description: Test {workflow_name} workflow\n" + "---\n" + f"# {workflow_name.title()}\n" + ), + encoding="utf-8", + ) + for rel in ( + "architecture/specs/traceability.md", + "architecture/specs/CDSL.md", + "architecture/specs/PDSL.md", + "architecture/specs/cli.md", + "architecture/specs/CLISPEC.md", + "architecture/specs/artifacts-registry.md", + "architecture/specs/kit/constraints.md", + "architecture/specs/kit/kit.md", + ): + target = cache / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"# {rel}\n", encoding="utf-8") + (cache / "whatsnew.toml").write_text( + '[whatsnew."v1.0.0"]\nsummary = "Initial"\ndetails = ""\n', + encoding="utf-8", + ) + (cache / "version.toml").write_text( + '[cfs]\nversion = "v1.0.0"\n', + encoding="utf-8", + ) + return cache + + +def _make_local_kit_source(root: Path, slug: str = "demo") -> Path: + kit_src = root / slug + (kit_src / "artifacts" / "FEATURE").mkdir(parents=True, exist_ok=True) + (kit_src / "artifacts" / "FEATURE" / "template.md").write_text( + f"# {slug} feature\n", + encoding="utf-8", + ) + (kit_src / "SKILL.md").write_text( + f"---\nname: {slug}\ndescription: Test kit\n---\n# {slug}\nkit body\n", + encoding="utf-8", + ) + (kit_src / "constraints.toml").write_text( + f"[naming]\npattern = '{slug}-*'\n", + encoding="utf-8", + ) + (kit_src / "conf.toml").write_text( + f'version = "1.2.3"\nslug = "{slug}"\n', + encoding="utf-8", + ) + return kit_src + + +def _make_public_manifest_kit_source(root: Path, slug: str = "pubkit") -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "skill.md").write_text( + "---\nname: helper\ndescription: Public helper skill\n---\n# Helper\n", + encoding="utf-8", + ) + (kit_src / "agent.md").write_text( + "---\nname: reviewer\ndescription: Public reviewer agent\n---\n# Reviewer\n", + encoding="utf-8", + ) + (kit_src / "auditor.md").write_text( + "---\nname: auditor\ndescription: Nested auditor\n---\n# Auditor\n", + encoding="utf-8", + ) + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + 'name = "Public Kit"', + 'version = "2.0.0"', + "", + "[[kits.resources]]", + 'id = "helper"', + 'kind = "skill"', + 'source = "skill.md"', + 'type = "file"', + "public = true", + 'generated_targets = ["cursor"]', + 'description = "Helper skill"', + "", + "[[kits.resources]]", + 'id = "reviewer"', + 'kind = "agent"', + 'source = "agent.md"', + 'type = "file"', + "public = true", + 'generated_targets = ["cursor"]', + 'description = "Reviewer agent"', + "", + "[kits.resources.targets.cursor]", + 'mode = "readonly"', + 'provider = "anthropic"', + 'reasoning_effort = "medium"', + "", + "[[kits.resources.agent.subagents]]", + 'id = "auditor"', + 'source = "auditor.md"', + 'description = "Nested auditor"', + 'generated_targets = ["cursor"]', + "prefix_generated_name = false", + 'mode = "readonly"', + 'provider = "anthropic"', + 'tools = ["Read"]', + ]) + "\n", + encoding="utf-8", + ) + return kit_src + + +def _init_project( + root: Path, + cache: Path, + *, + runtime_tracking: str = "tracked", + agent_tracking: str = "tracked", + kit_tracking: str = "tracked", +) -> dict: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + with patch("studio.commands.init.CACHE_DIR", cache), patch( + "studio.commands.init._install_default_kit", + return_value={}, + ): + rc, out, stderr = _run_main_json( + [ + "init", + "--project-root", + str(root), + "--install-dir", + ".bootstrap", + "--runtime-tracking", + runtime_tracking, + "--agent-tracking", + agent_tracking, + "--kit-tracking", + kit_tracking, + "--yes", + ], + cwd=root, + ) + assert rc == 0, stderr + assert out["status"] == "PASS", out + return out + + +class TestCliKitUtilityE2E(unittest.TestCase): + def test_chunk_input_dry_run_from_files_is_read_only(self): + with TemporaryDirectory() as td: + root = Path(td) + src = root / "input.md" + src.write_text("one\ntwo\nthree\nfour\n", encoding="utf-8") + out_dir = root / "chunks" + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + [ + "chunk-input", + str(src), + "--output-dir", + str(out_dir), + "--max-lines", + "2", + "--threshold-lines", + "3", + "--dry-run", + ], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "OK") + self.assertTrue(out["dry_run"]) + self.assertEqual(out["total_sources"], 1) + self.assertEqual(out["total_lines"], 4) + self.assertTrue(out["plan_required"]) + self.assertFalse(out_dir.exists()) + self.assertEqual(_snapshot_tree(root), before) + + def test_chunk_input_public_cli_writes_manifest_and_chunk_contents(self): + with TemporaryDirectory() as td: + root = Path(td) + src = root / "request notes.md" + out_dir = root / "chunks" + src.write_text("alpha\nbeta\ngamma\ndelta\nepsilon\n", encoding="utf-8") + + rc, out, stderr = _run_main_json( + [ + "chunk-input", + str(src), + "--output-dir", + str(out_dir), + "--include-stdin", + "--stdin-label", + "Prompt Request", + "--max-lines", + "2", + "--threshold-lines", + "4", + ], + cwd=root, + stdin_text="intro\ncontext\nbridge\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "OK") + self.assertEqual(out["total_sources"], 2) + self.assertEqual(out["total_lines"], 8) + self.assertEqual(out["chunk_count"], 5) + self.assertTrue(out["plan_required"]) + + direct_prompt = out_dir / "direct-prompt.md" + manifest_path = out_dir / "manifest.json" + self.assertEqual(out["direct_prompt_file"], direct_prompt.resolve().as_posix()) + self.assertEqual(out["package_manifest"], manifest_path.resolve().as_posix()) + self.assertEqual(direct_prompt.read_text(encoding="utf-8"), "intro\ncontext\nbridge\n") + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual(manifest["input_signature"], out["input_signature"]) + self.assertEqual(manifest["direct_prompt_file"], "direct-prompt.md") + self.assertEqual(manifest["total_sources"], 2) + self.assertEqual(manifest["total_lines"], 8) + self.assertEqual(manifest["max_lines"], 2) + self.assertEqual( + [chunk["file"] for chunk in manifest["chunks"]], + [ + "001-01-prompt-request-part-01.md", + "002-01-prompt-request-part-02.md", + "003-02-request-notes-part-01.md", + "004-02-request-notes-part-02.md", + "005-02-request-notes-part-03.md", + ], + ) + self.assertEqual( + sorted(path.name for path in out_dir.iterdir() if path.is_file()), + [ + "001-01-prompt-request-part-01.md", + "002-01-prompt-request-part-02.md", + "003-02-request-notes-part-01.md", + "004-02-request-notes-part-02.md", + "005-02-request-notes-part-03.md", + "direct-prompt.md", + "manifest.json", + ], + ) + + expected_chunks = { + "001-01-prompt-request-part-01.md": "intro\ncontext\n", + "002-01-prompt-request-part-02.md": "bridge\n", + "003-02-request-notes-part-01.md": "alpha\nbeta\n", + "004-02-request-notes-part-02.md": "gamma\ndelta\n", + "005-02-request-notes-part-03.md": "epsilon\n", + } + for chunk in out["chunks"]: + chunk_path = Path(chunk["path"]) + self.assertEqual(chunk_path.read_text(encoding="utf-8"), expected_chunks[chunk["file"]]) + + def test_chunk_input_stdin_only_writes_expected_files(self): + with TemporaryDirectory() as td: + root = Path(td) + out_dir = root / "chunks" + + rc, out, stderr = _run_main_json( + [ + "chunk-input", + "--output-dir", + str(out_dir), + "--stdin-label", + "Direct Prompt", + "--max-lines", + "2", + ], + cwd=root, + stdin_text="alpha\nbeta\ngamma\n", + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "OK") + self.assertEqual(out["total_sources"], 1) + self.assertEqual(out["chunk_count"], 2) + self.assertEqual((out_dir / "direct-prompt.md").read_text(encoding="utf-8"), "alpha\nbeta\ngamma\n") + self.assertTrue((out_dir / "001-01-direct-prompt-part-01.md").is_file()) + self.assertTrue((out_dir / "002-01-direct-prompt-part-02.md").is_file()) + + def test_chunk_input_invalid_output_dir_errors_without_writes(self): + with TemporaryDirectory() as td: + root = Path(td) + src = root / "input.md" + src.write_text("alpha\n", encoding="utf-8") + output_file = root / "not-a-dir" + output_file.write_text("occupied\n", encoding="utf-8") + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + [ + "chunk-input", + str(src), + "--output-dir", + str(output_file), + ], + cwd=root, + ) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "ERROR") + self.assertIn("not a directory", out["message"]) + self.assertEqual(_snapshot_tree(root), before) + + def test_chunk_input_invalid_thresholds_error_without_writes(self): + with TemporaryDirectory() as td: + root = Path(td) + src = root / "input.md" + src.write_text("alpha\n", encoding="utf-8") + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + [ + "chunk-input", + str(src), + "--output-dir", + str(root / "chunks"), + "--threshold-lines", + "0", + ], + cwd=root, + ) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "ERROR") + self.assertIn("--threshold-lines must be > 0", out["message"]) + self.assertEqual(_snapshot_tree(root), before) + + def test_chunk_input_missing_source_file_errors_without_writes(self): + with TemporaryDirectory() as td: + root = Path(td) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + [ + "chunk-input", + str(root / "missing.md"), + "--output-dir", + str(root / "chunks"), + ], + cwd=root, + ) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "ERROR") + self.assertIn("Input file not found", out["message"]) + self.assertEqual(_snapshot_tree(root), before) + + def test_toc_skip_validate_and_indent_max_level_only_mutate_targets(self): + with TemporaryDirectory() as td: + root = Path(td) + doc = root / "doc.md" + doc.write_text("# Title\n\n## Alpha\n\n### Beta\n\n#### Gamma\n", encoding="utf-8") + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + [ + "toc", + str(doc), + "--skip-validate", + "--indent", + "4", + "--max-level", + "2", + ], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "OK") + self.assertEqual(out["results"][0]["status"], "UPDATED") + self.assertNotIn("validation", out["results"][0]) + text = doc.read_text(encoding="utf-8") + self.assertIn("- [Alpha](#alpha)", text) + self.assertNotIn("Beta](#beta)", text) + self.assertEqual(_snapshot_tree(root).keys(), before.keys()) + + def test_toc_multi_file_mixed_results(self): + with TemporaryDirectory() as td: + root = Path(td) + first = root / "first.md" + second = root / "second.md" + first.write_text("# One\n\n## Alpha\n", encoding="utf-8") + second.write_text("# Two\n\n\n- [Two](#two)\n\n", encoding="utf-8") + + rc, out, stderr = _run_main_json(["toc", str(first), str(second)], cwd=root) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "OK") + statuses = {item["file"]: item["status"] for item in out["results"]} + self.assertEqual(statuses[first.resolve().as_posix()], "UPDATED") + self.assertIn(statuses[second.resolve().as_posix()], {"UNCHANGED", "UPDATED"}) + + def test_pdsl_stdin_mode_pass_is_read_only(self): + with TemporaryDirectory() as td: + root = Path(td) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + ["pdsl", "validate", "-"], + cwd=root, + stdin_text=VALID_PDSL, + ) + + self.assertEqual(rc, 0, stderr) + self.assertTrue(out["ok"]) + self.assertEqual(out["summary"]["error_count"], 0) + self.assertEqual(_snapshot_tree(root), before) + + def test_pdsl_file_mode_pass_is_read_only(self): + with TemporaryDirectory() as td: + root = Path(td) + source = root / "prompt.md" + source.write_text("```pdsl\n" + VALID_PDSL + "\n```\n", encoding="utf-8") + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + ["pdsl", "validate", str(source)], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertTrue(out["ok"]) + self.assertEqual(out["results"][0]["status"], "PASS") + self.assertEqual(_snapshot_tree(root), before) + + def test_pdsl_verbose_fail_contract_reports_findings(self): + with TemporaryDirectory() as td: + root = Path(td) + invalid = "UNIT Demo\n\nDO:\n - MUST not use old keyword\n" + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + ["pdsl", "validate", "--text", invalid, "--verbose"], + cwd=root, + ) + + self.assertEqual(rc, 2, stderr) + self.assertFalse(out["ok"]) + self.assertGreater(out["summary"]["finding_count"], 0) + self.assertTrue(any("context" in finding for finding in out["results"][0]["findings"])) + self.assertEqual(_snapshot_tree(root), before) + + def test_pdsl_read_error_contract(self): + with TemporaryDirectory() as td: + root = Path(td) + missing = root / "missing.md" + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + ["pdsl", "validate", str(missing)], + cwd=root, + ) + + self.assertEqual(rc, 1, stderr) + self.assertFalse(out["ok"]) + self.assertEqual(out["results"][0]["status"], "ERROR") + self.assertEqual(out["results"][0]["errors"][0]["kind"], "READ_ERROR") + self.assertEqual(_snapshot_tree(root), before) + + def test_kit_install_dry_run_public_cli_writes_no_files(self): + with TemporaryDirectory() as td: + root = Path(td) + cache = _make_cache(root) + project_root = root / "proj" + kit_src = _make_local_kit_source(root, "drykit") + _init_project(project_root, cache) + before = _snapshot_tree(project_root) + + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(kit_src), + "--dry-run", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "DRY_RUN") + self.assertEqual(out["kit"], "drykit") + self.assertEqual(out["version"], "1.2.3") + self.assertEqual( + Path(out["target"]).resolve(), + (project_root / ".bootstrap" / "config" / "kits" / "drykit").resolve(), + ) + self.assertFalse((project_root / ".bootstrap" / "config" / "kits" / "drykit").exists()) + self.assertEqual(_snapshot_tree(project_root), before) + + def test_kit_install_public_cli_copies_expected_files_and_core_entry(self): + with TemporaryDirectory() as td: + root = Path(td) + cache = _make_cache(root) + project_root = root / "proj" + kit_src = _make_local_kit_source(root, "demo") + _init_project(project_root, cache) + + with patch("sys.stdin.isatty", return_value=False): + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kit"], "demo") + self.assertEqual(out["install_mode"], "copy") + + installed_root = project_root / ".bootstrap" / "config" / "kits" / "demo" + skill_path = installed_root / "SKILL.md" + constraints_path = installed_root / "constraints.toml" + template_path = installed_root / "artifacts" / "FEATURE" / "template.md" + core_toml_path = project_root / ".bootstrap" / "config" / "core.toml" + + self.assertEqual( + skill_path.read_text(encoding="utf-8"), + "---\nname: demo\ndescription: Test kit\n---\n# demo\nkit body\n", + ) + self.assertEqual( + constraints_path.read_text(encoding="utf-8"), + "[naming]\npattern = 'demo-*'\n", + ) + self.assertEqual( + template_path.read_text(encoding="utf-8"), + "# demo feature\n", + ) + core_toml = core_toml_path.read_text(encoding="utf-8") + self.assertIn('[kits.demo]', core_toml) + self.assertIn('path = "config/kits/demo"', core_toml) + + def test_kit_install_public_cli_ignored_tracking_updates_gitignore_narrowly(self): + with TemporaryDirectory() as td: + root = Path(td) + cache = _make_cache(root) + project_root = root / "proj" + kit_src = _make_local_kit_source(root, "ignoredkit") + _init_project(project_root, cache) + + with patch("studio.commands.kit._prompt_git_tracking_for_installed_kit", return_value="ignored"), patch( + "sys.stdin.isatty", + return_value=True, + ): + rc, out, stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(kit_src), + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + gitignore_text = (project_root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".bootstrap/config/kits/ignoredkit/", gitignore_text) + self.assertNotIn(".bootstrap/config/kits/\n", gitignore_text) + self.assertEqual( + (project_root / ".bootstrap" / "config" / "kits" / "ignoredkit" / "SKILL.md").read_text( + encoding="utf-8", + ), + "---\nname: ignoredkit\ndescription: Test kit\n---\n# ignoredkit\nkit body\n", + ) + + def test_kit_update_public_cli_dry_run_from_local_path_is_read_only(self): + with TemporaryDirectory() as td: + root = Path(td) + cache = _make_cache(root) + project_root = root / "proj" + kit_src = _make_local_kit_source(root, "updatekit") + _init_project(project_root, cache) + + with patch("sys.stdin.isatty", return_value=False): + install_rc, install_out, install_stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(install_rc, 0, install_stderr) + self.assertEqual(install_out["status"], "PASS") + + installed_skill = project_root / ".bootstrap" / "config" / "kits" / "updatekit" / "SKILL.md" + before = _snapshot_tree(project_root) + + (kit_src / "SKILL.md").write_text( + "---\nname: updatekit\ndescription: Test kit\n---\n# updatekit\nchanged upstream\n", + encoding="utf-8", + ) + (kit_src / "artifacts" / "FEATURE" / "template.md").write_text( + "# updated feature\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + [ + "kit", + "update", + "--path", + str(kit_src), + "--dry-run", + "--no-interactive", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_updated"], 0) + self.assertEqual(out["results"][0]["kit"], "updatekit") + self.assertEqual(out["results"][0]["action"], "dry_run") + self.assertEqual( + installed_skill.read_text(encoding="utf-8"), + "---\nname: updatekit\ndescription: Test kit\n---\n# updatekit\nkit body\n", + ) + self.assertEqual(_snapshot_tree(project_root), before) + + def test_generate_agents_public_cli_ignores_manifest_kit_public_skill_and_agent_proxy(self): + with TemporaryDirectory() as td: + root = Path(td) + cache = _make_cache(root) + project_root = root / "proj" + kit_src = _make_public_manifest_kit_source(root, "pubkit") + _init_project(project_root, cache, agent_tracking="ignored") + + with patch("sys.stdin.isatty", return_value=False): + install_rc, install_out, install_stderr = _run_main_json( + [ + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(install_rc, 0, install_stderr) + self.assertEqual(install_out["status"], "PASS") + + rc, out, stderr = _run_main_json( + [ + "generate-agents", + "--agent", + "cursor", + ], + cwd=project_root, + ) + + self.assertEqual(rc, 0, stderr) + cursor_result = out.get("results", {}).get("cursor", out) + self.assertEqual(cursor_result.get("status"), "PASS") + + skill_file = project_root / ".agents" / "skills" / "cf-pubkit-helper" / "SKILL.md" + agent_file = project_root / ".cursor" / "agents" / "cf-pubkit-reviewer.mdc" + gitignore_lines = (project_root / ".gitignore").read_text(encoding="utf-8").splitlines() + + self.assertTrue(skill_file.is_file()) + self.assertTrue(agent_file.is_file()) + + self.assertIn(".agents/skills/cf-pubkit-helper/SKILL.md", gitignore_lines) + self.assertIn(".cursor/agents/cf-pubkit-reviewer.mdc", gitignore_lines) + self.assertNotIn(".agents/skills/cf-pubkit-helper/", gitignore_lines) + self.assertNotIn(".cursor/agents/", gitignore_lines) + + self.assertIn("name: cf-pubkit-helper", skill_file.read_text(encoding="utf-8")) + agent_text = agent_file.read_text(encoding="utf-8") + self.assertIn("cf-pubkit-reviewer", agent_text) + self.assertIn("Generated by cf agents -- do not edit", agent_text) + self.assertIn("# Reviewer", agent_text) + + def test_kit_migrate_public_cli_is_deprecated_and_non_mutating(self): + with TemporaryDirectory() as td: + root = Path(td) + before = _snapshot_tree(root) + + rc, stdout, stderr = _run_main(["kit", "migrate"], cwd=root) + + self.assertEqual(rc, 1) + self.assertEqual(stdout, "") + self.assertIn("WARNING: 'cfs kit migrate' is deprecated.", stderr) + self.assertIn("Use 'cfs kit update ' instead.", stderr) + self.assertEqual(_snapshot_tree(root), before) diff --git a/tests/test_cli_map_public_e2e.py b/tests/test_cli_map_public_e2e.py new file mode 100644 index 00000000..1df6782c --- /dev/null +++ b/tests/test_cli_map_public_e2e.py @@ -0,0 +1,257 @@ +"""Public CLI end-to-end coverage for `cfs map`.""" + +from __future__ import annotations + +import io +import json +import os +import sys +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "map" +REPO_BASIC = FIXTURES / "repo-basic" +REPO_NO_REGISTRY = FIXTURES / "repo-no-registry" +REPO_DANGLING = FIXTURES / "repo-dangling" +REPO_FEDERATED_MAIN = FIXTURES / "repo-federated" / "main" + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str, bool]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + try: + exit_code = main(argv) + exited = False + except SystemExit as exc: + exit_code = int(exc.code) + exited = True + return exit_code, stdout.getvalue(), stderr.getvalue(), exited + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _changed_paths( + before: dict[str, tuple[str, bytes | None]], + after: dict[str, tuple[str, bytes | None]], +) -> set[str]: + return {path for path in set(before) | set(after) if before.get(path) != after.get(path)} + + +def test_map_html_default_writes_sidecar_and_summary(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + out_dir.mkdir() + out_file = out_dir / "map.html" + before = _snapshot_tree(out_dir) + + exit_code, stdout, stderr, exited = _run_main(["map", "--out", str(out_file)], cwd=REPO_BASIC) + + after = _snapshot_tree(out_dir) + assert exited is False + assert exit_code == 0 + assert stderr == "" + assert _changed_paths(before, after) == {"map.html", "map.html.js"} + + html_text = out_file.read_text(encoding="utf-8") + sidecar_path = out_dir / "map.html.js" + sidecar_text = sidecar_path.read_text(encoding="utf-8") + assert '' in html_text + assert "window.MAP_DATA =" not in html_text + assert sidecar_text.startswith("window.MAP_DATA = ") + payload = json.loads(sidecar_text.removeprefix("window.MAP_DATA = ").rstrip(";\n")) + assert payload["version"] == "1.0" + assert {node["kind"] for node in payload["nodes"]} == {"markdown", "source"} + + expected_stdout = "\n".join( + [ + "Config : (none)", + "Mode : single-repo (1 reachable, 0 unreachable)", + "Source scan : artifacts.toml: 1 systems, 0 DOCS-ONLY", + "Scanned : 3 markdown, 2 source files", + "Edges : 2 file-link, 1 cpt-doc, 2 cpt-impl", + "Phantom IDs : 0 dangling cpt uses", + f"Wrote : {out_file}", + f" {sidecar_path}", + "", + ] + ) + assert stdout == expected_stdout + + +def test_map_html_inline_data_avoids_sidecar_and_limits_writes(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + out_dir.mkdir() + out_file = out_dir / "inline.html" + before = _snapshot_tree(out_dir) + + exit_code, stdout, stderr, exited = _run_main( + ["map", "--inline-data", "--out", str(out_file)], + cwd=REPO_BASIC, + ) + + after = _snapshot_tree(out_dir) + assert exited is False + assert exit_code == 0 + assert stderr == "" + assert _changed_paths(before, after) == {"inline.html"} + + html_text = out_file.read_text(encoding="utf-8") + assert "window.MAP_DATA =" in html_text + assert '' not in html_text + assert not (out_dir / "inline.html.js").exists() + assert stdout.endswith(f"Wrote : {out_file}\n") + assert "map.html.js" not in stdout + + +def test_map_json_no_registry_warns_and_writes_only_json(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + out_dir.mkdir() + out_file = out_dir / "noreg.json" + before = _snapshot_tree(out_dir) + + exit_code, stdout, stderr, exited = _run_main( + ["map", "--format", "json", "--out", str(out_file)], + cwd=REPO_NO_REGISTRY, + ) + + after = _snapshot_tree(out_dir) + assert exited is False + assert exit_code == 0 + assert stderr == "map: no artifacts.toml found via adapter resolution; source scanning disabled\n" + assert _changed_paths(before, after) == {"noreg.json"} + + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert payload["version"] == "1.0" + assert {node["kind"] for node in payload["nodes"]} == {"markdown"} + assert payload["scan"]["artifacts_toml"] is None + assert [source["name"] for source in payload["workspace"]["sources"]] == ["local"] + assert stdout == "\n".join( + [ + "Config : (none)", + "Mode : single-repo (1 reachable, 0 unreachable)", + "Source scan : artifacts.toml: 0 systems, 0 DOCS-ONLY", + "Scanned : 2 markdown, 0 source files", + "Edges : 1 file-link, 0 cpt-doc, 0 cpt-impl", + "Phantom IDs : 0 dangling cpt uses", + f"Wrote : {out_file}", + "", + ] + ) + + +def test_map_json_federated_includes_workspace_source_nodes(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + out_dir.mkdir() + out_file = out_dir / "fed.json" + before = _snapshot_tree(out_dir) + + exit_code, stdout, stderr, exited = _run_main( + ["map", "--format", "json", "--out", str(out_file)], + cwd=REPO_FEDERATED_MAIN, + ) + + after = _snapshot_tree(out_dir) + assert exited is False + assert exit_code == 0 + assert stderr == "" + assert _changed_paths(before, after) == {"fed.json"} + + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert payload["version"] == "1.0" + assert [source["name"] for source in payload["workspace"]["sources"]] == ["local", "kits"] + assert {node["source"] for node in payload["nodes"]} == {"local", "kits"} + assert stdout == "\n".join( + [ + "Config : (none)", + "Mode : federated (2 reachable, 0 unreachable)", + "Source scan : artifacts.toml: 1 systems, 0 DOCS-ONLY", + "Scanned : 1 markdown, 1 source files", + "Edges : 0 file-link, 0 cpt-doc, 1 cpt-impl", + "Phantom IDs : 0 dangling cpt uses", + f"Wrote : {out_file}", + "", + ] + ) + + +def test_map_json_dangling_reports_phantom_ids(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + out_dir.mkdir() + out_file = out_dir / "dangling.json" + before = _snapshot_tree(out_dir) + + exit_code, stdout, stderr, exited = _run_main( + ["map", "--format", "json", "--out", str(out_file)], + cwd=REPO_DANGLING, + ) + + after = _snapshot_tree(out_dir) + assert exited is False + assert exit_code == 0 + assert stderr == "" + assert _changed_paths(before, after) == {"dangling.json"} + + payload = json.loads(out_file.read_text(encoding="utf-8")) + assert any(node["kind"] == "phantom-cpt" for node in payload["nodes"]) + assert sum(1 for edge in payload["edges"] if edge["type"] == "cpt-impl") == 1 + assert stdout == "\n".join( + [ + "Config : (none)", + "Mode : single-repo (1 reachable, 0 unreachable)", + "Source scan : artifacts.toml: 1 systems, 0 DOCS-ONLY", + "Scanned : 1 markdown, 1 source files", + "Edges : 0 file-link, 0 cpt-doc, 1 cpt-impl", + "Phantom IDs : 1 dangling cpt uses", + f"Wrote : {out_file}", + "", + ] + ) + + +def test_map_invalid_config_exits_2_without_output_mutation(tmp_path: Path) -> None: + out_dir = tmp_path / "out" + out_dir.mkdir() + config_path = out_dir / "bad.toml" + config_path.write_bytes(b"\xff\xfe invalid toml [[[\n") + out_file = out_dir / "bad.json" + before = _snapshot_tree(out_dir) + + exit_code, stdout, stderr, exited = _run_main( + ["map", "--format", "json", "--config", str(config_path), "--out", str(out_file)], + cwd=REPO_BASIC, + ) + + after = _snapshot_tree(out_dir) + assert exited is True + assert exit_code == 2 + assert stdout == "" + assert not out_file.exists() + assert _changed_paths(before, after) == set() + assert stderr.startswith(f"map: invalid {config_path}: ") + diff --git a/tests/test_cli_navigation_e2e.py b/tests/test_cli_navigation_e2e.py new file mode 100644 index 00000000..a7c8f246 --- /dev/null +++ b/tests/test_cli_navigation_e2e.py @@ -0,0 +1,527 @@ +"""Public CLI end-to-end coverage for navigation/read commands.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main +from studio.utils import toml_utils + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = main(argv) + return exit_code, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _changed_paths( + before: dict[str, tuple[str, bytes | None]], + after: dict[str, tuple[str, bytes | None]], +) -> set[str]: + return {path for path in set(before) | set(after) if before.get(path) != after.get(path)} + + +def _write_root_agents(root: Path, adapter_rel: str) -> None: + (root / "AGENTS.md").write_text( + ( + "\n" + "```toml\n" + f'cf-studio-path = "{adapter_rel}"\n' + "```\n" + "\n" + ), + encoding="utf-8", + ) + + +def _bootstrap_navigation_project(root: Path, *, adapter_rel: str = "adapter") -> None: + (root / ".git").mkdir(parents=True, exist_ok=True) + _write_root_agents(root, adapter_rel) + + adapter = root / adapter_rel + config_dir = adapter / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + + toml_utils.dump( + { + "version": "1.0", + "project_root": "..", + "kits": {"test": {"format": "CFS", "path": "kits/test"}}, + }, + config_dir / "core.toml", + ) + toml_utils.dump( + { + "version": "1.0", + "project_root": "..", + "kits": {"test": {"format": "CFS", "path": "kits/test"}}, + "systems": [ + { + "name": "Web", + "slug": "web", + "kit": "test", + "artifacts": [ + {"path": "architecture/PRD.md", "kind": "PRD", "traceability": "FULL"}, + {"path": "architecture/DESIGN.md", "kind": "DESIGN", "traceability": "FULL"}, + {"path": "architecture/FEATURE.md", "kind": "FEATURE", "traceability": "FULL"}, + ], + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + }, + config_dir / "artifacts.toml", + ) + toml_utils.dump( + { + "artifacts": { + "PRD": {"identifiers": {"item": {"template": "cpt-{system}-item-{slug}"}}}, + "DESIGN": {"identifiers": {"item": {"template": "cpt-{system}-item-{slug}"}}}, + "FEATURE": {"identifiers": {"item": {"template": "cpt-{system}-item-{slug}"}}}, + }, + }, + root / "kits" / "test" / "constraints.toml", + ) + + architecture = root / "architecture" + architecture.mkdir(parents=True, exist_ok=True) + (architecture / "PRD.md").write_text( + "- [x] `p1` - **ID**: `cpt-web-item-login`\n" + "- [x] `p2` - `cpt-web-item-login`: referenced from requirement\n", + encoding="utf-8", + ) + (architecture / "DESIGN.md").write_text( + "- [x] `p1` - **ID**: `cpt-web-item-login`\n" + "Design details.\n", + encoding="utf-8", + ) + (architecture / "FEATURE.md").write_text( + "# Feature\n\n" + "### cpt-web-item-scope\n" + "alpha\n" + "beta\n\n" + "### cpt-web-item-other\n" + "gamma\n", + encoding="utf-8", + ) + + code_file = root / "src" / "web" / "handlers.py" + code_file.parent.mkdir(parents=True, exist_ok=True) + code_file.write_text( + "# @cpt-begin:cpt-web-flow-login:p1:inst-validate\n" + "def validate():\n" + " return True\n" + "# @cpt-end:cpt-web-flow-login:p1:inst-validate\n", + encoding="utf-8", + ) + + +def _bootstrap_workspace_source(root: Path, *, source_id: str, source_name: str) -> None: + _bootstrap_navigation_project(root, adapter_rel=".bootstrap") + prd = root / "architecture" / "PRD.md" + prd.write_text( + f"- [x] `p1` - **ID**: `{source_id}`\n" + f"- [x] `p2` - `{source_id}`: referenced from {source_name}\n", + encoding="utf-8", + ) + (root / "architecture" / "DESIGN.md").unlink() + (root / "architecture" / "FEATURE.md").unlink() + shutil_target = root / "src" / "web" / "handlers.py" + shutil_target.write_text( + f"# @cpt-flow:{source_id}:p1\n" + "def validate():\n" + " return True\n", + encoding="utf-8", + ) + + +class TestCLINavigationE2E(unittest.TestCase): + def test_list_ids_filters_and_negative_paths_are_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_navigation_project(root) + baseline = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "list-ids", "--pattern", "login", "--all"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["count"], 3) + self.assertEqual({item["id"] for item in payload["ids"]}, {"cpt-web-item-login"}) + + exit_code, stdout, stderr = _run_main( + ["--json", "list-ids", "--pattern", r"cpt-web-item-log.*", "--regex", "--all"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["count"], 3) + self.assertEqual({item["id"] for item in payload["ids"]}, {"cpt-web-item-login"}) + + exit_code, stdout, stderr = _run_main( + ["--json", "list-ids", "--kind", "item", "--all"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["count"], 3) + self.assertTrue(all(item["kind"] == "item" for item in payload["ids"])) + + exit_code, stdout, stderr = _run_main( + ["--json", "list-ids", "--source", "docs-repo"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["message"], "--source requires a workspace context") + + unregistered = root / "architecture" / "UNREGISTERED.md" + unregistered.write_text("- [x] `p1` - **ID**: `cpt-web-item-untracked`\n", encoding="utf-8") + with_unregistered = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "list-ids", "--artifact", str(unregistered)], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, with_unregistered) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["message"], "Artifact not registered in Constructor Studio registry.") + + def test_single_project_matrix_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_navigation_project(root) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "list-ids", "--include-code", "--all"], + cwd=root, + ) + after_list_ids = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after_list_ids, before) + + list_payload = json.loads(stdout) + self.assertEqual(list_payload["count"], 4) + self.assertEqual(list_payload["artifacts_scanned"], 3) + self.assertEqual(list_payload["code_files_scanned"], 1) + ids = [(item["id"], item["type"], Path(item["artifact"]).name) for item in list_payload["ids"]] + self.assertEqual( + ids, + [ + ("cpt-web-flow-login", "code_reference", "handlers.py"), + ("cpt-web-item-login", "definition", "PRD.md"), + ("cpt-web-item-login", "definition", "DESIGN.md"), + ("cpt-web-item-login", "reference", "PRD.md"), + ], + ) + + baseline = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main(["--json", "list-id-kinds"], cwd=root) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + kinds_payload = json.loads(stdout) + self.assertEqual(kinds_payload["artifacts_scanned"], 3) + self.assertEqual(kinds_payload["kinds"], ["item"]) + self.assertEqual(kinds_payload["kind_counts"]["item"], 2) + self.assertEqual(kinds_payload["kind_to_templates"]["item"], ["DESIGN", "PRD"]) + self.assertEqual(kinds_payload["template_to_kinds"]["DESIGN"], ["item"]) + self.assertEqual(kinds_payload["template_to_kinds"]["PRD"], ["item"]) + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "get-content", + "--artifact", + str(root / "architecture" / "FEATURE.md"), + "--id", + "cpt-web-item-scope", + ], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + content_payload = json.loads(stdout) + self.assertEqual(content_payload["status"], "FOUND") + self.assertEqual(content_payload["id"], "cpt-web-item-scope") + self.assertEqual(content_payload["kind"], "FEATURE") + self.assertEqual(content_payload["system"], "Web") + self.assertEqual(content_payload["start_line"], 4) + self.assertEqual(content_payload["end_line"], 5) + self.assertEqual(content_payload["text"], "alpha\nbeta") + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "get-content", + "--code", + str(root / "src" / "web" / "handlers.py"), + "--id", + "cpt-web-flow-login", + "--inst", + "validate", + ], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + code_payload = json.loads(stdout) + self.assertEqual(code_payload["status"], "FOUND") + self.assertEqual(code_payload["id"], "cpt-web-flow-login") + self.assertEqual(code_payload["inst"], "validate") + self.assertIn("def validate():", code_payload["text"]) + + exit_code, stdout, stderr = _run_main( + ["--json", "where-defined", "--id", "cpt-web-item-login"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + where_defined_payload = json.loads(stdout) + self.assertEqual(where_defined_payload["status"], "AMBIGUOUS") + self.assertEqual(where_defined_payload["count"], 2) + self.assertEqual( + [(Path(item["artifact"]).name, item["artifact_type"]) for item in where_defined_payload["definitions"]], + [("PRD.md", "PRD"), ("DESIGN.md", "DESIGN")], + ) + + exit_code, stdout, stderr = _run_main( + ["--json", "where-used", "--id", "cpt-web-item-login"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + where_used_payload = json.loads(stdout) + self.assertEqual(where_used_payload["count"], 1) + self.assertEqual(where_used_payload["references"][0]["type"], "reference") + self.assertEqual(Path(where_used_payload["references"][0]["artifact"]).name, "PRD.md") + + exit_code, stdout, stderr = _run_main( + ["--json", "where-used", "--id", "cpt-web-item-login", "--include-definitions"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + where_used_defs_payload = json.loads(stdout) + self.assertEqual(where_used_defs_payload["count"], 3) + self.assertEqual( + [(Path(item["artifact"]).name, item["type"]) for item in where_used_defs_payload["references"]], + [("DESIGN.md", "definition"), ("PRD.md", "definition"), ("PRD.md", "reference")], + ) + + def test_where_defined_negative_and_positional_id_paths_are_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_navigation_project(root) + baseline = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "where-defined", "--id", "cpt-web-item-missing"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "NOT_FOUND") + self.assertEqual(payload["count"], 0) + self.assertEqual(payload["id"], "cpt-web-item-missing") + + exit_code, stdout, stderr = _run_main( + ["--json", "where-defined", "cpt-web-item-login"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "AMBIGUOUS") + self.assertEqual(payload["count"], 2) + self.assertEqual(payload["id"], "cpt-web-item-login") + + def test_where_used_scope_positional_and_no_match_paths_are_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_navigation_project(root) + baseline = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "where-used", + "--artifact", + str(root / "architecture" / "PRD.md"), + "--id", + "cpt-web-item-login", + "--include-definitions", + ], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["count"], 2) + self.assertEqual( + [(Path(item["artifact"]).name, item["type"]) for item in payload["references"]], + [("PRD.md", "definition"), ("PRD.md", "reference")], + ) + + exit_code, stdout, stderr = _run_main( + ["--json", "where-used", "cpt-web-item-login"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["count"], 1) + self.assertEqual(payload["id"], "cpt-web-item-login") + self.assertEqual(payload["references"][0]["type"], "reference") + + exit_code, stdout, stderr = _run_main( + ["--json", "where-used", "--id", "cpt-web-item-missing"], + cwd=root, + ) + after = _snapshot_tree(root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["count"], 0) + self.assertEqual(payload["references"], []) + self.assertEqual(payload["id"], "cpt-web-item-missing") + + def test_workspace_root_source_queries_work_without_local_adapter(self): + with TemporaryDirectory() as tmpdir: + workspace_root = Path(tmpdir) / "workspace-root" + workspace_root.mkdir(parents=True, exist_ok=True) + (workspace_root / ".git").mkdir() + + docs_repo = workspace_root / "docs-repo" + backend_repo = workspace_root / "backend-repo" + _bootstrap_workspace_source(docs_repo, source_id="cpt-docs-item-home", source_name="docs") + _bootstrap_workspace_source(backend_repo, source_id="cpt-backend-item-api", source_name="backend") + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init"], cwd=workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + init_payload = json.loads(stdout) + self.assertEqual(init_payload["status"], "CREATED") + + baseline = _snapshot_tree(workspace_root) + + exit_code, stdout, stderr = _run_main( + ["--json", "list-ids", "--source", "backend-repo"], + cwd=workspace_root, + ) + after = _snapshot_tree(workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + list_payload = json.loads(stdout) + self.assertEqual(list_payload["count"], 1) + self.assertEqual(list_payload["artifacts_scanned"], 1) + self.assertEqual(list_payload["ids"][0]["id"], "cpt-backend-item-api") + self.assertTrue(list_payload["ids"][0]["artifact"].endswith("backend-repo/architecture/PRD.md")) + + exit_code, stdout, stderr = _run_main( + ["--json", "where-defined", "--id", "cpt-backend-item-api"], + cwd=workspace_root, + ) + after = _snapshot_tree(workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + where_defined_payload = json.loads(stdout) + self.assertEqual(where_defined_payload["status"], "FOUND") + self.assertEqual(where_defined_payload["count"], 1) + self.assertEqual(where_defined_payload["definitions"][0]["source"], "backend-repo") + + exit_code, stdout, stderr = _run_main( + ["--json", "where-used", "--id", "cpt-backend-item-api", "--include-definitions"], + cwd=workspace_root, + ) + after = _snapshot_tree(workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + where_used_payload = json.loads(stdout) + self.assertEqual(where_used_payload["count"], 2) + self.assertEqual( + [(Path(item["artifact"]).name, item["type"], item.get("source")) for item in where_used_payload["references"]], + [("PRD.md", "definition", "backend-repo"), ("PRD.md", "reference", "backend-repo")], + ) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_router_e2e.py b/tests/test_cli_router_e2e.py new file mode 100644 index 00000000..e09565e2 --- /dev/null +++ b/tests/test_cli_router_e2e.py @@ -0,0 +1,75 @@ +"""Public CLI router smoke tests.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(argv) + return rc, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +class TestCLIRouterE2E(unittest.TestCase): + def test_help_json_contract(self): + with TemporaryDirectory() as td: + root = Path(td) + rc, stdout, stderr = _run_main(["--json", "--help"], cwd=root) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertEqual(payload["usage"], "cfs [options]") + self.assertIn("validate", payload["commands"]) + self.assertIn("Workspace", payload["sections"]) + + def test_unknown_command_errors_cleanly(self): + with TemporaryDirectory() as td: + root = Path(td) + rc, stdout, stderr = _run_main(["--json", "definitely-missing"], cwd=root) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Unknown command", payload["message"]) + self.assertIn("validate", payload["available"]) + + def test_self_check_alias_routes_to_validate_kits(self): + with TemporaryDirectory() as td: + root = Path(td) + rc, stdout, stderr = _run_main(["--json", "self-check"], cwd=root) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertIn(payload["status"], {"PASS", "OK"}) + self.assertIn("summary", payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_setup_e2e.py b/tests/test_cli_setup_e2e.py new file mode 100644 index 00000000..51d6bb17 --- /dev/null +++ b/tests/test_cli_setup_e2e.py @@ -0,0 +1,582 @@ +""" +End-to-end CLI coverage for setup/config public surfaces. + +These tests stay at the public ``studio.cli.main([...])`` layer and focus on +thin setup/config branches without re-testing deeper command internals already +covered elsewhere. +""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +@contextmanager +def _chdir(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(argv) + return rc, stdout.getvalue(), stderr.getvalue() + + +def _run_main_json(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: + rc, stdout, stderr = _run_main(["--json", *argv], cwd=cwd) + return rc, json.loads(stdout), stderr + + +def _write_toml(path: Path, data: dict) -> None: + from studio.utils import toml_utils + + path.parent.mkdir(parents=True, exist_ok=True) + toml_utils.dump(data, path) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _bootstrap_adapter_project(root: Path, adapter_rel: str = "adapter") -> Path: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + (root / "AGENTS.md").write_text( + f'\n```toml\ncf-studio-path = "{adapter_rel}"\n```\n\n', + encoding="utf-8", + ) + adapter = root / adapter_rel + (adapter / ".core").mkdir(parents=True, exist_ok=True) + (adapter / ".gen").mkdir(parents=True, exist_ok=True) + (adapter / "config").mkdir(parents=True, exist_ok=True) + (adapter / "config" / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + return adapter + + +def _bootstrap_generator_project(root: Path) -> None: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + (root / "config").mkdir(parents=True, exist_ok=True) + (root / "skills" / "cypilot").mkdir(parents=True, exist_ok=True) + (root / "skills" / "cypilot" / "SKILL.md").write_text( + "---\nname: cypilot\ndescription: Test skill\n---\n# Cypilot\n", + encoding="utf-8", + ) + (root / "workflows").mkdir(parents=True, exist_ok=True) + (root / "workflows" / "generate.md").write_text( + "---\ntype: workflow\nname: cypilot-generate\ndescription: Generate\n---\n# Generate\n", + encoding="utf-8", + ) + (root / "workflows" / "analyze.md").write_text( + "---\ntype: workflow\nname: cypilot-analyze\ndescription: Analyze\n---\n# Analyze\n", + encoding="utf-8", + ) + + +class TestInfoAndResolveVarsE2E(unittest.TestCase): + def test_info_no_project_root_returns_not_found_without_writes(self): + with TemporaryDirectory() as td: + root = Path(td) / "plain" + root.mkdir(parents=True, exist_ok=True) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json(["info", "--root", str(root)], cwd=root) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "NOT_FOUND") + self.assertIn("No project root found", out["message"]) + self.assertEqual(_snapshot_tree(root), before) + + def test_info_not_initialized_project_returns_not_found_without_writes(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir() + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json(["info", "--root", str(root)], cwd=root) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "NOT_FOUND") + self.assertIn("not initialized", out["message"]) + self.assertEqual(_snapshot_tree(root), before) + + def test_info_honors_global_json_flag(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + _write_toml( + adapter / "config" / "core.toml", + {"version": "1.0", "project_root": "..", "kits": {}}, + ) + before = _snapshot_tree(root) + gitignore_path = root / ".gitignore" + self.assertFalse(gitignore_path.exists()) + + rc, stdout, _stderr = _run_main( + ["--json", "info", "--root", str(root)], + cwd=root, + ) + + self.assertEqual(rc, 0) + out = json.loads(stdout) + self.assertEqual(out["status"], "FOUND") + self.assertEqual(out["project_root"], root.resolve().as_posix()) + self.assertEqual(out["relative_path"], "adapter") + self.assertTrue(out["has_config"]) + self.assertFalse(gitignore_path.exists()) + self.assertEqual(_snapshot_tree(root), before) + + def test_info_legacy_registry_json_fallback_is_reported(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + (adapter / "artifacts.json").write_text( + json.dumps( + { + "version": "1.0", + "project_root": "..", + "systems": [{"name": "Legacy", "slug": "legacy", "artifacts": []}], + } + ), + encoding="utf-8", + ) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json(["info", "--root", str(root)], cwd=root) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "FOUND") + self.assertTrue(out["artifacts_registry_path"].endswith("artifacts.json")) + self.assertIsNone(out["artifacts_registry_error"]) + self.assertEqual(out["artifacts_registry"]["systems"][0]["slug"], "legacy") + self.assertEqual(_snapshot_tree(root), before) + + def test_resolve_vars_flat_success_via_main(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + _write_toml( + adapter / "config" / "core.toml", + { + "version": "1.0", + "project_root": "..", + "kits": { + "sdlc": { + "resources": { + "adr_template": { + "path": "config/kits/sdlc/artifacts/ADR/template.md", + }, + }, + }, + }, + }, + ) + before = _snapshot_tree(root) + gitignore_path = root / ".gitignore" + self.assertFalse(gitignore_path.exists()) + + rc, stdout, _stderr = _run_main( + ["--json", "resolve-vars", "--root", str(root), "--flat"], + cwd=root, + ) + + self.assertEqual(rc, 0) + out = json.loads(stdout) + self.assertIn("variables", out) + self.assertIn("cf-studio-path", out["variables"]) + self.assertIn("adr_template", out["variables"]) + self.assertTrue( + out["variables"]["adr_template"].endswith( + "config/kits/sdlc/artifacts/ADR/template.md" + ) + ) + self.assertFalse(gitignore_path.exists()) + self.assertEqual(_snapshot_tree(root), before) + + def test_resolve_vars_missing_kit_errors_via_main(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + _write_toml( + adapter / "config" / "core.toml", + {"version": "1.0", "project_root": "..", "kits": {}}, + ) + before = _snapshot_tree(root) + gitignore_path = root / ".gitignore" + self.assertFalse(gitignore_path.exists()) + + rc, stdout, _stderr = _run_main( + ["--json", "resolve-vars", "--root", str(root), "--kit", "missing"], + cwd=root, + ) + + self.assertEqual(rc, 1) + out = json.loads(stdout) + self.assertEqual(out["status"], "ERROR") + self.assertIn("Kit 'missing' not found", out["message"]) + self.assertEqual(out["available_kits"], []) + self.assertFalse(gitignore_path.exists()) + self.assertEqual(_snapshot_tree(root), before) + + def test_resolve_vars_no_project_root_errors_without_writes(self): + with TemporaryDirectory() as td: + root = Path(td) / "plain" + root.mkdir(parents=True, exist_ok=True) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json(["resolve-vars", "--root", str(root)], cwd=root) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "ERROR") + self.assertIn("No project root found", out["message"]) + self.assertEqual(_snapshot_tree(root), before) + + def test_resolve_vars_kit_filter_returns_only_selected_kit_and_system_vars(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + _write_toml( + adapter / "config" / "core.toml", + { + "version": "1.0", + "project_root": "..", + "kits": { + "alpha": { + "resources": { + "alpha_template": {"path": "config/kits/alpha/template.md"}, + }, + }, + "beta": { + "resources": { + "beta_template": {"path": "config/kits/beta/template.md"}, + }, + }, + }, + }, + ) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + ["resolve-vars", "--root", str(root), "--kit", "alpha", "--flat"], + cwd=root, + ) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + self.assertIn("variables", out) + self.assertIn("alpha_template", out["variables"]) + self.assertNotIn("beta_template", out["variables"]) + self.assertIn("cf-studio-path", out["variables"]) + self.assertEqual(_snapshot_tree(root), before) + + def test_resolve_vars_core_parse_warning_is_degraded_but_returns_json(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + (adapter / "config" / "core.toml").write_text( + 'version = "1.0"\nproject_root = ".."\ninvalid = [\n', + encoding="utf-8", + ) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + ["resolve-vars", "--root", str(root), "--flat"], + cwd=root, + ) + + self.assertEqual(rc, 0) + self.assertIn("Failed to parse", stderr) + self.assertIn("core_load_error", out) + self.assertIn("variables", out) + self.assertIn("cf-studio-path", out["variables"]) + self.assertEqual(_snapshot_tree(root), before) + + +class TestUpdateE2E(unittest.TestCase): + def test_update_bare_with_kits_requires_value(self): + with TemporaryDirectory() as td: + root = Path(td) + with _chdir(root), self.assertRaises(SystemExit): + main(["update", "--with-kits"]) + + def test_update_dry_run_accepts_explicit_option_matrix(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + core_toml_path = adapter / "config" / "core.toml" + _write_toml( + core_toml_path, + {"version": "1.0", "project_root": "..", "kits": {}}, + ) + cache_dir = Path(td) / "cache" + cache_dir.mkdir() + before = _snapshot_tree(root) + core_toml_before = core_toml_path.read_text(encoding="utf-8") + gitignore_path = root / ".gitignore" + self.assertFalse(gitignore_path.exists()) + + with patch("studio.commands.update.CACHE_DIR", cache_dir): + rc, stdout, _stderr = _run_main( + [ + "--json", + "update", + "--project-root", + str(root), + "--dry-run", + "--with-kits", + "yes", + "--migrate-from-cypilot", + "no", + "--update-legacy-studio", + "no", + "--no-interactive", + "--yes", + ], + cwd=root, + ) + + self.assertEqual(rc, 0) + out = json.loads(stdout) + self.assertEqual(out["status"], "PASS") + self.assertTrue(out["dry_run"]) + self.assertEqual(out["actions"]["core_toml_metadata"], "dry_run") + self.assertEqual(out["actions"]["gitignore"], "dry_run") + self.assertEqual(out["actions"]["kits"], {}) + self.assertFalse(gitignore_path.exists()) + self.assertEqual(core_toml_path.read_text(encoding="utf-8"), core_toml_before) + self.assertEqual(_snapshot_tree(root), before) + + +class TestAgentsAndGenerateAgentsE2E(unittest.TestCase): + def test_generate_agents_show_layers_legacy_via_main(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + before = _snapshot_tree(root) + gitignore_path = root / ".gitignore" + self.assertFalse(gitignore_path.exists()) + + rc, stdout, _stderr = _run_main( + [ + "--json", + "generate-agents", + "--agent", + "claude", + "--root", + str(root), + "--cf-constructor-root", + str(root), + "--show-layers", + ], + cwd=root, + ) + + self.assertEqual(rc, 0) + out = json.loads(stdout) + self.assertEqual(out["status"], "OK") + self.assertEqual(out["provenance"]["components"], []) + self.assertFalse(gitignore_path.exists()) + self.assertEqual(_snapshot_tree(root), before) + + def test_generate_agents_discover_writes_manifest_via_main(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + (root / ".claude" / "agents").mkdir(parents=True, exist_ok=True) + (root / ".claude" / "agents" / "local-reviewer.md").write_text( + "---\ndescription: Local reviewer\n---\n# Reviewer\n", + encoding="utf-8", + ) + before = _snapshot_tree(root) + gitignore_path = root / ".gitignore" + self.assertFalse(gitignore_path.exists()) + + rc, stdout, _stderr = _run_main( + [ + "--json", + "generate-agents", + "--agent", + "claude", + "--root", + str(root), + "--cf-constructor-root", + str(root), + "--discover", + "--yes", + ], + cwd=root, + ) + + self.assertEqual(rc, 0) + out = json.loads(stdout) + self.assertEqual(out["status"], "PASS") + manifest_path = root / "config" / "manifest.toml" + self.assertTrue(manifest_path.exists()) + manifest_text = manifest_path.read_text(encoding="utf-8") + self.assertIn('id = "local-reviewer"', manifest_text) + self.assertIn( + f'source = "{(root / ".claude" / "agents" / "local-reviewer.md").resolve().as_posix()}"', + manifest_text, + ) + self.assertFalse(gitignore_path.exists()) + after = _snapshot_tree(root) + added_paths = sorted(set(after) - set(before)) + removed_paths = sorted(set(before) - set(after)) + changed_paths = sorted( + path for path in set(before) & set(after) if before[path] != after[path] + ) + expected_added_paths = sorted( + [ + ".claude/skills", + ".claude/skills/cf", + ".claude/skills/cf/SKILL.md", + ".claude/skills/cf-analyze", + ".claude/skills/cf-analyze/SKILL.md", + ".claude/skills/cf-explore", + ".claude/skills/cf-explore/SKILL.md", + ".claude/skills/cf-generate", + ".claude/skills/cf-generate/SKILL.md", + ".claude/skills/cf-plan", + ".claude/skills/cf-plan/SKILL.md", + ".claude/skills/cf-workspace", + ".claude/skills/cf-workspace/SKILL.md", + ".claude/skills/cypilot-analyze", + ".claude/skills/cypilot-analyze/SKILL.md", + ".claude/skills/cypilot-generate", + ".claude/skills/cypilot-generate/SKILL.md", + "config/manifest.toml", + ] + ) + self.assertEqual(removed_paths, []) + self.assertEqual(added_paths, expected_added_paths) + self.assertEqual(changed_paths, []) + self.assertFalse((root / ".agents").exists()) + self.assertFalse((root / ".codex").exists()) + + def test_generate_agents_openai_dry_run_via_main(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + + rc, stdout, _stderr = _run_main( + [ + "--json", + "generate-agents", + "--openai", + "--root", + str(root), + "--cf-constructor-root", + str(root), + "--dry-run", + ], + cwd=root, + ) + + self.assertEqual(rc, 0) + out = json.loads(stdout) + self.assertEqual(out["status"], "PASS") + self.assertTrue(out["dry_run"]) + self.assertEqual(out["agents"], ["openai"]) + self.assertIn("openai", out["results"]) + self.assertFalse((root / ".codex").exists()) + self.assertFalse((root / ".agents").exists()) + + def test_generate_agents_yes_remove_cypilot_cleans_legacy_outputs(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + (root / ".claude" / "skills" / "cypilot").mkdir(parents=True, exist_ok=True) + (root / ".claude" / "skills" / "cypilot" / "SKILL.md").write_text( + "# legacy\n", + encoding="utf-8", + ) + (root / ".claude" / "agents").mkdir(parents=True, exist_ok=True) + (root / ".claude" / "agents" / "cypilot-reviewer.md").write_text( + "# legacy agent\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + [ + "generate-agents", + "--agent", + "claude", + "--root", + str(root), + "--cf-constructor-root", + str(root), + "--remove-cypilot", + "yes", + "--yes", + ], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + claude = out["results"]["claude"] + deleted = claude.get("skills", {}).get("deleted", []) + claude.get("subagents", {}).get("deleted", []) + self.assertTrue(any("cypilot" in path for path in deleted)) + self.assertFalse((root / ".claude" / "skills" / "cypilot").exists()) + self.assertFalse((root / ".claude" / "agents" / "cypilot-reviewer.md").exists()) + + def test_agents_openai_flag_stays_read_only(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + + rc, stdout, _stderr = _run_main( + [ + "--json", + "agents", + "--openai", + "--root", + str(root), + "--cf-constructor-root", + str(root), + ], + cwd=root, + ) + + self.assertEqual(rc, 0) + out = json.loads(stdout) + self.assertEqual(out["status"], "OK") + self.assertEqual(out["agents"], ["openai"]) + self.assertIn("openai", out["results"]) + self.assertFalse((root / ".codex").exists()) + self.assertFalse((root / ".agents").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_update_e2e.py b/tests/test_cli_update_e2e.py new file mode 100644 index 00000000..504b5173 --- /dev/null +++ b/tests/test_cli_update_e2e.py @@ -0,0 +1,147 @@ +"""Public CLI e2e coverage for exact `update` command.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main +from studio.utils import toml_utils + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = main(argv) + return exit_code, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _bootstrap_update_project(root: Path, adapter_rel: str = "adapter") -> Path: + (root / ".git").mkdir(parents=True, exist_ok=True) + (root / "AGENTS.md").write_text( + f'\n```toml\ncf-studio-path = "{adapter_rel}"\n```\n\n', + encoding="utf-8", + ) + adapter = root / adapter_rel + (adapter / ".core").mkdir(parents=True, exist_ok=True) + (adapter / ".gen").mkdir(parents=True, exist_ok=True) + (adapter / "config").mkdir(parents=True, exist_ok=True) + (adapter / "config" / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + toml_utils.dump({"version": "1.0", "project_root": "..", "kits": {}}, adapter / "config" / "core.toml") + return adapter + + +class TestCLIUpdateE2E(unittest.TestCase): + def test_update_without_project_root_errors_without_writing(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main(["--json", "update"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("No project root found", payload["message"]) + + def test_update_dry_run_option_matrix_is_non_mutating(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_update_project(root) + core_toml_path = adapter / "config" / "core.toml" + cache_dir = Path(td) / "cache" + cache_dir.mkdir() + before = _snapshot_tree(root) + core_before = core_toml_path.read_text(encoding="utf-8") + + with patch("studio.commands.update.CACHE_DIR", cache_dir): + exit_code, stdout, stderr = _run_main( + [ + "--json", + "update", + "--project-root", + str(root), + "--dry-run", + "--with-kits", + "yes", + "--migrate-from-cypilot", + "no", + "--update-legacy-studio", + "no", + "--no-interactive", + "--yes", + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + self.assertEqual(core_toml_path.read_text(encoding="utf-8"), core_before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertTrue(payload["dry_run"]) + self.assertEqual(payload["actions"]["core_toml_metadata"], "dry_run") + self.assertEqual(payload["actions"]["gitignore"], "dry_run") + self.assertEqual(payload["actions"]["kits"], {}) + self.assertFalse((root / ".gitignore").exists()) + + def test_update_missing_cache_returns_error_without_writing(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_update_project(root) + before = _snapshot_tree(root) + missing_cache = Path(td) / "missing-cache" + + with patch("studio.commands.update.CACHE_DIR", missing_cache): + exit_code, stdout, stderr = _run_main( + ["--json", "update", "--project-root", str(root), "--migrate-from-cypilot", "no", "--update-legacy-studio", "no"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Cache not found", payload["message"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_validate_e2e.py b/tests/test_cli_validate_e2e.py new file mode 100644 index 00000000..df3a2c53 --- /dev/null +++ b/tests/test_cli_validate_e2e.py @@ -0,0 +1,557 @@ +"""Public CLI e2e coverage for exact `validate` command.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main +from studio.utils import toml_utils + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = main(argv) + return exit_code, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _changed_paths( + before: dict[str, tuple[str, bytes | None]], + after: dict[str, tuple[str, bytes | None]], +) -> set[str]: + return {path for path in set(before) | set(after) if before.get(path) != after.get(path)} + + +def _bootstrap_empty_validate_project(root: Path) -> None: + (root / ".git").mkdir(parents=True, exist_ok=True) + (root / "AGENTS.md").write_text( + '\n```toml\ncf-studio-path = "adapter"\n```\n\n', + encoding="utf-8", + ) + config_dir = root / "adapter" / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + toml_utils.dump( + { + "version": "1.0", + "project_root": "..", + "kits": {}, + "systems": [], + }, + config_dir / "artifacts.toml", + ) + + +def _bootstrap_workspace_validate_source(root: Path, *, source_id: str) -> None: + (root / ".git").mkdir(parents=True, exist_ok=True) + (root / "AGENTS.md").write_text( + '\n```toml\ncf-studio-path = ".bootstrap"\n```\n\n', + encoding="utf-8", + ) + cfg = root / ".bootstrap" / "config" + cfg.mkdir(parents=True, exist_ok=True) + (cfg / "AGENTS.md").write_text("# Adapter\n", encoding="utf-8") + for kind in ("PRD", "DESIGN"): + template_dir = root / "kits" / "test" / "artifacts" / kind + template_dir.mkdir(parents=True, exist_ok=True) + (template_dir / "template.md").write_text( + "---\n" + "cypilot-template:\n" + " version:\n" + " major: 1\n" + " minor: 0\n" + f" kind: {kind}\n" + "---\n" + "text\n", + encoding="utf-8", + ) + toml_utils.dump( + { + "version": "1.0", + "project_root": "..", + "kits": {"test": {"format": "CFS", "path": "kits/test"}}, + "systems": [ + { + "name": "Test", + "slug": "test", + "kit": "test", + "artifacts": [ + {"path": "architecture/PRD.md", "kind": "PRD"}, + {"path": "architecture/DESIGN.md", "kind": "DESIGN"}, + ], + }, + ], + }, + cfg / "artifacts.toml", + ) + architecture = root / "architecture" + architecture.mkdir(parents=True, exist_ok=True) + (architecture / "PRD.md").write_text(f"**ID**: `{source_id}`\ncontent\n", encoding="utf-8") + (architecture / "DESIGN.md").write_text(f"ref `{source_id}`\n", encoding="utf-8") + + +def _bootstrap_validate_traceability_project( + root: Path, + *, + prd_body: str, + design_body: str, +) -> tuple[Path, Path]: + _bootstrap_empty_validate_project(root) + for kind in ("PRD", "DESIGN"): + template_dir = root / "kits" / "test" / "artifacts" / kind + template_dir.mkdir(parents=True, exist_ok=True) + (template_dir / "template.md").write_text( + "---\n" + "cypilot-template:\n" + " version:\n" + " major: 1\n" + " minor: 0\n" + f" kind: {kind}\n" + "---\n" + "text\n", + encoding="utf-8", + ) + toml_utils.dump( + { + "version": "1.0", + "project_root": "..", + "kits": {"test": {"format": "CFS", "path": "kits/test"}}, + "systems": [ + { + "name": "Test", + "slug": "test", + "kit": "test", + "artifacts": [ + {"path": "architecture/PRD.md", "kind": "PRD"}, + {"path": "architecture/DESIGN.md", "kind": "DESIGN"}, + ], + }, + ], + }, + root / "adapter" / "config" / "artifacts.toml", + ) + architecture = root / "architecture" + architecture.mkdir(parents=True, exist_ok=True) + prd = architecture / "PRD.md" + design = architecture / "DESIGN.md" + prd.write_text(prd_body, encoding="utf-8") + design.write_text(design_body, encoding="utf-8") + return prd, design + + +class TestCLIValidateE2E(unittest.TestCase): + def test_validate_uninitialized_project_errors_without_writing(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / ".git").mkdir() + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main(["--json", "validate"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Constructor Studio not initialized", payload["message"]) + + def test_validate_no_artifacts_reports_pass_and_only_bootstraps_runtime_files(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_empty_validate_project(root) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main(["--json", "validate"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["artifacts_validated"], 0) + self.assertEqual(payload["error_count"], 0) + self.assertEqual(payload["warning_count"], 0) + self.assertEqual(payload["message"], "No artifacts found in registry") + self.assertFalse((root / ".gitignore").exists()) + + def test_validate_artifact_not_in_registry_returns_error_without_extra_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_empty_validate_project(root) + artifact = root / "architecture" / "PRD.md" + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_text("# PRD\n", encoding="utf-8") + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--artifact", "architecture/PRD.md"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Artifact not in registry", payload["message"]) + self.assertFalse((root / ".gitignore").exists()) + + def test_validate_cross_artifact_reference_failure_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + prd, _design = _bootstrap_validate_traceability_project( + root, + prd_body="**ID**: `cpt-test-aa`\ncontent\n", + design_body="# Design\n(no refs)\n", + ) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--artifact", str(prd), "--skip-code", "--verbose"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["artifacts_validated"], 1) + self.assertEqual(payload["error_count"], 1) + self.assertEqual(payload["warnings"], []) + self.assertEqual(payload["errors"][0]["code"], "id-not-referenced") + self.assertEqual(payload["errors"][0]["id"], "cpt-test-aa") + self.assertEqual(payload["errors"][0]["other_kinds"], ["DESIGN"]) + + def test_validate_cross_artifact_reference_pass_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + prd, _design = _bootstrap_validate_traceability_project( + root, + prd_body="**ID**: `cpt-test-aa`\ncontent\n", + design_body="ref `cpt-test-aa`\n", + ) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--artifact", str(prd), "--skip-code", "--verbose"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["artifacts_validated"], 1) + self.assertEqual(payload["error_count"], 0) + self.assertEqual(payload["warning_count"], 0) + self.assertEqual(payload["errors"], []) + self.assertEqual(payload["warnings"], []) + + def test_validate_output_writes_report_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + prd, _design = _bootstrap_validate_traceability_project( + root, + prd_body="**ID**: `cpt-test-aa`\ncontent\n", + design_body="ref `cpt-test-aa`\n", + ) + report_path = root / "validate-report.json" + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "validate", + "--artifact", + str(prd), + "--skip-code", + "--output", + str(report_path), + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + self.assertEqual(_changed_paths(before, after), {"validate-report.json"}) + payload = json.loads(report_path.read_text(encoding="utf-8")) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["artifacts_validated"], 1) + self.assertEqual(payload["error_count"], 0) + self.assertEqual(payload["warning_count"], 0) + self.assertIn("next_step", payload) + + def test_validate_source_missing_errors_without_writes(self): + with TemporaryDirectory() as tmpdir: + workspace_root = Path(tmpdir) / "workspace-root" + workspace_root.mkdir(parents=True, exist_ok=True) + (workspace_root / ".git").mkdir() + _bootstrap_workspace_validate_source(workspace_root / "docs-repo", source_id="cpt-docs-item-home") + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init"], cwd=workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + + before = _snapshot_tree(workspace_root) + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--source", "missing-source", "--skip-code"], + cwd=workspace_root, + ) + after = _snapshot_tree(workspace_root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Source 'missing-source' not found in workspace", payload["message"]) + + def test_validate_workspace_artifact_source_mismatch_errors_without_writes(self): + with TemporaryDirectory() as tmpdir: + workspace_root = Path(tmpdir) / "workspace-root" + workspace_root.mkdir(parents=True, exist_ok=True) + (workspace_root / ".git").mkdir() + + docs_repo = workspace_root / "docs-repo" + backend_repo = workspace_root / "backend-repo" + _bootstrap_workspace_validate_source(docs_repo, source_id="cpt-docs-item-home") + _bootstrap_workspace_validate_source(backend_repo, source_id="cpt-backend-item-api") + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init"], cwd=workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + + before = _snapshot_tree(workspace_root) + exit_code, stdout, stderr = _run_main( + [ + "--json", + "validate", + "--source", + "docs-repo", + "--artifact", + str(backend_repo / "architecture" / "PRD.md"), + "--skip-code", + ], + cwd=workspace_root, + ) + after = _snapshot_tree(workspace_root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("belongs to source 'backend-repo'", payload["message"]) + self.assertIn("not 'docs-repo'", payload["message"]) + + def test_validate_missing_artifact_path_errors_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_empty_validate_project(root) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--artifact", "architecture/missing.md"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Artifact not found:", payload["message"]) + self.assertIn("architecture/missing.md", payload["message"]) + + def test_validate_local_only_skips_workspace_expansion_branches(self): + with TemporaryDirectory() as tmpdir: + workspace_root = Path(tmpdir) / "workspace-root" + workspace_root.mkdir(parents=True, exist_ok=True) + (workspace_root / ".git").mkdir() + + docs_repo = workspace_root / "docs-repo" + backend_repo = workspace_root / "backend-repo" + _bootstrap_workspace_validate_source(docs_repo, source_id="cpt-docs-item-home") + _bootstrap_workspace_validate_source(backend_repo, source_id="cpt-backend-item-api") + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init"], cwd=workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + + before = _snapshot_tree(workspace_root) + with patch( + "studio.utils.context.WorkspaceContext.get_all_artifact_ids", + side_effect=AssertionError("workspace ID expansion must be skipped under --local-only"), + ), patch( + "studio.commands.validate._collect_cross_repo_artifacts", + side_effect=AssertionError("cross-repo artifact expansion must be skipped under --local-only"), + ): + exit_code, stdout, stderr = _run_main( + [ + "--json", + "validate", + "--source", + "backend-repo", + "--artifact", + str(backend_repo / "architecture" / "PRD.md"), + "--skip-code", + "--local-only", + "--verbose", + ], + cwd=workspace_root, + ) + after = _snapshot_tree(workspace_root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["artifacts_validated"], 1) + self.assertEqual(payload["error_count"], 0) + + def test_validate_self_check_failure_is_surfaced_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + prd, _design = _bootstrap_validate_traceability_project( + root, + prd_body="**ID**: `cpt-test-aa`\ncontent\n", + design_body="ref `cpt-test-aa`\n", + ) + before = _snapshot_tree(root) + + with patch( + "studio.commands.validate_kits.run_validate_kits", + return_value=(2, {"status": "FAIL", "error_count": 1, "errors": [{"code": "kit-bad"}]}), + ): + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--artifact", str(prd), "--skip-code"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 2) + self.assertEqual(after, before) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["message"], "validate-kits failed (kit structure or templates are inconsistent)") + self.assertEqual(payload["validate_kits"]["status"], "FAIL") + self.assertEqual(payload["validate_kits"]["error_count"], 1) + + def test_validate_workspace_config_errors_are_surfaced_in_report(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + prd, _design = _bootstrap_validate_traceability_project( + root, + prd_body="**ID**: `cpt-test-aa`\ncontent\n", + design_body="ref `cpt-test-aa`\n", + ) + (root / ".cf-workspace.toml").write_text( + 'version = "1.0"\n' + "\n" + "[sources.local]\n" + 'path = "."\n' + 'role = "full"\n' + "\n" + "[validation]\n" + 'allowed_content_languages = ["xx_fake"]\n', + encoding="utf-8", + ) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--artifact", str(prd), "--skip-code", "--verbose"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 2) + self.assertEqual(after, before) + self.assertIn("Warning: workspace config error:", stderr) + self.assertIn("xx_fake", stderr) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["artifact_count"], 1) + self.assertEqual(payload["error_count"], 1) + self.assertEqual(payload["warning_count"], 0) + self.assertEqual(payload["errors"][0]["code"], "file-load-error") + self.assertIn("Workspace config error", payload["errors"][0]["message"]) + self.assertIn("xx_fake", payload["errors"][0]["message"]) + + def test_validate_source_works_from_workspace_root_without_local_adapter(self): + with TemporaryDirectory() as tmpdir: + workspace_root = Path(tmpdir) / "workspace-root" + workspace_root.mkdir(parents=True, exist_ok=True) + (workspace_root / ".git").mkdir() + + docs_repo = workspace_root / "docs-repo" + backend_repo = workspace_root / "backend-repo" + _bootstrap_workspace_validate_source(docs_repo, source_id="cpt-docs-item-home") + _bootstrap_workspace_validate_source(backend_repo, source_id="cpt-backend-item-api") + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init"], cwd=workspace_root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + + before = _snapshot_tree(workspace_root) + exit_code, stdout, stderr = _run_main( + ["--json", "validate", "--source", "backend-repo", "--artifact", str(backend_repo / "architecture" / "PRD.md"), "--skip-code", "--verbose"], + cwd=workspace_root, + ) + after = _snapshot_tree(workspace_root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["artifacts_validated"], 1) + self.assertEqual(payload["error_count"], 0) + self.assertEqual(payload["warning_count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_validation_e2e.py b/tests/test_cli_validation_e2e.py new file mode 100644 index 00000000..7ef6f036 --- /dev/null +++ b/tests/test_cli_validation_e2e.py @@ -0,0 +1,772 @@ +"""Public CLI e2e coverage for validation and alias command surfaces.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +import studio.cli as cli + + +def _bootstrap_project(root: Path, *, systems: list[dict] | None = None, kits: dict | None = None) -> Path: + from studio.utils import toml_utils + + (root / ".git").mkdir() + (root / "AGENTS.md").write_text( + '\n```toml\ncf-studio-path = "adapter"\n```\n\n', + encoding="utf-8", + ) + adapter = root / "adapter" + config_dir = adapter / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + (adapter / "kits" / "test").mkdir(parents=True, exist_ok=True) + + registry_kits = kits if kits is not None else {"test": {"format": "CFS", "path": "kits/test"}} + common = { + "version": "1.0", + "project_root": "..", + "kits": registry_kits, + } + toml_utils.dump(common, config_dir / "core.toml") + artifacts = dict(common) + artifacts["systems"] = systems or [] + toml_utils.dump(artifacts, config_dir / "artifacts.toml") + return adapter + + +def _snapshot_files(root: Path) -> dict[str, str]: + return { + str(path.relative_to(root)): path.read_text(encoding="utf-8") + for path in sorted(p for p in root.rglob("*") if p.is_file()) + } + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = cli.main(argv) + return exit_code, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +class TestCLIValidateTocE2E(unittest.TestCase): + def test_validate_toc_pass_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + doc = root / "good.md" + doc.write_text( + "# Title\n\n" + "## Table of Contents\n\n" + "1. [Section](#section)\n\n" + "---\n\n" + "## Section\n", + encoding="utf-8", + ) + before = doc.read_text(encoding="utf-8") + + exit_code, stdout, stderr = _run_main( + ["--json", "validate-toc", "--max-level", "2", str(doc)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["results"][0]["status"], "PASS") + self.assertEqual(doc.read_text(encoding="utf-8"), before) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + self.assertEqual(stderr, "") + + def test_validate_toc_fail_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + doc = root / "bad.md" + doc.write_text("# Title\n\n## Missing TOC\n", encoding="utf-8") + before = doc.read_text(encoding="utf-8") + + exit_code, stdout, stderr = _run_main( + ["--json", "validate-toc", str(doc)], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["results"][0]["status"], "FAIL") + self.assertGreater(payload["error_count"], 0) + self.assertEqual(doc.read_text(encoding="utf-8"), before) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + self.assertEqual(stderr, "") + + def test_validate_toc_missing_file_is_reported_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + missing = root / "missing.md" + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate-toc", str(missing)], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["error_count"], 1) + self.assertEqual(payload["results"][0]["status"], "ERROR") + self.assertEqual(payload["results"][0]["message"], "File not found") + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_validate_toc_warn_only_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + doc = root / "stale.md" + doc.write_text( + "# Title\n\n" + "## Table of Contents\n\n" + "1. [Section B](#section-b)\n" + "2. [Section A](#section-a)\n\n" + "---\n\n" + "## Section A\n\n" + "## Section B\n", + encoding="utf-8", + ) + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate-toc", "--max-level", "2", str(doc)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "WARN") + self.assertEqual(payload["error_count"], 0) + self.assertGreaterEqual(payload["warning_count"], 1) + result = payload["results"][0] + self.assertEqual(result["status"], "WARN") + self.assertIn("warnings", result) + self.assertTrue(any(w["code"] == "toc-stale" for w in result["warnings"])) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_validate_toc_multi_file_mixed_results_are_reported(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + good = root / "good.md" + stale = root / "stale.md" + bad = root / "bad.md" + good.write_text( + "# Title\n\n" + "## Table of Contents\n\n" + "1. [Section](#section)\n\n" + "---\n\n" + "## Section\n", + encoding="utf-8", + ) + stale.write_text( + "# Title\n\n" + "## Table of Contents\n\n" + "1. [Section B](#section-b)\n" + "2. [Section A](#section-a)\n\n" + "---\n\n" + "## Section A\n\n" + "## Section B\n", + encoding="utf-8", + ) + bad.write_text("# Title\n\n## Missing TOC\n", encoding="utf-8") + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate-toc", "--max-level", "2", str(good), str(stale), str(bad)], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["files_validated"], 3) + self.assertEqual(payload["error_count"], 1) + self.assertGreaterEqual(payload["warning_count"], 1) + statuses = {Path(item["file"]).name: item["status"] for item in payload["results"]} + self.assertEqual(statuses, {"good.md": "PASS", "stale.md": "WARN", "bad.md": "FAIL"}) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_validate_toc_verbose_includes_empty_details(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + doc = root / "good.md" + doc.write_text( + "# Title\n\n" + "## Table of Contents\n\n" + "1. [Section](#section)\n\n" + "---\n\n" + "## Section\n", + encoding="utf-8", + ) + + exit_code, stdout, stderr = _run_main( + ["--json", "validate-toc", "--verbose", "--max-level", "2", str(doc)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + result = payload["results"][0] + self.assertEqual(result["errors"], []) + self.assertEqual(result["warnings"], []) + self.assertEqual(stderr, "") + + +class TestCLISpecCoverageE2E(unittest.TestCase): + def test_spec_coverage_filters_systems_without_mutating_sources(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "API", + "slug": "api", + "kit": "test", + "codebase": [{"path": "src/api", "extensions": [".py"]}], + }, + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + api_file = root / "src" / "api" / "app.py" + web_file = root / "src" / "web" / "app.py" + api_file.parent.mkdir(parents=True, exist_ok=True) + web_file.parent.mkdir(parents=True, exist_ok=True) + api_file.write_text("x = 1\n", encoding="utf-8") + web_file.write_text("# @cpt-algo:cpt-web-flow:p1\nx = 1\n", encoding="utf-8") + api_before = api_file.read_text(encoding="utf-8") + web_before = web_file.read_text(encoding="utf-8") + + exit_code, stdout, stderr = _run_main( + ["--json", "spec-coverage", "--system", "web"], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["summary"]["total_files"], 1) + self.assertIn("src/web/app.py", payload["files"]) + self.assertNotIn("src/api/app.py", payload["files"]) + self.assertEqual(api_file.read_text(encoding="utf-8"), api_before) + self.assertEqual(web_file.read_text(encoding="utf-8"), web_before) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertIn('cf-studio-path = "adapter"', (root / "AGENTS.md").read_text(encoding="utf-8")) + self.assertFalse((root / ".gitignore").exists()) + self.assertEqual(stderr, "") + + def test_spec_coverage_unknown_system_fails_without_mutating_sources(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + web_file = root / "src" / "web" / "app.py" + web_file.parent.mkdir(parents=True, exist_ok=True) + web_file.write_text("# @cpt-algo:cpt-web-flow:p1\nx = 1\n", encoding="utf-8") + web_before = web_file.read_text(encoding="utf-8") + + exit_code, stdout, stderr = _run_main( + ["--json", "spec-coverage", "--system", "missing"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["unknown_systems"], ["missing"]) + self.assertEqual(web_file.read_text(encoding="utf-8"), web_before) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + self.assertEqual(stderr, "") + + def test_spec_coverage_output_writes_report_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + source_file = root / "src" / "web" / "app.py" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_text("# @cpt-algo:cpt-web-flow:p1\nx = 1\n", encoding="utf-8") + source_before = source_file.read_text(encoding="utf-8") + report_path = root / "coverage-report.json" + + exit_code, stdout, stderr = _run_main( + ["spec-coverage", "--output", str(report_path)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + self.assertTrue(report_path.exists()) + payload = json.loads(report_path.read_text(encoding="utf-8")) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(source_file.read_text(encoding="utf-8"), source_before) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + + def test_spec_coverage_min_coverage_threshold_fails(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + source_file = root / "src" / "web" / "app.py" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_text( + "# @cpt-begin:cpt-web-flow:p1:inst-a\n" + "x = 1\n" + "# @cpt-end:cpt-web-flow:p1:inst-a\n" + "y = 2\n", + encoding="utf-8", + ) + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "spec-coverage", "--min-coverage", "75"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["summary"]["coverage_pct"], 50.0) + self.assertTrue(any("coverage 50.00% < 75.00%" in item for item in payload["threshold_failures"])) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_spec_coverage_min_file_coverage_threshold_fails(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + low = root / "src" / "web" / "low.py" + high = root / "src" / "web" / "high.py" + low.parent.mkdir(parents=True, exist_ok=True) + low.write_text( + "# @cpt-begin:cpt-web-flow:p1:inst-a\n" + "x = 1\n" + "# @cpt-end:cpt-web-flow:p1:inst-a\n" + "y = 2\n", + encoding="utf-8", + ) + high.write_text( + "# @cpt-begin:cpt-web-flow:p1:inst-a\n" + "x = 1\n" + "y = 2\n" + "# @cpt-end:cpt-web-flow:p1:inst-a\n", + encoding="utf-8", + ) + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "spec-coverage", "--min-file-coverage", "75"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertTrue( + any("file src/web/low.py coverage 50.00% < 75.00%" in item for item in payload["threshold_failures"]) + ) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_spec_coverage_min_granularity_threshold_fails(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + source_file = root / "src" / "web" / "granularity.py" + source_file.parent.mkdir(parents=True, exist_ok=True) + body = "".join(f"line_{i} = {i}\n" for i in range(1, 31)) + source_file.write_text( + "# @cpt-begin:cpt-web-flow:p1:inst-a\n" + f"{body}" + "# @cpt-end:cpt-web-flow:p1:inst-a\n", + encoding="utf-8", + ) + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "spec-coverage", "--min-granularity", "0.5"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertAlmostEqual(payload["summary"]["granularity_score"], 0.3333, places=4) + self.assertTrue(any("granularity 0.3333 < 0.5000" in item for item in payload["threshold_failures"])) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_spec_coverage_min_file_granularity_threshold_fails(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + low = root / "src" / "web" / "low_granularity.py" + high = root / "src" / "web" / "high_granularity.py" + low.parent.mkdir(parents=True, exist_ok=True) + low.write_text( + "# @cpt-begin:cpt-web-flow:p1:inst-a\n" + + "".join(f"line_{i} = {i}\n" for i in range(1, 31)) + + "# @cpt-end:cpt-web-flow:p1:inst-a\n", + encoding="utf-8", + ) + high.write_text( + "# @cpt-begin:cpt-web-flow:p1:inst-a\n" + "x = 1\n" + "# @cpt-end:cpt-web-flow:p1:inst-a\n" + "# @cpt-begin:cpt-web-flow:p1:inst-b\n" + "y = 2\n" + "# @cpt-end:cpt-web-flow:p1:inst-b\n", + encoding="utf-8", + ) + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "spec-coverage", "--min-file-granularity", "0.5"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertTrue( + any( + "file src/web/low_granularity.py granularity 0.3333 < 0.5000" in item + for item in payload["threshold_failures"] + ) + ) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_spec_coverage_verbose_includes_file_details(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + source_file = root / "src" / "web" / "verbose.py" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_text( + "# @cpt-begin:cpt-web-flow:p1:inst-a\n" + "x = 1\n" + "# @cpt-end:cpt-web-flow:p1:inst-a\n" + "y = 2\n", + encoding="utf-8", + ) + + exit_code, stdout, stderr = _run_main( + ["--json", "spec-coverage", "--verbose"], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout) + entry = payload["files"]["src/web/verbose.py"] + self.assertEqual(entry["scope_markers"], 0) + self.assertEqual(entry["block_markers"], 1) + self.assertEqual(entry["covered_ranges"], [[2, 2]]) + self.assertEqual(entry["uncovered_ranges"], [[4, 4]]) + self.assertEqual(stderr, "") + + +class TestCLICheckLanguageE2E(unittest.TestCase): + def test_check_language_uses_default_project_root_and_leaves_doc_unchanged(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project(root) + doc = root / "architecture" / "PRD.md" + doc.parent.mkdir(parents=True, exist_ok=True) + doc.write_text("# PRD\n\nПривет мир\n", encoding="utf-8") + before = doc.read_text(encoding="utf-8") + + exit_code, stdout, stderr = _run_main( + ["--json", "check-language"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["files_scanned"], 1) + self.assertEqual(payload["violation_count"], 1) + self.assertEqual(payload["allowed_languages"], ["en"]) + self.assertEqual(doc.read_text(encoding="utf-8"), before) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + self.assertEqual(stderr, "") + + def test_check_language_override_passes_without_creating_reports(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project(root) + doc = root / "architecture" / "notes.md" + doc.parent.mkdir(parents=True, exist_ok=True) + doc.write_text("# Notes\n\nHello\nПривет\n", encoding="utf-8") + before = doc.read_text(encoding="utf-8") + report_path = root / "language-report.json" + + exit_code, stdout, stderr = _run_main( + ["--json", "check-language", "--languages", "en,ru", str(doc)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(doc.read_text(encoding="utf-8"), before) + self.assertFalse(report_path.exists()) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + self.assertEqual(stderr, "") + + def test_check_language_unknown_language_errors_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project(root) + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "check-language", "--languages", "en,xx_invalid"], + cwd=root, + ) + + self.assertEqual(exit_code, 1) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Unknown language code(s): xx_invalid", payload["message"]) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_check_language_missing_path_errors_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project(root) + missing = root / "architecture" / "missing.md" + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "check-language", "--languages", "en", str(missing)], + cwd=root, + ) + + self.assertEqual(exit_code, 1) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn(str(missing), payload["message"]) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_check_language_ignore_pattern_skips_violations(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project(root) + ignored = root / "architecture" / "translations" / "ru.md" + clean = root / "architecture" / "notes.md" + ignored.parent.mkdir(parents=True, exist_ok=True) + ignored.write_text("# RU\n\nПривет мир\n", encoding="utf-8") + clean.write_text("# Notes\n\nHello world\n", encoding="utf-8") + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "check-language", "--languages", "en", "--ignore", "*/translations/*.md"], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["files_scanned"], 2) + self.assertEqual(payload["violation_count"], 0) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_check_language_quiet_violation_contract(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project(root) + doc = root / "architecture" / "PRD.md" + doc.parent.mkdir(parents=True, exist_ok=True) + doc.write_text("# PRD\n\nПривет мир\n", encoding="utf-8") + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["check-language", "--quiet"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + self.assertNotIn("Allowed languages", stdout) + self.assertNotIn("Files scanned", stdout) + self.assertIn("FAIL", stdout) + self.assertIn("PRD.md", stdout) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + def test_check_language_real_violation_reports_details(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project(root) + doc = root / "architecture" / "PRD.md" + doc.parent.mkdir(parents=True, exist_ok=True) + doc.write_text("# PRD\n\nПривет мир\n", encoding="utf-8") + before = _snapshot_files(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "check-language", str(doc)], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["file_count"], 1) + self.assertEqual(payload["violation_count"], 1) + violation = payload["violations"][0] + self.assertEqual(Path(violation["path"]).name, "PRD.md") + self.assertEqual(violation["line"], 3) + self.assertIn("Привет", violation["preview"]) + self.assertEqual(_snapshot_files(root), before) + self.assertEqual(stderr, "") + + +class TestCLIAliasBehaviorE2E(unittest.TestCase): + def test_aliases_forward_args_and_do_not_create_files_outside_projects(self): + cases = [ + ("validate-code", "_cmd_validate", ["--artifact", "architecture/PRD.md", "--verbose"]), + ("validate-rules", "_cmd_validate_kits", ["kits/test", "--kit", "test", "--verbose"]), + ("self-check", "_cmd_validate_kits", ["--kit", "test", "--verbose"]), + ] + for alias, target_name, forwarded_args in cases: + with self.subTest(alias=alias): + seen: dict[str, object] = {} + + def _fake(argv: list[str]) -> int: + from studio.utils.ui import is_json_mode + + seen["argv"] = argv + seen["json_mode"] = is_json_mode() + return 17 + + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + before_entries = sorted(p.name for p in root.iterdir()) + with patch.object(cli, target_name, side_effect=_fake): + exit_code, stdout, stderr = _run_main( + ["--json", alias, *forwarded_args], + cwd=root, + ) + + self.assertEqual(exit_code, 17) + self.assertEqual(seen["argv"], forwarded_args) + self.assertTrue(seen["json_mode"]) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + self.assertEqual(sorted(p.name for p in root.iterdir()), before_entries) + self.assertFalse((root / "CLAUDE.md").exists()) + self.assertFalse((root / ".gitignore").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_workspace_diag_e2e.py b/tests/test_cli_workspace_diag_e2e.py new file mode 100644 index 00000000..e7a12e11 --- /dev/null +++ b/tests/test_cli_workspace_diag_e2e.py @@ -0,0 +1,1085 @@ +"""Public CLI e2e coverage for workspace, delegate, and doctor surfaces.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main +from studio.utils import toml_utils + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with redirect_stdout(stdout), redirect_stderr(stderr): + exit_code = main(argv) + return exit_code, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _changed_paths(before: dict[str, tuple[str, bytes | None]], after: dict[str, tuple[str, bytes | None]]) -> set[str]: + return {path for path in set(before) | set(after) if before.get(path) != after.get(path)} + + +def _write_root_agents(root: Path, adapter_rel: str) -> None: + (root / "AGENTS.md").write_text( + ( + "\n" + "```toml\n" + f'cf-studio-path = "{adapter_rel}"\n' + "```\n" + "\n" + ), + encoding="utf-8", + ) + + +def _make_repo(root: Path, *, with_git: bool = True) -> None: + root.mkdir(parents=True, exist_ok=True) + if with_git: + (root / ".git").mkdir(exist_ok=True) + + +def _make_adapter_repo(root: Path, *, adapter_rel: str = ".bootstrap", role_dir: str = "architecture") -> None: + _make_repo(root) + _write_root_agents(root, adapter_rel) + adapter = root / adapter_rel / "config" + adapter.mkdir(parents=True, exist_ok=True) + (adapter / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + (root / role_dir).mkdir(parents=True, exist_ok=True) + + +def _write_core_config(root: Path, text: str, *, adapter_rel: str = ".bootstrap") -> Path: + _write_root_agents(root, adapter_rel) + config_path = root / adapter_rel / "config" / "core.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(text, encoding="utf-8") + return config_path + + +class TestCLIWorkspaceE2E(unittest.TestCase): + def test_workspace_init_inline_writes_core_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + _write_core_config(root, '[project]\nname = "workspace-root"\n') + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main(["--json", "workspace-init", "--inline"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(_changed_paths(before, after), {".bootstrap/config/core.toml"}) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CREATED") + self.assertEqual(payload["config_path"], str((root / ".bootstrap" / "config" / "core.toml").resolve())) + + core_data = toml_utils.load(root / ".bootstrap" / "config" / "core.toml") + self.assertEqual(core_data["project"]["name"], "workspace-root") + self.assertEqual(core_data["workspace"]["version"], "1.0") + self.assertEqual(core_data["workspace"]["sources"]["docs-repo"]["path"], "docs-repo") + + def test_workspace_init_output_writes_custom_location_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + custom_dir = root / "generated" + custom_dir.mkdir(parents=True, exist_ok=True) + output_path = custom_dir / "custom-workspace.toml" + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--output", str(output_path)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(_changed_paths(before, after), {"generated/custom-workspace.toml"}) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CREATED") + self.assertEqual(payload["config_path"], str(output_path.resolve())) + self.assertIn("Custom output path used", payload["hint"]) + self.assertFalse((root / ".cf-workspace.toml").exists()) + + def test_workspace_init_inline_and_output_are_mutually_exclusive(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--inline", "--output", "custom.toml"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("mutually exclusive", payload["message"]) + + def test_workspace_init_invalid_root_errors_without_writing(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + missing_root = root / "missing" + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--root", str(missing_root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Scan root directory not found", payload["message"]) + + def test_workspace_init_force_overwrites_existing_workspace(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + _make_adapter_repo(root / "shared-lib", role_dir="src") + + workspace_path = root / ".cf-workspace.toml" + toml_utils.dump( + { + "version": "1.0", + "sources": { + "stale-source": { + "path": "stale-source", + "role": "codebase", + }, + }, + }, + workspace_path, + ) + before = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init", "--force"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(_changed_paths(before, after), {".cf-workspace.toml"}) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CREATED") + workspace_data = toml_utils.load(workspace_path) + self.assertNotIn("stale-source", workspace_data["sources"]) + self.assertEqual(set(workspace_data["sources"]), {"docs-repo", "shared-lib"}) + + def test_workspace_init_max_depth_excludes_deeper_repos(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "level1-repo", role_dir="architecture") + nested_parent = root / "group" + nested_parent.mkdir(parents=True, exist_ok=True) + _make_adapter_repo(nested_parent / "level2-repo", role_dir="src") + + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--dry-run", "--max-depth", "1"], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertEqual(payload["status"], "DRY_RUN") + self.assertEqual(payload["sources"], ["level1-repo"]) + + def test_workspace_init_dry_run_reports_sources_without_writing(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + _make_adapter_repo(root / "shared-lib", role_dir="src") + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--dry-run"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "DRY_RUN") + self.assertEqual(payload["message"], "Would generate workspace config") + self.assertEqual(payload["sources_count"], 2) + self.assertEqual(set(payload["sources"]), {"docs-repo", "shared-lib"}) + self.assertEqual(payload["workspace"]["sources"]["docs-repo"]["role"], "artifacts") + self.assertEqual(payload["workspace"]["sources"]["shared-lib"]["role"], "codebase") + + def test_workspace_init_add_info_round_trip_with_bounded_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + workspace_path = root / ".cf-workspace.toml" + + before_init = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main(["--json", "workspace-init"], cwd=root) + after_init = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + init_payload = json.loads(stdout) + self.assertEqual(init_payload["status"], "CREATED") + self.assertEqual(init_payload["sources_count"], 1) + self.assertEqual(init_payload["sources"], ["docs-repo"]) + self.assertEqual(_changed_paths(before_init, after_init), {".cf-workspace.toml"}) + self.assertTrue(workspace_path.is_file()) + + workspace_data = toml_utils.load(workspace_path) + self.assertEqual(workspace_data["version"], "1.0") + self.assertEqual(workspace_data["sources"]["docs-repo"]["path"], "docs-repo") + self.assertEqual(workspace_data["sources"]["docs-repo"]["adapter"], ".bootstrap") + self.assertEqual(workspace_data["sources"]["docs-repo"]["role"], "artifacts") + + _make_adapter_repo(root / "shared-lib", role_dir="src") + before_add = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + [ + "--json", + "workspace-add", + "--name", + "shared-lib", + "--path", + "shared-lib", + "--role", + "codebase", + "--adapter", + ".bootstrap", + ], + cwd=root, + ) + after_add = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + add_payload = json.loads(stdout) + self.assertEqual(add_payload["status"], "ADDED") + self.assertEqual(add_payload["source"]["name"], "shared-lib") + self.assertEqual(_changed_paths(before_add, after_add), {".cf-workspace.toml"}) + + workspace_data = toml_utils.load(workspace_path) + self.assertEqual(workspace_data["sources"]["shared-lib"]["path"], "shared-lib") + self.assertEqual(workspace_data["sources"]["shared-lib"]["role"], "codebase") + self.assertEqual(workspace_data["sources"]["shared-lib"]["adapter"], ".bootstrap") + + before_info = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + after_info = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after_info, before_info) + + info_payload = json.loads(stdout) + self.assertEqual(info_payload["status"], "OK") + self.assertEqual(info_payload["sources_count"], 2) + self.assertFalse(info_payload["is_inline"]) + self.assertFalse(info_payload["context_loaded"]) + + sources = {source["name"]: source for source in info_payload["sources"]} + self.assertTrue(sources["docs-repo"]["reachable"]) + self.assertEqual(sources["docs-repo"]["adapter"], ".bootstrap") + self.assertTrue(sources["shared-lib"]["reachable"]) + self.assertEqual(sources["shared-lib"]["adapter"], ".bootstrap") + + def test_workspace_add_rejects_unreachable_path_without_writing(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init"], cwd=root) + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + + before_add = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + [ + "--json", + "workspace-add", + "--name", + "shared-lib", + "--path", + "../shared-lib", + "--role", + "codebase", + "--adapter", + ".bootstrap", + ], + cwd=root, + ) + after_add = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after_add, before_add) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Source path not reachable", payload["message"]) + + def test_workspace_add_url_source_to_standalone_config(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + _run_main(["--json", "workspace-init"], cwd=root) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + [ + "--json", + "workspace-add", + "--name", + "remote-docs", + "--url", + "https://gitlab.com/acme/docs.git", + "--branch", + "main", + "--role", + "artifacts", + "--adapter", + ".bootstrap", + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(_changed_paths(before, after), {".cf-workspace.toml"}) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ADDED") + self.assertEqual(payload["source"]["name"], "remote-docs") + self.assertEqual(payload["source"]["url"], "https://gitlab.com/acme/docs.git") + self.assertEqual(payload["source"]["branch"], "main") + + workspace_data = toml_utils.load(root / ".cf-workspace.toml") + self.assertEqual(workspace_data["sources"]["remote-docs"]["url"], "https://gitlab.com/acme/docs.git") + self.assertEqual(workspace_data["sources"]["remote-docs"]["branch"], "main") + self.assertEqual(workspace_data["sources"]["remote-docs"]["adapter"], ".bootstrap") + + def test_workspace_add_duplicate_source_requires_force(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + _make_adapter_repo(root / "shared-lib", role_dir="src") + _run_main(["--json", "workspace-init"], cwd=root) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + [ + "--json", + "workspace-add", + "--name", + "docs-repo", + "--path", + "shared-lib", + "--role", + "codebase", + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("already exists", payload["message"]) + self.assertIn("--force", payload["message"]) + + def test_workspace_add_force_replaces_existing_source(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + _make_adapter_repo(root / "shared-lib", role_dir="src") + _run_main(["--json", "workspace-init"], cwd=root) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + [ + "--json", + "workspace-add", + "--name", + "docs-repo", + "--path", + "shared-lib", + "--role", + "codebase", + "--adapter", + ".bootstrap", + "--force", + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(_changed_paths(before, after), {".cf-workspace.toml"}) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ADDED") + self.assertTrue(payload["replaced"]) + + workspace_data = toml_utils.load(root / ".cf-workspace.toml") + self.assertEqual(workspace_data["sources"]["docs-repo"]["path"], "shared-lib") + self.assertEqual(workspace_data["sources"]["docs-repo"]["role"], "codebase") + + def test_workspace_add_invalid_name_errors_without_writing(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-add", "--name", "bad/name", "--path", "repo"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Invalid source name", payload["message"]) + + def test_workspace_add_inline_rejects_git_url_source(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _write_core_config(root, '[project]\nname = "workspace-root"\n') + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + [ + "--json", + "workspace-add", + "--name", + "remote-docs", + "--url", + "https://gitlab.com/acme/docs.git", + "--inline", + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("not supported in inline", payload["message"]) + + def test_workspace_info_git_source_not_cloned_reports_warning_without_network(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + workspace_path = root / ".cf-workspace.toml" + toml_utils.dump( + { + "version": "1.0", + "sources": { + "remote-docs": { + "url": "https://gitlab.com/acme/docs.git", + "branch": "main", + "role": "artifacts", + }, + }, + }, + workspace_path, + ) + + before = _snapshot_tree(root) + with patch( + "studio.utils.git_utils.resolve_git_source", + return_value=root / ".workspace-sources" / "acme" / "docs", + ): + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + source = payload["sources"][0] + self.assertFalse(source["reachable"]) + self.assertIn("Source not cloned", source["warning"]) + + def test_workspace_info_invalid_adapter_reports_adapter_found_false(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + source_root = root / "docs-repo" + _make_adapter_repo(source_root, role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": { + "docs-repo": { + "path": "docs-repo", + "role": "artifacts", + "adapter": "missing-adapter", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + source = payload["sources"][0] + self.assertTrue(source["reachable"]) + self.assertFalse(source["adapter_found"]) + + def test_workspace_info_config_warning_is_exposed(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _write_core_config(root, '[project]\nname = "workspace-root"\n') + + before = _snapshot_tree(root) + with patch("studio.utils.workspace.find_workspace_config") as mock_find: + from studio.utils.workspace import WorkspaceConfig, SourceEntry + + ws_cfg = WorkspaceConfig( + sources={ + "broken-source": SourceEntry(name="broken-source", path="docs-repo", role="artifacts"), + }, + workspace_file=root / ".cf-workspace.toml", + ) + mock_find.return_value = (ws_cfg, None) + with patch.object( + WorkspaceConfig, + "validate", + return_value=["synthetic warning from test"], + ): + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["config_warnings"], ["synthetic warning from test"]) + + def test_workspace_info_no_workspace_error_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["message"], "No workspace configuration found") + self.assertIn("workspace-init", payload["hint"]) + + def test_workspace_sync_dry_run_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + toml_utils.dump( + { + "version": "1.0", + "sources": { + "remote-docs": { + "url": "https://gitlab.com/acme/docs.git", + "branch": "main", + "role": "artifacts", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-sync", "--dry-run"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "DRY_RUN") + self.assertEqual(payload["message"], "Would sync the following Git URL sources") + self.assertEqual(payload["sources"], [{"name": "remote-docs", "url": "https://gitlab.com/acme/docs.git", "branch": "main"}]) + + def test_workspace_sync_non_dry_run_reports_mixed_results_without_local_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + toml_utils.dump( + { + "version": "1.0", + "sources": { + "remote-docs": { + "url": "https://gitlab.com/acme/docs.git", + "branch": "main", + "role": "artifacts", + }, + "remote-code": { + "url": "https://gitlab.com/acme/code.git", + "branch": "develop", + "role": "codebase", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + before = _snapshot_tree(root) + with patch( + "studio.commands.workspace_sync._sync_sources", + return_value=( + [ + {"name": "remote-docs", "status": "synced"}, + {"name": "remote-code", "status": "failed", "error": "dirty worktree"}, + ], + 1, + 1, + ), + ) as mock_sync: + exit_code, stdout, stderr = _run_main(["--json", "workspace-sync"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + self.assertEqual(set(mock_sync.call_args.args[0].keys()), {"remote-docs", "remote-code"}) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["synced"], 1) + self.assertEqual(payload["failed"], 1) + self.assertEqual(payload["results"][0]["status"], "synced") + self.assertEqual(payload["results"][1]["status"], "failed") + self.assertEqual(payload["results"][1]["error"], "dirty worktree") + + def test_workspace_sync_source_not_found_errors_cleanly(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + toml_utils.dump( + { + "version": "1.0", + "sources": { + "remote-docs": { + "url": "https://gitlab.com/acme/docs.git", + "branch": "main", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-sync", "--source", "missing-source"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertEqual(payload["available"], ["remote-docs"]) + + def test_workspace_sync_no_git_sources_returns_ok_and_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": { + "docs-repo": { + "path": "docs-repo", + "role": "artifacts", + "adapter": ".bootstrap", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main(["--json", "workspace-sync"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertEqual(payload["message"], "No Git URL sources to sync") + self.assertEqual(payload["results"], []) + + def test_workspace_sync_all_failed_returns_exit_2(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + toml_utils.dump( + { + "version": "1.0", + "sources": { + "remote-docs": { + "url": "https://gitlab.com/acme/docs.git", + "branch": "main", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + before = _snapshot_tree(root) + with patch( + "studio.commands.workspace_sync._sync_sources", + return_value=([{"name": "remote-docs", "status": "failed", "error": "network error"}], 0, 1), + ): + exit_code, stdout, stderr = _run_main(["--json", "workspace-sync"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["synced"], 0) + self.assertEqual(payload["failed"], 1) + + def test_workspace_sync_force_propagates_to_sync_layer(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + toml_utils.dump( + { + "version": "1.0", + "sources": { + "remote-docs": { + "url": "https://gitlab.com/acme/docs.git", + "branch": "main", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + before = _snapshot_tree(root) + with patch( + "studio.commands.workspace_sync._sync_sources", + return_value=([{"name": "remote-docs", "status": "synced"}], 1, 0), + ) as mock_sync: + exit_code, stdout, stderr = _run_main(["--json", "workspace-sync", "--force"], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + self.assertTrue(mock_sync.call_args.kwargs["force"]) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + + +class TestCLIDelegateE2E(unittest.TestCase): + def test_delegate_invalid_root_is_read_only(self): + with TemporaryDirectory() as tmpdir: + cwd = Path(tmpdir) + before = _snapshot_tree(cwd) + + exit_code, stdout, stderr = _run_main( + ["--json", "delegate", "plan-dir", "--root", str(cwd / "missing-root")], + cwd=cwd, + ) + after = _snapshot_tree(cwd) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "error") + self.assertIn("Project root not found", payload["error"]) + + def test_delegate_missing_plan_toml_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "repo" + _make_repo(root) + plan_dir = root / "plans" / "slice" + plan_dir.mkdir(parents=True, exist_ok=True) + + before = _snapshot_tree(root) + exit_code, stdout, stderr = _run_main( + ["--json", "delegate", str(plan_dir), "--root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "error") + self.assertIn("plan.toml not found", payload["error"]) + + def test_delegate_dry_run_via_public_cli_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "repo" + _make_repo(root) + plan_dir = root / "plans" / "slice" + plan_dir.mkdir(parents=True, exist_ok=True) + (plan_dir / "plan.toml").write_text("[plan]\ntask = 'slice'\n", encoding="utf-8") + + before = _snapshot_tree(root) + with patch( + "studio.ralphex_export.run_delegation", + return_value={ + "status": "ready", + "command": ["/usr/bin/ralphex", "delegate-plan.md", "--serve"], + "plan_file": str(plan_dir / "delegate-plan.md"), + "dashboard_url": "http://127.0.0.1:8400", + "lifecycle_state": "exported", + }, + ) as mock_run: + exit_code, stdout, stderr = _run_main( + ["delegate", str(plan_dir), "--root", str(root), "--dry-run"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(after, before) + self.assertEqual(mock_run.call_args.kwargs["dry_run"], True) + self.assertEqual(mock_run.call_args.kwargs["repo_root"], str(root.resolve())) + self.assertEqual(stdout, "") + self.assertIn("[DRY RUN] Command assembled (not invoked):", stderr) + self.assertIn("Dashboard: http://127.0.0.1:8400", stderr) + self.assertIn("Lifecycle: exported", stderr) + + def test_delegate_json_non_dry_run_success_is_read_only_locally(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "repo" + _make_repo(root) + plan_dir = root / "plans" / "slice" + plan_dir.mkdir(parents=True, exist_ok=True) + (plan_dir / "plan.toml").write_text("[plan]\ntask = 'slice'\n", encoding="utf-8") + + before = _snapshot_tree(root) + with patch( + "studio.ralphex_export.run_delegation", + return_value={ + "status": "delegated", + "command": ["/usr/bin/ralphex", "delegate-plan.md", "--serve"], + "plan_file": str(plan_dir / "delegate-plan.md"), + "dashboard_url": "http://127.0.0.1:8400", + "lifecycle_state": "started", + "mode": "execute", + }, + ) as mock_run: + exit_code, stdout, stderr = _run_main( + ["--json", "delegate", str(plan_dir), "--root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + self.assertFalse(mock_run.call_args.kwargs["dry_run"]) + self.assertTrue(mock_run.call_args.kwargs["serve"]) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "delegated") + self.assertEqual(payload["lifecycle_state"], "started") + self.assertEqual(payload["dashboard_url"], "http://127.0.0.1:8400") + self.assertEqual(payload["mode"], "execute") + + +class TestCLIDoctorE2E(unittest.TestCase): + def test_doctor_json_exception_path_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "repo" + _make_repo(root) + + before = _snapshot_tree(root) + with patch("studio.commands.doctor._check_ralphex", side_effect=RuntimeError("boom")): + exit_code, stdout, stderr = _run_main(["--json", "doctor", "--root", str(root)], cwd=root) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, before) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "unhealthy") + self.assertEqual(payload["checks"][0]["status"], "fail") + self.assertIn("Check raised an exception: boom", payload["checks"][0]["detail"]) + self.assertEqual(payload["summary"], "Doctor found issues that need attention.") + + def test_doctor_json_fail_path_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "repo" + _make_repo(root) + + before = _snapshot_tree(root) + with patch( + "studio.commands.doctor._check_ralphex", + return_value={ + "level": "FAIL", + "name": "inst-check-ralphex", + "message": "ralphex installation is corrupted", + }, + ): + exit_code, stdout, stderr = _run_main( + ["--json", "doctor", "--root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 2) + self.assertEqual(after, before) + self.assertEqual(stderr, "") + + payload = json.loads(stdout) + self.assertEqual(payload["status"], "unhealthy") + self.assertEqual(payload["summary"], "Doctor found issues that need attention.") + self.assertEqual( + payload["checks"], + [ + { + "name": "inst-check-ralphex", + "status": "fail", + "detail": "ralphex installation is corrupted", + }, + ], + ) + + def test_doctor_healthy_output_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "repo" + _make_repo(root) + + before = _snapshot_tree(root) + with patch( + "studio.commands.doctor._check_ralphex", + return_value={ + "level": "PASS", + "name": "inst-check-ralphex", + "message": "ralphex 1.2.3 at /tmp/ralphex", + }, + ): + exit_code, stdout, stderr = _run_main( + ["doctor", "--root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(after, before) + self.assertEqual(stdout, "") + self.assertIn("Studio Doctor", stderr) + self.assertIn("[PASS] inst-check-ralphex: ralphex 1.2.3 at /tmp/ralphex", stderr) + self.assertIn("All checks passed.", stderr) + + def test_doctor_degraded_output_is_read_only(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "repo" + _make_repo(root) + + before = _snapshot_tree(root) + with patch( + "studio.commands.doctor._check_ralphex", + return_value={ + "level": "WARN", + "name": "inst-check-ralphex", + "message": "ralphex not found. install guidance", + }, + ): + exit_code, stdout, stderr = _run_main( + ["doctor", "--root", str(root)], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(after, before) + self.assertEqual(stdout, "") + self.assertIn("Studio Doctor", stderr) + self.assertIn("[WARN] inst-check-ralphex: ralphex not found. install guidance", stderr) + self.assertIn("All checks passed with warnings.", stderr) diff --git a/tests/test_generate_manifest_agents.py b/tests/test_generate_manifest_agents.py index db3eb42c..ec8166d3 100644 --- a/tests/test_generate_manifest_agents.py +++ b/tests/test_generate_manifest_agents.py @@ -345,7 +345,7 @@ def test_output_path_openai(self): with tempfile.TemporaryDirectory() as tmpdir: project_root = self._run_with_target("openai", tmpdir) out_path = project_root / ".codex" / "agents" / "my-agent.toml" - self.assertFalse(out_path.exists(), f"Did not expect {out_path} to exist") + self.assertTrue(out_path.exists(), f"Expected {out_path} to exist") # --------------------------------------------------------------------------- @@ -728,7 +728,7 @@ def _make_src(self, project_root: Path) -> Path: return src def test_openai_model_written_when_set(self): - """OpenAI manifest agents are skipped instead of generating TOML files.""" + """OpenAI manifest agents emit dedicated TOML files with model metadata.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) src = self._make_src(project_root) @@ -742,11 +742,13 @@ def test_openai_model_written_when_set(self): ) } result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) - self.assertEqual(result["created"], []) - self.assertFalse((project_root / ".codex" / "agents" / "my-agent.toml").exists()) + out_path = project_root / ".codex" / "agents" / "my-agent.toml" + self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertTrue(out_path.exists()) + self.assertIn('model = "claude-opus-4"', out_path.read_text(encoding="utf-8")) def test_openai_variables_substituted(self): - """OpenAI manifest-agent skip does not emit a dedicated TOML output.""" + """OpenAI manifest-agent output resolves template variables in TOML content.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) src = project_root / "agents" / "sub-agent.md" @@ -764,11 +766,14 @@ def test_openai_variables_substituted(self): agents, "openai", project_root, dry_run=False, variables={"project_name": "MyProject"}, ) - self.assertEqual(result["created"], []) - self.assertFalse((project_root / ".codex" / "agents" / "sub-agent.toml").exists()) + out_path = project_root / ".codex" / "agents" / "sub-agent.toml" + self.assertEqual(result["created"], [out_path.as_posix()]) + content = out_path.read_text(encoding="utf-8") + self.assertIn('description = "Agent with MyProject"', content) + self.assertIn("Hello MyProject.", content) def test_openai_developer_instructions_contains_source_body(self): - """OpenAI manifest-agent skip leaves no dedicated TOML payload behind.""" + """OpenAI developer_instructions embed the prompt source body.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) src = project_root / "agents" / "my-agent.md" @@ -784,11 +789,14 @@ def test_openai_developer_instructions_contains_source_body(self): ) } result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) - self.assertEqual(result["created"], []) - self.assertFalse((project_root / ".codex" / "agents" / "my-agent.toml").exists()) + out_path = project_root / ".codex" / "agents" / "my-agent.toml" + self.assertEqual(result["created"], [out_path.as_posix()]) + content = out_path.read_text(encoding="utf-8") + self.assertIn('developer_instructions = """', content) + self.assertIn(prompt_body, content) def test_openai_append_included_in_output(self): - """OpenAI manifest-agent skip leaves no dedicated TOML output.""" + """OpenAI manifest-agent output appends extra instructions.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) src = self._make_src(project_root) @@ -802,11 +810,12 @@ def test_openai_append_included_in_output(self): ) } result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) - self.assertEqual(result["created"], []) - self.assertFalse((project_root / ".codex" / "agents" / "my-agent.toml").exists()) + out_path = project_root / ".codex" / "agents" / "my-agent.toml" + self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertIn("# extra section", out_path.read_text(encoding="utf-8")) def test_openai_output_contains_generator_ownership_marker(self): - """OpenAI manifest-agent entries are skipped with no dedicated output file.""" + """OpenAI manifest-agent output includes generator ownership marker.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) src = self._make_src(project_root) @@ -820,11 +829,13 @@ def test_openai_output_contains_generator_ownership_marker(self): } result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) - self.assertEqual(result["created"], []) - self.assertFalse((project_root / ".codex" / "agents" / "my-agent.toml").exists()) + out_path = project_root / ".codex" / "agents" / "my-agent.toml" + self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertTrue(out_path.exists()) + self.assertTrue(out_path.read_text(encoding="utf-8").startswith("# Generated by cf agents -- do not edit")) def test_openai_removes_generator_owned_legacy_agents_path(self): - """OpenAI skip removes old-format generator-owned .agents/{id}/agent.toml output. + """OpenAI generation removes old-format generator-owned .agents/{id}/agent.toml output. The legacy file is seeded with old-format content — has the _GENERATED_MARKER_TOML line but lacks new fields like model_reasoning_effort — to exercise the marker-presence ownership @@ -859,13 +870,13 @@ def test_openai_removes_generator_owned_legacy_agents_path(self): result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) - self.assertFalse((project_root / ".codex" / "agents" / "my-agent.toml").exists()) + self.assertTrue((project_root / ".codex" / "agents" / "my-agent.toml").exists()) self.assertFalse(legacy_out.exists()) self.assertEqual(len(result["deleted"]), 1) deleted_outputs = [o for o in result["outputs"] if o.get("action") == "deleted"] self.assertEqual(len(deleted_outputs), 1) self.assertEqual(deleted_outputs[0]["path"], ".agents/my-agent/agent.toml") - self.assertEqual(deleted_outputs[0]["reason"], "skipped_agent_stale_artifact") + self.assertEqual(deleted_outputs[0]["reason"], "migrated_openai_agent_output") def test_openai_does_not_delete_unrelated_legacy_file(self): """OpenAI generation does NOT delete a hand-written legacy file with no marker.""" diff --git a/tests/test_kit.py b/tests/test_kit.py index 043d94f4..8164be67 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -3105,6 +3105,7 @@ def test_human_kit_update_covers_status_and_authority_variants(self): def test_build_kit_update_result_normalizes_errors_and_authority(self): from studio.commands.kit import ( _build_kit_update_result, + _collect_kit_update_partial_reasons, _normalize_kit_update_action, ) @@ -3133,6 +3134,13 @@ def test_build_kit_update_result_normalizes_errors_and_authority(self): self.assertEqual(result["errors"], ["boom"]) self.assertEqual(result["authority"]["resolved_ref"], "v1") self.assertEqual(result["prune_required"][0]["prune_fingerprint"], "abc123") + partials = _collect_kit_update_partial_reasons([result]) + self.assertEqual(partials[0]["kit"], "sdlc") + self.assertIn("errors", partials[0]["categories"]) + self.assertIn("declined_files", partials[0]["categories"]) + self.assertIn("declined_prunes", partials[0]["categories"]) + self.assertIn("failed", partials[0]["categories"]) + self.assertEqual(partials[0]["declined"], ["AGENTS.md"]) def test_resolve_github_update_targets_covers_source_branches(self): import studio.commands.kit as kit_module @@ -4466,10 +4474,15 @@ def test_warn_with_errors(self): "kits_updated": 1, "results": [{"kit": "sdlc", "action": "current"}], "errors": ["oops", "fail"], + "partial_reasons": [ + {"kit": "sdlc", "categories": ["declined_files", "partial_update"]}, + ], }) out = buf.getvalue() self.assertIn("oops", out) self.assertIn("fail", out) + self.assertIn("partial reason for sdlc", out) + self.assertIn("declined_files", out) self.assertIn("warnings", out.lower()) def test_unknown_status(self): diff --git a/tests/test_schema_translation.py b/tests/test_schema_translation.py index e96d33a5..b9b0f31a 100644 --- a/tests/test_schema_translation.py +++ b/tests/test_schema_translation.py @@ -262,13 +262,13 @@ def test_codex_readonly_sandbox_mode(self): result = _translate_codex_schema(agent) self.assertIn("sandbox_mode", result) self.assertEqual(result["sandbox_mode"], "read-only") - self.assertTrue(result["skip"]) + self.assertFalse(result["skip"]) def test_codex_readwrite_sandbox_mode(self): agent = _make_agent(mode="readwrite") result = _translate_codex_schema(agent) self.assertEqual(result["sandbox_mode"], "workspace-write") - self.assertTrue(result["skip"]) + self.assertFalse(result["skip"]) def test_codex_model_passthrough(self): agent = _make_agent(model="gpt-4o") @@ -297,7 +297,8 @@ def test_codex_developer_instructions_field(self): agent = _make_agent(description="My codex agent") result = _translate_codex_schema(agent) self.assertIn("developer_instructions", result) - self.assertIn("unsupported", result["skip_reason"]) + self.assertEqual(result["developer_instructions"], "My codex agent") + self.assertEqual(result["skip_reason"], "") # --------------------------------------------------------------------------- @@ -350,7 +351,7 @@ def test_dispatch_to_openai(self): agent = _make_agent() result = translate_agent_schema(agent, "openai") self.assertIn("sandbox_mode", result) - self.assertTrue(result["skip"]) + self.assertFalse(result["skip"]) def test_dispatch_unknown_tool_raises(self): agent = _make_agent() diff --git a/tests/test_spec_coverage.py b/tests/test_spec_coverage.py index b37b7f56..4d7f0d7a 100644 --- a/tests/test_spec_coverage.py +++ b/tests/test_spec_coverage.py @@ -318,9 +318,29 @@ def test_system_filter_excludes_non_matching(self): with patch("studio.utils.context.get_context", return_value=ctx): with patch("sys.stdout", new_callable=StringIO) as mock_out: ret = cmd_spec_coverage(["--system", "other"]) - self.assertEqual(ret, 0) + self.assertEqual(ret, 2) parsed = json.loads(mock_out.getvalue()) - self.assertEqual(parsed["summary"]["total_files"], 0) + self.assertEqual(parsed["status"], "FAIL") + self.assertEqual(parsed["unknown_systems"], ["other"]) + + def test_system_filter_mixed_known_and_unknown_fails(self): + with TemporaryDirectory() as d: + root = Path(d) + src = root / "src" + src.mkdir() + (src / "a.py").write_text("x = 1\n", encoding="utf-8") + ctx = self._make_context(root, systems=[ + SystemNode(name="Only", slug="only", kit="test", + artifacts=[], children=[], + codebase=[CodebaseEntry(path="src", extensions=[".py"])]), + ]) + with patch("studio.utils.context.get_context", return_value=ctx): + with patch("sys.stdout", new_callable=StringIO) as mock_out: + ret = cmd_spec_coverage(["--system", "only", "--system", "other"]) + self.assertEqual(ret, 2) + parsed = json.loads(mock_out.getvalue()) + self.assertEqual(parsed["status"], "FAIL") + self.assertEqual(parsed["unknown_systems"], ["other"]) def test_system_filter_multiple(self): with TemporaryDirectory() as d: diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 5af60e82..18918804 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -2430,6 +2430,18 @@ def test_add_with_url_and_branch(self, capsys): assert data["source"]["url"] == "https://x.com/a/b.git" assert data["source"]["branch"] == "main" + def test_add_rejects_unreachable_local_path(self, capsys): + with TemporaryDirectory() as tmpdir: + ws_cfg = _make_standalone_ws_mock(tmpdir) + with patch("studio.utils.files.find_project_root", return_value=Path(tmpdir)): + with patch("studio.utils.workspace.find_workspace_config", return_value=(ws_cfg, None)): + rc = cmd_workspace_add(["--name", "docs", "--path", "../missing-docs"]) + assert rc == 1 + out = capsys.readouterr().out + assert "Source path not reachable" in out + ws_cfg.add_source.assert_not_called() + ws_cfg.save.assert_not_called() + def test_no_workspace_found(self, capsys): with TemporaryDirectory() as tmpdir: From 39b857bb99ea84ee61dc4de6bc94f4a4574827e3 Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 00:28:53 +0300 Subject: [PATCH 02/14] test: update assertions in CLI utility and router E2E tests for improved validation Signed-off-by: ainetx --- tests/test_cli_kit_utility_e2e.py | 2 +- tests/test_cli_router_e2e.py | 17 ++++++++------ tests/test_cli_setup_e2e.py | 39 ------------------------------- 3 files changed, 11 insertions(+), 47 deletions(-) diff --git a/tests/test_cli_kit_utility_e2e.py b/tests/test_cli_kit_utility_e2e.py index 7619be73..263f62f0 100644 --- a/tests/test_cli_kit_utility_e2e.py +++ b/tests/test_cli_kit_utility_e2e.py @@ -484,7 +484,7 @@ def test_toc_multi_file_mixed_results(self): self.assertEqual(out["status"], "OK") statuses = {item["file"]: item["status"] for item in out["results"]} self.assertEqual(statuses[first.resolve().as_posix()], "UPDATED") - self.assertIn(statuses[second.resolve().as_posix()], {"UNCHANGED", "UPDATED"}) + self.assertIn(statuses[second.resolve().as_posix()], {"UNCHANGED", "UPDATED", "SKIP"}) def test_pdsl_stdin_mode_pass_is_read_only(self): with TemporaryDirectory() as td: diff --git a/tests/test_cli_router_e2e.py b/tests/test_cli_router_e2e.py index e09565e2..fb5f25da 100644 --- a/tests/test_cli_router_e2e.py +++ b/tests/test_cli_router_e2e.py @@ -62,13 +62,16 @@ def test_unknown_command_errors_cleanly(self): def test_self_check_alias_routes_to_validate_kits(self): with TemporaryDirectory() as td: root = Path(td) - rc, stdout, stderr = _run_main(["--json", "self-check"], cwd=root) - - self.assertEqual(rc, 0) - self.assertEqual(stderr, "") - payload = json.loads(stdout) - self.assertIn(payload["status"], {"PASS", "OK"}) - self.assertIn("summary", payload) + alias_rc, alias_stdout, alias_stderr = _run_main(["--json", "self-check"], cwd=root) + canonical_rc, canonical_stdout, canonical_stderr = _run_main( + ["--json", "validate-kits"], + cwd=root, + ) + + self.assertEqual(alias_rc, canonical_rc) + self.assertEqual(alias_stderr, "") + self.assertEqual(canonical_stderr, "") + self.assertEqual(json.loads(alias_stdout), json.loads(canonical_stdout)) if __name__ == "__main__": diff --git a/tests/test_cli_setup_e2e.py b/tests/test_cli_setup_e2e.py index 51d6bb17..3b6c5c06 100644 --- a/tests/test_cli_setup_e2e.py +++ b/tests/test_cli_setup_e2e.py @@ -512,45 +512,6 @@ def test_generate_agents_openai_dry_run_via_main(self): self.assertFalse((root / ".codex").exists()) self.assertFalse((root / ".agents").exists()) - def test_generate_agents_yes_remove_cypilot_cleans_legacy_outputs(self): - with TemporaryDirectory() as td: - root = Path(td) / "proj" - _bootstrap_generator_project(root) - (root / ".claude" / "skills" / "cypilot").mkdir(parents=True, exist_ok=True) - (root / ".claude" / "skills" / "cypilot" / "SKILL.md").write_text( - "# legacy\n", - encoding="utf-8", - ) - (root / ".claude" / "agents").mkdir(parents=True, exist_ok=True) - (root / ".claude" / "agents" / "cypilot-reviewer.md").write_text( - "# legacy agent\n", - encoding="utf-8", - ) - - rc, out, stderr = _run_main_json( - [ - "generate-agents", - "--agent", - "claude", - "--root", - str(root), - "--cf-constructor-root", - str(root), - "--remove-cypilot", - "yes", - "--yes", - ], - cwd=root, - ) - - self.assertEqual(rc, 0, stderr) - self.assertEqual(out["status"], "PASS") - claude = out["results"]["claude"] - deleted = claude.get("skills", {}).get("deleted", []) + claude.get("subagents", {}).get("deleted", []) - self.assertTrue(any("cypilot" in path for path in deleted)) - self.assertFalse((root / ".claude" / "skills" / "cypilot").exists()) - self.assertFalse((root / ".claude" / "agents" / "cypilot-reviewer.md").exists()) - def test_agents_openai_flag_stays_read_only(self): with TemporaryDirectory() as td: root = Path(td) / "proj" From 24d8b7b499f930fb820d1b268be343a6d18dc92e Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 00:30:34 +0300 Subject: [PATCH 03/14] test: add legacy cypilot output handling and cleanup in agent generation Signed-off-by: ainetx --- tests/test_cli_setup_e2e.py | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/test_cli_setup_e2e.py b/tests/test_cli_setup_e2e.py index 3b6c5c06..05081a75 100644 --- a/tests/test_cli_setup_e2e.py +++ b/tests/test_cli_setup_e2e.py @@ -99,6 +99,39 @@ def _bootstrap_generator_project(root: Path) -> None: ) +def _write_legacy_follow_stub(path: Path, follow_target: str, *, name: str | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + frontmatter = "" + if name is not None: + frontmatter = f"---\nname: {name}\n---\n" + path.write_text( + frontmatter + f"ALWAYS open and follow `{follow_target}`\n", + encoding="utf-8", + ) + + +def _bootstrap_legacy_cypilot_outputs(root: Path) -> None: + _write_legacy_follow_stub( + root / ".claude" / "skills" / "cypilot" / "SKILL.md", + "{cypilot_path}/skills/cypilot/SKILL.md", + name="cypilot", + ) + _write_legacy_follow_stub( + root / ".claude" / "skills" / "cypilot-analyze" / "SKILL.md", + "{cypilot_path}/workflows/analyze.md", + name="cypilot-analyze", + ) + _write_legacy_follow_stub( + root / ".claude" / "skills" / "cypilot-generate" / "SKILL.md", + "{cypilot_path}/workflows/generate.md", + name="cypilot-generate", + ) + _write_legacy_follow_stub( + root / ".claude" / "agents" / "cypilot-reviewer.md", + "{cypilot_path}/agents/reviewer.md", + ) + + class TestInfoAndResolveVarsE2E(unittest.TestCase): def test_info_no_project_root_returns_not_found_without_writes(self): with TemporaryDirectory() as td: @@ -512,6 +545,48 @@ def test_generate_agents_openai_dry_run_via_main(self): self.assertFalse((root / ".codex").exists()) self.assertFalse((root / ".agents").exists()) + def test_generate_agents_yes_remove_cypilot_cleans_legacy_outputs(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generator_project(root) + _bootstrap_legacy_cypilot_outputs(root) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + [ + "generate-agents", + "--agent", + "claude", + "--root", + str(root), + "--cf-constructor-root", + str(root), + "--remove-cypilot", + "yes", + "--yes", + ], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "PASS") + + claude = out["results"]["claude"] + deleted = set(claude.get("skills", {}).get("deleted", [])) | set( + claude.get("subagents", {}).get("deleted", []) + ) + self.assertIn(".claude/skills/cypilot/SKILL.md", deleted) + self.assertIn(".claude/agents/cypilot-reviewer.md", deleted) + + self.assertFalse((root / ".claude" / "skills" / "cypilot" / "SKILL.md").exists()) + self.assertFalse((root / ".claude" / "agents" / "cypilot-reviewer.md").exists()) + + after = _snapshot_tree(root) + self.assertNotEqual(after, before) + self.assertTrue((root / ".claude" / "skills" / "cf-analyze" / "SKILL.md").exists()) + self.assertTrue((root / ".claude" / "skills" / "cf-plan" / "SKILL.md").exists()) + def test_agents_openai_flag_stays_read_only(self): with TemporaryDirectory() as td: root = Path(td) / "proj" From 5d3bf8ce705880e23c01929eeee5e5c6f0ac7acb Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 00:47:53 +0300 Subject: [PATCH 04/14] Add end-to-end tests for CLI filesystem invariants and human mode operations - Implemented `test_cli_fs_invariants_e2e.py` to cover filesystem invariants and stale-output cleanup in the CLI. - Added tests for kit installation, updates, and resource path validation. - Created `test_cli_human_mode_e2e.py` to validate human-mode CLI interactions for agent generation and kit flows. - Included tests for agent generation with user prompts and kit updates with mixed decisions. Signed-off-by: ainetx --- .../studio/scripts/studio/commands/agents.py | 124 +++- skills/studio/scripts/studio/commands/kit.py | 23 +- tests/e2e-analysis-report.md | 25 +- tests/test_cli_contracts_e2e.py | 491 ++++++++++++++++ tests/test_cli_fs_invariants_e2e.py | 546 ++++++++++++++++++ tests/test_cli_human_mode_e2e.py | 266 +++++++++ 6 files changed, 1450 insertions(+), 25 deletions(-) create mode 100644 tests/test_cli_contracts_e2e.py create mode 100644 tests/test_cli_fs_invariants_e2e.py create mode 100644 tests/test_cli_human_mode_e2e.py diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 6394b9e1..4bec30f5 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -2178,7 +2178,7 @@ def _component_enabled_for_agent(component: object, agent: str) -> bool: def _list_public_components( studio_root: Path, project_root: Optional[Path], - agent: str, + agent: Optional[str], ) -> Tuple[List[KitPublicComponent], Set[str]]: # @cpt-begin:cpt-studio-algo-kit-manifest-normalize:p1:inst-rollout-generate-agents # @cpt-begin:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel @@ -2219,7 +2219,7 @@ def _list_public_components( for component in model.public_components: if getattr(component, "kind", "") not in {"skill", "agent"}: continue - if not _component_enabled_for_agent(component, agent): + if agent is not None and not _component_enabled_for_agent(component, agent): continue resolved_source_path = _resolve_registered_resource_path(project_root, studio_root, kit_root, component, kit_entry) if resolved_source_path is None: @@ -3794,11 +3794,131 @@ def _process_kit_public_agents_and_rules( studio_root=studio_root, trusted_roots=trusted_roots, ) + _merge_action_result( + public_agents, + _cleanup_disabled_public_agent_outputs(project_root, studio_root, dry_run), + ) public_agents.setdefault("errors", []).extend(agent_collision_errors) public_rules = {"created": [], "updated": [], "deleted": [], "outputs": [], "errors": []} return {"agents": public_agents, "rules": public_rules} +def _expected_public_agent_output_for_target( + agent_id: str, + agent_entry: "_AgentEntry", + target: str, + project_root: Path, + studio_root: Path, + trusted_roots: List[Path], +) -> Optional[Tuple[str, str]]: + """Rebuild the exact output payload for one public agent target.""" + if target not in _AGENT_OUTPUT_PATHS: + return None + translated = translate_agent_schema(agent_entry, target) + if translated.get("skip"): + return None + source_content = _read_source_content( + "agent", + agent_id, + agent_entry.source or agent_entry.prompt_file, + project_root, + studio_root=studio_root, + trusted_roots=trusted_roots, + ) + if source_content is None: + return None + path_template = _AGENT_OUTPUT_PATHS[target] + if target == "openai": + return _build_openai_agent_file( + agent_id, + agent_entry, + translated, + source_content, + path_template, + None, + ) + return _build_standard_agent_file( + agent_id, + agent_entry, + translated, + source_content, + path_template, + None, + ) + + +def _cleanup_disabled_public_agent_outputs( + project_root: Path, + studio_root: Path, + dry_run: bool, +) -> Dict[str, Any]: + """Remove stale public-agent outputs for targets no longer enabled.""" + result: Dict[str, Any] = { + "created": [], + "updated": [], + "deleted": [], + "outputs": [], + "errors": [], + } + public_components, _manifest_backed_kits = _list_public_components( + studio_root, + project_root, + None, + ) + trusted_roots: List[Path] = [] + for kit_slug, component, source_path, kit_root, _kit_entry in public_components: + if getattr(component, "kind", "") != "agent": + continue + generated_name = str(getattr(component, "generated_name", "")).strip() + if not generated_name: + continue + trusted_roots = [source_path.parent, kit_root] + description = str(getattr(component, "description", "") or "").strip() + for target in _AGENT_OUTPUT_PATHS: + if _component_enabled_for_agent(component, target): + continue + target_config = _component_target_config(component, target) + agent_entry = _AgentEntry( + id=generated_name, + description=description or f"Constructor Studio {generated_name} agent", + source=source_path.as_posix(), + agents=_component_agent_targets(component), + tools=_component_config_list(component, target_config, "tools"), + disallowed_tools=_component_config_list(component, target_config, "disallowed_tools"), + mode=str(_component_config_value(component, target_config, "mode", "readwrite") or "readwrite"), + isolation=bool(_component_config_value(component, target_config, "isolation", False)), + model=str(_component_config_value(component, target_config, "model", "") or ""), + skills=_component_config_list(component, target_config, "skills"), + color=str(_component_config_value(component, target_config, "color", "") or ""), + memory_dir=str(_component_config_value(component, target_config, "memory_dir", "") or ""), + role=str(_component_config_value(component, target_config, "role", "any") or "any"), + target=str(_component_config_value(component, target_config, "target", "any") or "any"), + provider=str(_component_config_value(component, target_config, "provider", "anthropic") or "anthropic"), + reasoning_effort=_component_config_value(component, target_config, "reasoning_effort", None), + context_window=_component_config_value(component, target_config, "context_window", None), + ) + expected = _expected_public_agent_output_for_target( + generated_name, + agent_entry, + target, + project_root, + studio_root, + trusted_roots, + ) + if expected is None: + continue + expected_content, rel_out = expected + _delete_generated_file_if_owned( + project_root / rel_out, + result, + project_root, + dry_run, + expected_content=expected_content, + reason=f"disabled_public_agent_target:{kit_slug}:{target}", + ) + return result + + def _collect_managed_result_paths( project_root: Path, section: Dict[str, Any], diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index 06d0195c..eb78cb75 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -4466,16 +4466,16 @@ def _sync_manifest_resource_bindings( resource_path = (PurePosixPath(kit_root_rel) / install_path).as_posix() else: resource_path = PurePosixPath(install_path).as_posix() - merged[res.id] = {"path": resource_path} - kind = str(getattr(res, "kind", "") or "").strip() - if kind: - merged[res.id]["kind"] = kind - merged[res.id]["public"] = bool(getattr(res, "public", False)) - artifact_bindings = getattr(res, "artifact_bindings", None) - if isinstance(artifact_bindings, dict) and artifact_bindings: - merged[res.id]["artifacts"] = artifact_bindings - else: - merged[res.id].pop("artifacts", None) + merged[res.id] = _manifest_resource_binding_entry(res=res, path=resource_path) + continue + binding_path = str(merged[res.id].get("path", "") or "") + if not binding_path: + install_path = _resource_install_path(res) + if kit_root_rel: + binding_path = (PurePosixPath(kit_root_rel) / install_path).as_posix() + else: + binding_path = PurePosixPath(install_path).as_posix() + merged[res.id] = _manifest_resource_binding_entry(res=res, path=binding_path) return merged # @cpt-end:cpt-studio-algo-kit-update:p1:inst-sync-manifest-bindings @@ -4732,7 +4732,8 @@ def update_kit( result["errors"] = _mig_result.get("errors", []) return result installed_kit_entry = _read_kits_from_core_toml(config_dir).get(kit_slug, installed_kit_entry) - synced_resources = _sync_manifest_resource_bindings(_manifest, config_dir, kit_slug) + sync_manifest_model = _risk_model if _risk_model is not None else _manifest + synced_resources = _sync_manifest_resource_bindings(sync_manifest_model, config_dir, kit_slug) resources_changed = False if synced_resources is not None: current_resources = installed_kit_entry.get("resources", {}) diff --git a/tests/e2e-analysis-report.md b/tests/e2e-analysis-report.md index d1c8139f..67b20d30 100644 --- a/tests/e2e-analysis-report.md +++ b/tests/e2e-analysis-report.md @@ -16,10 +16,10 @@ This report is synchronized with the current `*_e2e.py` suite state in `tests/`. - Date: `2026-06-20` - Command: `pytest tests/*_e2e.py -q --tb=no -rA` -- Result: `114 passed`, `1 skipped`, `3 subtests passed` -- Inventory: `14` e2e modules total +- Result: `186 passed`, `1 skipped`, `3 subtests passed` +- Inventory: `15` e2e modules total - Status split: - - `12` fully green runnable modules + - `13` fully green runnable modules - `1` skipped module - `1` disabled / no runnable tests module @@ -32,18 +32,19 @@ Scope rules used for this report: | Module | Passing tests | Covered surface | |---|---:|---| -| `tests/test_cli_agents_e2e.py` | 2 | Public `agents` CLI behavior, including read-only OpenAI inspection and missing-root error handling | -| `tests/test_cli_artifact_tools_e2e.py` | 5 | Artifact utility commands: deprecated `generate-resources`, `pdsl validate`, and TOC dry-run/write flows | +| `tests/test_cli_agents_e2e.py` | 6 | Public `agents` CLI behavior, including default-target expansion, root override reporting, invalid-config handling, and read-only target selection | +| `tests/test_cli_artifact_tools_e2e.py` | 9 | Artifact utility commands: deprecated `generate-resources`, `get-content` error/read-only paths, `pdsl validate`, and TOC dry-run/write flows | | `tests/test_cli_example_kits_e2e.py` | 48 | End-to-end kit lifecycle across canonical, mixed, and legacy fixtures: install, register, normalize, validate, generate, update, interactive overwrite/prune flows, and Git/GitHub provenance | | `tests/test_cli_gitignore_e2e.py` | 5 | Managed `.gitignore` behavior for init, installed kits, generated public skills, and generated agent proxies | -| `tests/test_cli_kit_utility_e2e.py` | 7 | `chunk-input`, local kit install/update, deprecated `kit migrate`, and manifest-driven agent generation | +| `tests/test_cli_kit_utility_e2e.py` | 18 | `chunk-input` happy/error/dry-run paths, PDSL stdin/file contracts, TOC multi-file mutation behavior, local kit install/update, deprecated `kit migrate`, and manifest-driven agent generation | | `tests/test_cli_map_public_e2e.py` | 6 | Public `cfs map` JSON/HTML outputs, sidecar behavior, dangling IDs, federated workspace nodes, and invalid-config failure path | -| `tests/test_cli_navigation_e2e.py` | 2 | Navigation/read behavior for both single-project read-only mode and workspace-root source queries without a local adapter | -| `tests/test_cli_setup_e2e.py` | 9 | Setup/config surfaces: `info`, `resolve-vars`, `update` option validation, and `generate-agents` discovery/dry-run/show-layers flows | +| `tests/test_cli_navigation_e2e.py` | 5 | Navigation/read behavior for single-project read-only mode, workspace-root source queries, and negative/positional flows for `list-ids`, `where-defined`, and `where-used` | +| `tests/test_cli_router_e2e.py` | 3 | Top-level CLI router behavior for help JSON, alias routing, and unknown-command errors | +| `tests/test_cli_setup_e2e.py` | 16 | Setup/config surfaces: `info`, `resolve-vars`, update option validation, read-only error/degraded paths, and `generate-agents` discovery/dry-run/show-layers/legacy cleanup flows | | `tests/test_cli_update_e2e.py` | 3 | Exact `update` command error handling and dry-run non-mutation guarantees | -| `tests/test_cli_validate_e2e.py` | 6 | Exact `validate` public CLI scenarios, including artifact-not-in-registry, workspace-root source validation, and cross-artifact reference pass/fail paths | -| `tests/test_cli_validation_e2e.py` | 8 | `validate-toc`, `spec-coverage`, `check-language`, and alias forwarding behavior | -| `tests/test_cli_workspace_diag_e2e.py` | 13 | Workspace init/add/info/sync, delegate dry-run/non-dry-run/error paths, and `doctor` healthy/degraded/JSON exception/fail outputs | +| `tests/test_cli_validate_e2e.py` | 13 | Exact `validate` public CLI scenarios, including output-file mode, source/artifact mismatch paths, local-only/workspace behavior, cross-artifact reference pass/fail, and surfaced self-check/workspace-config failures | +| `tests/test_cli_validation_e2e.py` | 22 | `validate-toc`, `spec-coverage`, `check-language`, and alias forwarding across pass/fail/warn/verbose/output-path/threshold/error combinations | +| `tests/test_cli_workspace_diag_e2e.py` | 32 | Workspace init/add/info/sync permutations, delegate dry-run/non-dry-run/error paths, and `doctor` healthy/degraded/JSON exception/fail outputs | ## Not Runnable In Current Workspace @@ -63,7 +64,7 @@ The suite is still centered on public CLI behavior and observable filesystem eff - Map generation and workspace/delegation diagnostics - Error-path guarantees for missing roots, bad selectors, invalid configs, and unreachable workspace sources -The heaviest area remains `tests/test_cli_example_kits_e2e.py`, and it is currently fully green with `48` passing tests. +The heaviest area is now `tests/test_cli_example_kits_e2e.py` with `48` passing tests, followed by `tests/test_cli_workspace_diag_e2e.py` with `32`. ## Notes diff --git a/tests/test_cli_contracts_e2e.py b/tests/test_cli_contracts_e2e.py new file mode 100644 index 00000000..05af1091 --- /dev/null +++ b/tests/test_cli_contracts_e2e.py @@ -0,0 +1,491 @@ +"""Public JSON contract e2e coverage for selected CLI commands.""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import sys +import tomllib +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main + + +FIXTURE_KITS_DIR = Path(__file__).resolve().parent / "fixtures" / "kits" + + +@contextmanager +def _chdir(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _run_main_json(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(["--json", *argv]) + finally: + set_json_mode(saved_json_mode) + return rc, json.loads(stdout.getvalue()), stderr.getvalue() + + +def _make_cache(root: Path) -> Path: + cache = root / "cache" + for name in ("requirements", "schemas", "workflows", "skills"): + (cache / name).mkdir(parents=True, exist_ok=True) + (cache / name / "README.md").write_text(f"# {name}\n", encoding="utf-8") + (cache / "skills" / "studio").mkdir(parents=True, exist_ok=True) + (cache / "skills" / "studio" / "SKILL.md").write_text( + "---\nname: studio\ndescription: Test Studio skill\n---\n# Studio\n", + encoding="utf-8", + ) + for workflow_name in ("generate", "analyze", "plan", "explore", "workspace"): + (cache / "workflows" / f"{workflow_name}.md").write_text( + ( + "---\n" + "type: workflow\n" + f"name: {workflow_name}\n" + f"description: Test {workflow_name} workflow\n" + "---\n" + f"# {workflow_name.title()}\n" + ), + encoding="utf-8", + ) + for rel in ( + "architecture/specs/traceability.md", + "architecture/specs/CDSL.md", + "architecture/specs/PDSL.md", + "architecture/specs/cli.md", + "architecture/specs/CLISPEC.md", + "architecture/specs/artifacts-registry.md", + "architecture/specs/kit/constraints.md", + "architecture/specs/kit/kit.md", + ): + target = cache / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"# {rel}\n", encoding="utf-8") + (cache / "whatsnew.toml").write_text( + '[whatsnew."v1.0.0"]\nsummary = "Initial"\ndetails = ""\n', + encoding="utf-8", + ) + (cache / "version.toml").write_text( + '[cfs]\nversion = "v1.0.0"\n', + encoding="utf-8", + ) + return cache + + +def _init_project(root: Path, cache: Path) -> None: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + with patch("studio.commands.init.CACHE_DIR", cache), patch( + "studio.commands.init._install_default_kit", + return_value={}, + ): + rc, out, stderr = _run_main_json( + [ + "init", + "--project-root", + str(root), + "--install-dir", + ".bootstrap", + "--runtime-tracking", + "ignored", + "--agent-tracking", + "ignored", + "--kit-tracking", + "ignored", + "--yes", + ], + cwd=root, + ) + assert rc == 0, stderr + assert out["status"] == "PASS", out + + +def _copy_fixture(src_name: str, dst: Path) -> Path: + shutil.copytree(FIXTURE_KITS_DIR / src_name, dst) + return dst + + +def _make_manifest_kit_source(root: Path, slug: str = "manifestkit") -> Path: + kit_src = root / slug + (kit_src / "artifacts" / "FEATURE").mkdir(parents=True, exist_ok=True) + (kit_src / "artifacts" / "FEATURE" / "template.md").write_text( + "# Feature Spec\n", + encoding="utf-8", + ) + (kit_src / "SKILL.md").write_text( + f"---\nname: skill\ndescription: Kit {slug}\n---\n# Kit {slug}\n", + encoding="utf-8", + ) + (kit_src / "AGENTS.md").write_text( + f"---\nname: agents\ndescription: Agents {slug}\n---\n# Agents {slug}\n", + encoding="utf-8", + ) + (kit_src / "constraints.toml").write_text( + f"[naming]\npattern = '{slug}-*'\n", + encoding="utf-8", + ) + (kit_src / "conf.toml").write_text( + f'version = "1.0.0"\nslug = "{slug}"\n', + encoding="utf-8", + ) + (kit_src / "manifest.toml").write_text( + "\n".join( + [ + "[manifest]", + 'version = "1"', + 'root = "{cf-studio-path}/config/kits/{slug}"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "skill"', + 'source = "SKILL.md"', + 'default_path = "SKILL.md"', + 'type = "file"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "agents"', + 'source = "AGENTS.md"', + 'default_path = "AGENTS.md"', + 'type = "file"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "constraints"', + 'source = "constraints.toml"', + 'default_path = "constraints.toml"', + 'type = "file"', + "user_modifiable = false", + ], + ) + + "\n", + encoding="utf-8", + ) + return kit_src + + +def _empty_workflows_section() -> dict: + return { + "created": [], + "updated": [], + "unchanged": [], + "renamed": [], + "deleted": [], + "errors": [], + "counts": { + "created": 0, + "updated": 0, + "unchanged": 0, + "renamed": 0, + "deleted": 0, + }, + } + + +def _empty_output_section() -> dict: + return { + "created": [], + "updated": [], + "deleted": [], + "skipped": [], + "outputs": [], + "counts": { + "created": 0, + "updated": 0, + "deleted": 0, + "skipped": 0, + }, + } + + +class TestCliContractsE2E(unittest.TestCase): + def test_generate_agents_partial_json_contract_includes_partial_reasons_shape(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", temp_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + + partial_result = { + "status": "PARTIAL", + "agent": "openai", + "workflows": _empty_workflows_section(), + "skills": { + **_empty_output_section(), + "outputs": [ + { + "path": ".agents/skills/cf-example-v2-review/SKILL.md", + "action": "preserved", + "reason": "user_modified", + }, + ], + }, + "subagents": { + "created": [], + "updated": [], + "deleted": [], + "skipped": ["reviewer-helper"], + "skip_reason": "provider_not_supported", + "outputs": [], + "counts": {"created": 0, "updated": 0, "deleted": 0}, + }, + "rules": { + "created": [], + "updated": [], + "deleted": [], + "skipped": True, + "skip_reason": "target_not_supported", + "outputs": [], + "counts": {"created": 0, "updated": 0, "deleted": 0}, + }, + "errors": ["failed to inspect .codex/agents/cf-example-v2-reviewer.toml"], + } + + with patch("studio.commands.agents._process_single_agent", return_value=partial_result), patch( + "studio.commands.agents._refresh_managed_gitignore", + return_value="updated", + ): + rc, out, stderr = _run_main_json( + ["generate-agents", "--agent", "openai", "--root", str(project_root), "--yes"], + cwd=project_root, + ) + + self.assertEqual(rc, 1) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "PARTIAL") + self.assertEqual(out["agents"], ["openai"]) + self.assertEqual(out["gitignore"], "updated") + self.assertFalse(out["dry_run"]) + self.assertIn("results", out) + self.assertEqual(sorted(out["results"].keys()), ["openai"]) + self.assertEqual(out["results"]["openai"]["status"], "PARTIAL") + + partial_reasons = out["partial_reasons"] + self.assertIsInstance(partial_reasons, list) + self.assertEqual(len(partial_reasons), 1) + partial_reason = partial_reasons[0] + self.assertEqual(partial_reason["agent"], "openai") + self.assertEqual( + partial_reason["categories"], + ["errors", "preserved_outputs", "skipped_components"], + ) + self.assertEqual( + partial_reason["errors"], + ["failed to inspect .codex/agents/cf-example-v2-reviewer.toml"], + ) + self.assertEqual( + partial_reason["preserved_outputs"], + [ + { + "path": ".agents/skills/cf-example-v2-review/SKILL.md", + "reason": "user_modified", + }, + ], + ) + self.assertEqual( + partial_reason["skipped"], + [ + "subagents: provider_not_supported", + "rules: target_not_supported", + ], + ) + + def test_kit_update_partial_json_contract_reports_top_level_partial_reasons(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + local_kit = _copy_fixture("example-v2", temp_root / "local-kits" / "example-v2") + + rc, out, stderr = _run_main_json( + ["kit", "install", "--path", str(local_kit), "--install-mode", "copy"], + cwd=project_root, + ) + self.assertEqual(rc, 0, stderr) + + installed_template = ( + project_root + / ".bootstrap" + / "config" + / "kits" + / "example-v2" + / "artifacts" + / "PRD" + / "template.md" + ) + installed_template.write_text("USER MODIFIED TEMPLATE\n", encoding="utf-8") + (local_kit / "artifacts" / "PRD" / "template.md").write_text( + "@cpt-template:cpt-example-v2-prd-template:p1\n# Upstream changed template\n", + encoding="utf-8", + ) + + rc, out, stderr = _run_main_json( + ["kit", "update", "--path", str(local_kit), "--force", "--no-interactive", "-y"], + cwd=project_root, + ) + + self.assertEqual(rc, 2) + self.assertEqual(out["status"], "WARN") + self.assertEqual(out["kits_updated"], 1) + self.assertEqual(out["results"][0]["kit"], "example-v2") + self.assertEqual(out["results"][0]["action"], "partial") + self.assertEqual(out["results"][0]["declined"], ["artifacts/PRD/template.md"]) + + partial_reasons = out["partial_reasons"] + self.assertEqual( + partial_reasons, + [ + { + "kit": "example-v2", + "declined": ["artifacts/PRD/template.md"], + "categories": ["declined_files", "partial_update"], + }, + ], + ) + + def test_kit_check_updates_warn_contract_keeps_exit_zero_for_mixed_degraded_results(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + _init_project(project_root, cache) + + kits_map = {"alpha": {}, "beta": {}, "gamma": {}} + mixed_results = [ + { + "kit": "alpha", + "action": "current", + "installed_ref": "v1.0.0", + "latest_ref": "v1.0.0", + }, + { + "kit": "beta", + "action": "update_available", + "installed_ref": "v1.0.0", + "latest_ref": "v1.1.0", + "command": "cfs kit update beta", + }, + { + "kit": "gamma", + "action": "failed", + "message": "remote unavailable", + }, + ] + + with patch("studio.commands.kit._read_kits_from_core_toml", return_value=kits_map), patch( + "studio.commands.kit._check_registered_kit_updates", + return_value=(mixed_results, []), + ): + rc, out, stderr = _run_main_json( + ["kit", "check-updates", "--project-root", str(project_root)], + cwd=project_root, + ) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "WARN") + self.assertEqual(out["updates_available"], 1) + self.assertEqual(out["message"], "Kit updates available") + self.assertEqual(out["commands"], ["cfs kit update beta"]) + self.assertEqual(out["errors"], ["gamma: remote unavailable"]) + + results = {result["kit"]: result for result in out["results"]} + self.assertEqual(results["alpha"]["action"], "current") + self.assertEqual(results["beta"]["action"], "update_available") + self.assertEqual(results["beta"]["command"], "cfs kit update beta") + self.assertEqual(results["gamma"]["action"], "failed") + self.assertEqual(results["gamma"]["message"], "remote unavailable") + + def test_kit_normalize_dry_run_json_contract_includes_manifest_without_writing(self): + with TemporaryDirectory() as td: + kit_src = _make_manifest_kit_source(Path(td), "manifestkit") + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(kit_src), "--dry-run"], + cwd=Path(td), + ) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["action"], "normalized") + self.assertTrue(out["dry_run"]) + self.assertEqual(out["kit"], "manifestkit") + self.assertEqual(out["kits"], ["manifestkit"]) + self.assertEqual(out["kits_normalized"], 1) + self.assertEqual(out["output"], str((kit_src / ".cf-studio-kit.toml").resolve())) + self.assertEqual(out["report"]["manifest_source"], "legacy_manifest") + self.assertIn("manifest", out) + manifest_data = tomllib.loads(out["manifest"]) + self.assertEqual(manifest_data["manifest_version"], "1.0") + self.assertEqual(manifest_data["kits"][0]["slug"], "manifestkit") + self.assertFalse((kit_src / ".cf-studio-kit.toml").exists()) + + def test_kit_normalize_write_json_contract_omits_manifest_and_writes_same_output(self): + with TemporaryDirectory() as td: + root = Path(td) + kit_src = _make_manifest_kit_source(root, "manifestkit") + + dry_rc, dry_out, dry_stderr = _run_main_json( + ["kit", "normalize", str(kit_src), "--dry-run"], + cwd=root, + ) + self.assertEqual(dry_rc, 0) + self.assertEqual(dry_stderr, "") + + rc, out, stderr = _run_main_json( + ["kit", "normalize", str(kit_src)], + cwd=root, + ) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["action"], "normalized") + self.assertFalse(out["dry_run"]) + self.assertEqual(out["kit"], "manifestkit") + self.assertEqual(out["kits"], ["manifestkit"]) + self.assertEqual(out["kits_normalized"], 1) + self.assertEqual(out["output"], str((kit_src / ".cf-studio-kit.toml").resolve())) + self.assertEqual(out["report"], dry_out["report"]) + self.assertNotIn("manifest", out) + + written_manifest = (kit_src / ".cf-studio-kit.toml").read_text(encoding="utf-8") + self.assertEqual(written_manifest, dry_out["manifest"]) + written_data = tomllib.loads(written_manifest) + self.assertEqual(written_data["manifest_version"], "1.0") + self.assertEqual(written_data["kits"][0]["slug"], "manifestkit") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_fs_invariants_e2e.py b/tests/test_cli_fs_invariants_e2e.py new file mode 100644 index 00000000..6f0c4cb8 --- /dev/null +++ b/tests/test_cli_fs_invariants_e2e.py @@ -0,0 +1,546 @@ +"""Public CLI e2e coverage for filesystem invariants and stale-output cleanup.""" + +from __future__ import annotations + +import io +import json +import os +import sys +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main +from studio.commands.init import GITIGNORE_MARKER_END, GITIGNORE_MARKER_START +from studio.utils import toml_utils + + +@contextmanager +def _chdir(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(["--json", *argv]) + return rc, json.loads(stdout.getvalue()), stderr.getvalue() + + +def _make_cache(root: Path) -> Path: + cache = root / "cache" + for name in ("requirements", "schemas", "workflows", "skills"): + (cache / name).mkdir(parents=True, exist_ok=True) + (cache / name / "README.md").write_text(f"# {name}\n", encoding="utf-8") + (cache / "skills" / "studio").mkdir(parents=True, exist_ok=True) + (cache / "skills" / "studio" / "SKILL.md").write_text( + "---\nname: studio\ndescription: Test Studio skill\n---\n# Studio\n", + encoding="utf-8", + ) + for workflow_name in ("generate", "analyze", "plan", "explore", "workspace"): + (cache / "workflows" / f"{workflow_name}.md").write_text( + ( + "---\n" + "type: workflow\n" + f"name: {workflow_name}\n" + f"description: Test {workflow_name} workflow\n" + "---\n" + f"# {workflow_name.title()}\n" + ), + encoding="utf-8", + ) + for rel in ( + "architecture/specs/traceability.md", + "architecture/specs/CDSL.md", + "architecture/specs/PDSL.md", + "architecture/specs/cli.md", + "architecture/specs/CLISPEC.md", + "architecture/specs/artifacts-registry.md", + "architecture/specs/kit/constraints.md", + "architecture/specs/kit/kit.md", + ): + target = cache / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"# {rel}\n", encoding="utf-8") + (cache / "whatsnew.toml").write_text( + '[whatsnew."v1.0.0"]\nsummary = "Initial"\ndetails = ""\n', + encoding="utf-8", + ) + (cache / "version.toml").write_text( + '[cfs]\nversion = "v1.0.0"\n', + encoding="utf-8", + ) + return cache + + +def _init_project( + root: Path, + cache: Path, + *, + runtime_tracking: str = "tracked", + agent_tracking: str = "tracked", + kit_tracking: str = "tracked", +) -> dict: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + with patch("studio.commands.init.CACHE_DIR", cache), patch( + "studio.commands.init._install_default_kit", + return_value={}, + ): + rc, out, stderr = _run_main( + [ + "init", + "--project-root", + str(root), + "--install-dir", + ".bootstrap", + "--runtime-tracking", + runtime_tracking, + "--agent-tracking", + agent_tracking, + "--kit-tracking", + kit_tracking, + "--yes", + ], + cwd=root, + ) + assert rc == 0, stderr + assert out["status"] == "PASS", out + return out + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _extract_managed_gitignore_block(root: Path) -> list[str]: + lines = (root / ".gitignore").read_text(encoding="utf-8").splitlines() + self_start = [index for index, line in enumerate(lines) if line == GITIGNORE_MARKER_START] + self_end = [index for index, line in enumerate(lines) if line == GITIGNORE_MARKER_END] + if len(self_start) != 1 or len(self_end) != 1: + raise AssertionError("Expected exactly one managed Constructor Studio .gitignore block") + start_idx = self_start[0] + end_idx = self_end[0] + if end_idx < start_idx: + raise AssertionError("Malformed Constructor Studio managed .gitignore block") + return lines[start_idx : end_idx + 1] + + +def _extract_managed_gitignore_entries(root: Path) -> list[str]: + return [ + line + for line in _extract_managed_gitignore_block(root) + if line + and not line.startswith("#") + ] + + +def _kit_resources_by_id(project_root: Path, slug: str) -> dict[str, dict]: + core = toml_utils.load(project_root / ".bootstrap" / "config" / "core.toml") + kits = core.get("kits", {}) + assert isinstance(kits, dict), core + entry = kits.get(slug, {}) + assert isinstance(entry, dict), entry + resources = entry.get("resources", {}) + assert isinstance(resources, dict), resources + return resources + + +def _resource_path_map(project_root: Path, slug: str) -> dict[str, str]: + resources = _kit_resources_by_id(project_root, slug) + return { + resource_id: str(binding.get("path", "")) + for resource_id, binding in sorted(resources.items()) + if isinstance(binding, dict) + } + + +def _assert_resource_paths_match_installed_tree( + testcase: unittest.TestCase, + project_root: Path, + slug: str, +) -> None: + installed_root = project_root / ".bootstrap" / "config" / "kits" / slug + tree = _snapshot_tree(installed_root) + file_paths = { + f"config/kits/{slug}/{rel}" + for rel, (kind, _content) in tree.items() + if kind == "file" + } + resource_paths = set(_resource_path_map(project_root, slug).values()) + testcase.assertEqual(resource_paths, file_paths) + + +def _write_manifest_copy_kit_source( + root: Path, + *, + slug: str, + version: str, + include_guide: bool, +) -> Path: + kit_src = root / slug + (kit_src / "artifacts" / "FEATURE").mkdir(parents=True, exist_ok=True) + (kit_src / "docs").mkdir(parents=True, exist_ok=True) + (kit_src / "SKILL.md").write_text( + f"---\nname: {slug}\ndescription: Snapshot kit\n---\n# {slug}\n", + encoding="utf-8", + ) + (kit_src / "constraints.toml").write_text( + "[naming]\npattern = 'snap-*'\n", + encoding="utf-8", + ) + (kit_src / "artifacts" / "FEATURE" / "template.md").write_text( + f"# Feature template {version}\n", + encoding="utf-8", + ) + guide_path = kit_src / "docs" / "guide.md" + if include_guide: + guide_path.write_text( + f"# Guide {version}\n", + encoding="utf-8", + ) + elif guide_path.exists(): + guide_path.unlink() + (kit_src / "conf.toml").write_text( + f'version = "{version}"\nslug = "{slug}"\n', + encoding="utf-8", + ) + manifest_lines = [ + "[manifest]", + 'version = "1.0"', + 'root = "{cf-studio-path}/config/kits/{slug}"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "skill"', + 'source = "SKILL.md"', + 'default_path = "SKILL.md"', + 'type = "file"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "constraints"', + 'source = "constraints.toml"', + 'default_path = "constraints.toml"', + 'type = "file"', + "user_modifiable = false", + "", + "[[resources]]", + 'id = "feature_template"', + 'source = "artifacts/FEATURE/template.md"', + 'default_path = "artifacts/FEATURE/template.md"', + 'type = "file"', + "user_modifiable = false", + ] + if include_guide: + manifest_lines.extend( + [ + "", + "[[resources]]", + 'id = "guide"', + 'source = "docs/guide.md"', + 'default_path = "docs/guide.md"', + 'type = "file"', + "user_modifiable = false", + ] + ) + (kit_src / "manifest.toml").write_text("\n".join(manifest_lines) + "\n", encoding="utf-8") + return kit_src + + +def _write_public_manifest_kit_source( + root: Path, + *, + slug: str, + version: str, + skill_targets: tuple[str, ...], + agent_targets: tuple[str, ...], +) -> Path: + kit_src = root / slug + kit_src.mkdir(parents=True, exist_ok=True) + (kit_src / "skill.md").write_text( + "---\nname: helper\ndescription: Public helper skill\n---\n# Helper\n", + encoding="utf-8", + ) + (kit_src / "agent.md").write_text( + "---\nname: reviewer\ndescription: Public reviewer agent\n---\n# Reviewer\n", + encoding="utf-8", + ) + (kit_src / "conf.toml").write_text( + f'version = "{version}"\nslug = "{slug}"\n', + encoding="utf-8", + ) + target_skill_list = ", ".join(f'"{target}"' for target in skill_targets) + target_agent_list = ", ".join(f'"{target}"' for target in agent_targets) + (kit_src / ".cf-studio-kit.toml").write_text( + "\n".join( + [ + 'manifest_version = "1.0"', + "", + "[[kits]]", + f'slug = "{slug}"', + 'name = "Public Kit"', + f'version = "{version}"', + "", + "[[kits.resources]]", + 'id = "helper"', + 'kind = "skill"', + 'source = "skill.md"', + 'install_path = "skill.md"', + 'type = "file"', + "public = true", + f"generated_targets = [{target_skill_list}]", + 'description = "Helper skill"', + "", + "[[kits.resources]]", + 'id = "reviewer"', + 'kind = "agent"', + 'source = "agent.md"', + 'install_path = "agent.md"', + 'type = "file"', + "public = true", + f"generated_targets = [{target_agent_list}]", + 'description = "Reviewer agent"', + "", + "[kits.resources.reviewer]", + 'mode = "readonly"', + 'provider = "anthropic"', + 'reasoning_effort = "medium"', + ] + ) + + "\n", + encoding="utf-8", + ) + return kit_src + + +class TestCliFsInvariantsE2E(unittest.TestCase): + def test_copy_install_and_prune_update_keep_exact_tree_and_core_resources(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + kit_src = _write_manifest_copy_kit_source( + temp_root, + slug="snapkit", + version="1.0.0", + include_guide=True, + ) + _init_project( + project_root, + cache, + runtime_tracking="tracked", + agent_tracking="tracked", + kit_tracking="tracked", + ) + + install_rc, install_out, install_stderr = _run_main( + [ + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(install_rc, 0, install_stderr) + self.assertEqual(install_out["status"], "PASS") + + installed_root = project_root / ".bootstrap" / "config" / "kits" / "snapkit" + self.assertEqual( + _snapshot_tree(installed_root), + { + "SKILL.md": ("file", b"---\nname: snapkit\ndescription: Snapshot kit\n---\n# snapkit\n"), + "artifacts": ("dir", None), + "artifacts/FEATURE": ("dir", None), + "artifacts/FEATURE/template.md": ("file", b"# Feature template 1.0.0\n"), + "constraints.toml": ("file", b"[naming]\npattern = 'snap-*'\n"), + "docs": ("dir", None), + "docs/guide.md": ("file", b"# Guide 1.0.0\n"), + }, + ) + self.assertEqual( + _resource_path_map(project_root, "snapkit"), + { + "constraints": "config/kits/snapkit/constraints.toml", + "feature_template": "config/kits/snapkit/artifacts/FEATURE/template.md", + "guide": "config/kits/snapkit/docs/guide.md", + "skill": "config/kits/snapkit/SKILL.md", + }, + ) + _assert_resource_paths_match_installed_tree(self, project_root, "snapkit") + + _write_manifest_copy_kit_source( + temp_root, + slug="snapkit", + version="1.1.0", + include_guide=False, + ) + + update_rc, update_out, update_stderr = _run_main( + [ + "kit", + "update", + "--path", + str(kit_src), + "--prune", + "--no-interactive", + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(update_rc, 0, update_stderr) + self.assertEqual(update_out["status"], "PASS") + self.assertEqual( + _snapshot_tree(installed_root), + { + "SKILL.md": ("file", b"---\nname: snapkit\ndescription: Snapshot kit\n---\n# snapkit\n"), + "artifacts": ("dir", None), + "artifacts/FEATURE": ("dir", None), + "artifacts/FEATURE/template.md": ("file", b"# Feature template 1.1.0\n"), + "constraints.toml": ("file", b"[naming]\npattern = 'snap-*'\n"), + "docs": ("dir", None), + }, + ) + self.assertEqual( + _resource_path_map(project_root, "snapkit"), + { + "constraints": "config/kits/snapkit/constraints.toml", + "feature_template": "config/kits/snapkit/artifacts/FEATURE/template.md", + "skill": "config/kits/snapkit/SKILL.md", + }, + ) + _assert_resource_paths_match_installed_tree(self, project_root, "snapkit") + + def test_generate_agents_rewrites_exact_managed_gitignore_block_after_public_target_shift(self): + with TemporaryDirectory() as td: + temp_root = Path(td) + cache = _make_cache(temp_root) + project_root = temp_root / "proj" + kit_src = _write_public_manifest_kit_source( + temp_root, + slug="pubkit", + version="1.0.0", + skill_targets=("cursor",), + agent_targets=("cursor",), + ) + _init_project( + project_root, + cache, + runtime_tracking="tracked", + agent_tracking="ignored", + kit_tracking="tracked", + ) + base_block = _extract_managed_gitignore_block(project_root) + base_entries = _extract_managed_gitignore_entries(project_root) + + install_rc, install_out, install_stderr = _run_main( + [ + "kit", + "install", + "--path", + str(kit_src), + "--install-mode", + "copy", + ], + cwd=project_root, + ) + self.assertEqual(install_rc, 0, install_stderr) + self.assertEqual(install_out["status"], "PASS") + install_entries = set(_extract_managed_gitignore_entries(project_root)) + self.assertEqual(install_entries, set(base_entries)) + + first_generate_rc, first_generate_out, first_generate_stderr = _run_main( + [ + "generate-agents", + "--agent", + "cursor", + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(first_generate_rc, 0, first_generate_stderr) + first_cursor = first_generate_out.get("results", {}).get("cursor", first_generate_out) + self.assertEqual(first_cursor.get("status"), "PASS") + self.assertTrue((project_root / ".cursor" / "agents" / "cf-pubkit-reviewer.mdc").is_file()) + self.assertEqual( + set(_extract_managed_gitignore_entries(project_root)), + set(base_entries) + | { + ".agents/skills/cf-pubkit-helper/SKILL.md", + ".agents/skills/helper/SKILL.md", + ".claude/skills/helper/SKILL.md", + ".cursor/agents/cf-pubkit-reviewer.mdc", + }, + ) + + _write_public_manifest_kit_source( + temp_root, + slug="pubkit", + version="1.1.0", + skill_targets=("openai",), + agent_targets=("openai",), + ) + + update_rc, update_out, update_stderr = _run_main( + [ + "kit", + "update", + "--path", + str(kit_src), + "--no-interactive", + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(update_rc, 0, update_stderr) + self.assertEqual(update_out["status"], "PASS") + + second_generate_rc, second_generate_out, second_generate_stderr = _run_main( + [ + "generate-agents", + "--agent", + "openai", + "--yes", + ], + cwd=project_root, + ) + self.assertEqual(second_generate_rc, 0, second_generate_stderr) + second_openai = second_generate_out.get("results", {}).get("openai", second_generate_out) + self.assertEqual(second_openai.get("status"), "PASS") + self.assertFalse((project_root / ".cursor" / "agents" / "cf-pubkit-reviewer.mdc").exists()) + self.assertEqual( + set(_extract_managed_gitignore_entries(project_root)), + set(base_entries) + | { + ".agents/skills/cf-pubkit-helper/SKILL.md", + ".agents/skills/helper/SKILL.md", + ".claude/skills/helper/SKILL.md", + ".codex/agents/cf-pubkit-reviewer.toml", + }, + ) + self.assertEqual(_extract_managed_gitignore_block(project_root)[0], base_block[0]) + self.assertEqual(_extract_managed_gitignore_block(project_root)[-1], base_block[-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_human_mode_e2e.py b/tests/test_cli_human_mode_e2e.py new file mode 100644 index 00000000..b2281d60 --- /dev/null +++ b/tests/test_cli_human_mode_e2e.py @@ -0,0 +1,266 @@ +"""Human-mode CLI e2e coverage for generate-agents and kit flows.""" + +from __future__ import annotations + +import io +import os +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) + +from studio.cli import main +from studio.utils import toml_utils + + +class _TTYStringIO(io.StringIO): + def __init__(self, text: str = "", *, is_tty: bool) -> None: + super().__init__(text) + self._is_tty = is_tty + + def isatty(self) -> bool: + return self._is_tty + + +def _run_main_human( + argv: list[str], + *, + cwd: Path, + stdin_text: str = "", + stdin_tty: bool = False, +) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + + stdout = io.StringIO() + stderr = io.StringIO() + stdin = _TTYStringIO(stdin_text, is_tty=stdin_tty) + old_cwd = Path.cwd() + saved_json_mode = is_json_mode() + try: + set_json_mode(False) + os.chdir(cwd) + with patch("sys.stdin", stdin), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(argv) + return rc, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) + os.chdir(old_cwd) + + +def _snapshot_tree(root: Path) -> dict[str, tuple[str, bytes | None]]: + snapshot: dict[str, tuple[str, bytes | None]] = {} + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_dir(): + snapshot[rel] = ("dir", None) + elif path.is_file(): + snapshot[rel] = ("file", path.read_bytes()) + return snapshot + + +def _bootstrap_generate_agents_project(root: Path) -> None: + (root / ".git").mkdir(parents=True, exist_ok=True) + (root / "skills" / "cypilot").mkdir(parents=True, exist_ok=True) + (root / "skills" / "cypilot" / "SKILL.md").write_text( + "---\nname: cypilot\ndescription: Cypilot skill for testing\n---\n# Cypilot\n", + encoding="utf-8", + ) + (root / "workflows").mkdir(parents=True, exist_ok=True) + (root / "workflows" / "generate.md").write_text( + "---\ncypilot: true\ntype: workflow\nname: cypilot-generate\ndescription: Generate artifacts\n---\n# Generate\n", + encoding="utf-8", + ) + (root / "workflows" / "analyze.md").write_text( + "---\ncypilot: true\ntype: workflow\nname: cypilot-analyze\ndescription: Analyze artifacts\n---\n# Analyze\n", + encoding="utf-8", + ) + + +def _bootstrap_studio_project(root: Path, adapter_rel: str = "cypilot") -> Path: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + (root / "AGENTS.md").write_text( + ( + "\n" + "```toml\n" + f'cf-studio-path = "{adapter_rel}"\n' + "```\n" + "\n" + ), + encoding="utf-8", + ) + adapter = root / adapter_rel + (adapter / ".core").mkdir(parents=True, exist_ok=True) + (adapter / ".gen").mkdir(parents=True, exist_ok=True) + (adapter / "config").mkdir(parents=True, exist_ok=True) + (adapter / "config" / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + toml_utils.dump( + {"version": "1.0", "project_root": "..", "kits": {}}, + adapter / "config" / "core.toml", + ) + toml_utils.dump( + {"systems": [{"name": "TestProject", "slug": "test"}]}, + adapter / "config" / "artifacts.toml", + ) + return adapter + + +def _make_legacy_layout_kit_source(root: Path, slug: str = "layoutkit") -> Path: + kit_src = root / slug + (kit_src / "artifacts" / "FEATURE").mkdir(parents=True, exist_ok=True) + (kit_src / "artifacts" / "FEATURE" / "template.md").write_text( + "# Feature template\n", + encoding="utf-8", + ) + (kit_src / "workflows").mkdir(parents=True, exist_ok=True) + (kit_src / "workflows" / "review.md").write_text( + "---\ntype: workflow\nname: review\ndescription: Review\n---\n# Review\n", + encoding="utf-8", + ) + (kit_src / "SKILL.md").write_text( + f"---\nname: {slug}\ndescription: Legacy kit\n---\n# {slug}\n", + encoding="utf-8", + ) + (kit_src / "constraints.toml").write_text( + "[FEATURE.identifiers.flow]\nrequired = true\n", + encoding="utf-8", + ) + toml_utils.dump({"version": 1, "slug": slug}, kit_src / "conf.toml") + return kit_src + + +class TestCliHumanModeE2E(unittest.TestCase): + def test_generate_agents_human_preview_abort_preserves_tree(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generate_agents_project(root) + before = _snapshot_tree(root) + + rc, stdout, stderr = _run_main_human( + [ + "generate-agents", + "--agent", + "windsurf", + "--root", + str(root), + "--cf-constructor-root", + str(root), + ], + cwd=root, + stdin_text="n\n", + stdin_tty=True, + ) + + after = _snapshot_tree(root) + combined = stdout + stderr + + self.assertEqual(rc, 1) + self.assertEqual(after, before) + self.assertIn("Generate Agent Integration", combined) + self.assertIn("Reply with `y` to write these generated files or `n` to abort.", combined) + self.assertIn("Proceed? [Y/n]", combined) + self.assertIn("Aborted.", combined) + self.assertFalse((root / ".agents").exists()) + + def test_generate_agents_human_success_prints_real_cli_summary(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_generate_agents_project(root) + + rc, stdout, stderr = _run_main_human( + [ + "generate-agents", + "--agent", + "windsurf", + "--root", + str(root), + "--cf-constructor-root", + str(root), + "-y", + ], + cwd=root, + ) + + combined = stdout + stderr + + self.assertEqual(rc, 0) + self.assertIn("Constructor Studio Agent Setup", combined) + self.assertIn("windsurf", combined) + self.assertIn(".agents/skills/cf/SKILL.md", combined) + self.assertIn("Agent integration complete!", combined) + self.assertIn("Your IDE will now:", combined) + self.assertTrue((root / ".agents" / "skills" / "cf" / "SKILL.md").is_file()) + + def test_kit_update_human_partial_summary_after_mixed_decisions(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_studio_project(root) + kit_src = Path(td) / "demokit" + kit_src.mkdir(parents=True, exist_ok=True) + toml_utils.dump({"version": 1, "slug": "demokit"}, kit_src / "conf.toml") + + with patch("studio.commands.kit.show_kit_whatsnew", return_value=True), patch( + "studio.commands.kit.update_kit", + return_value={ + "version": {"status": "partial"}, + "gen": { + "files_written": 1, + "accepted_files": ["SKILL.md"], + "unchanged": 2, + }, + "gen_rejected": ["constraints.toml"], + }, + ), patch("studio.commands.kit.regenerate_gen_aggregates"): + rc, stdout, stderr = _run_main_human( + [ + "kit", + "update", + f"path/{kit_src}", + "--project-root", + str(root), + ], + cwd=root, + stdin_tty=True, + ) + + combined = stdout + stderr + + self.assertEqual(rc, 0) + self.assertIn("Kit Update", combined) + self.assertIn("Kits updated", combined) + self.assertIn("demokit: partial", combined) + self.assertIn("1 accepted", combined) + self.assertIn("1 declined", combined) + self.assertIn("2 unchanged", combined) + self.assertIn("SKILL.md", combined) + self.assertIn("constraints.toml (declined)", combined) + self.assertIn("partial reason for demokit: declined_files, partial_update", combined) + self.assertIn("Kit update complete.", combined) + + def test_kit_normalize_human_happy_path_writes_manifest(self): + with TemporaryDirectory() as td: + root = Path(td) + kit_src = _make_legacy_layout_kit_source(root) + + rc, stdout, stderr = _run_main_human( + ["kit", "normalize", str(kit_src), "--from", "layout"], + cwd=root, + ) + + combined = stdout + stderr + manifest_path = kit_src / ".cf-studio-kit.toml" + + self.assertEqual(rc, 0) + self.assertTrue(manifest_path.is_file()) + self.assertIn("Kit Normalize", combined) + self.assertIn("Canonical manifest written.", combined) + self.assertIn('manifest_version = "1.0"', manifest_path.read_text(encoding="utf-8")) + self.assertIn('slug = "layoutkit"', manifest_path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() From 4ff7f167ad30844754cb50909ddb82b49406af88 Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 00:59:20 +0300 Subject: [PATCH 05/14] Remove E2E Test Analysis Report and enhance setup E2E tests with legacy project bootstrapping and cache creation Signed-off-by: ainetx --- tests/e2e-analysis-report.md | 72 ------- tests/test_cli_setup_e2e.py | 392 ++++++++++++++++++++++++++++++++++- 2 files changed, 389 insertions(+), 75 deletions(-) delete mode 100644 tests/e2e-analysis-report.md diff --git a/tests/e2e-analysis-report.md b/tests/e2e-analysis-report.md deleted file mode 100644 index 67b20d30..00000000 --- a/tests/e2e-analysis-report.md +++ /dev/null @@ -1,72 +0,0 @@ -# E2E Test Analysis Report - -This report is synchronized with the current `*_e2e.py` suite state in `tests/`. - - - -- [Current Snapshot](#current-snapshot) -- [Fully Green Modules](#fully-green-modules) -- [Not Runnable In Current Workspace](#not-runnable-in-current-workspace) -- [Coverage Shape](#coverage-shape) -- [Notes](#notes) - - - -## Current Snapshot - -- Date: `2026-06-20` -- Command: `pytest tests/*_e2e.py -q --tb=no -rA` -- Result: `186 passed`, `1 skipped`, `3 subtests passed` -- Inventory: `15` e2e modules total -- Status split: - - `13` fully green runnable modules - - `1` skipped module - - `1` disabled / no runnable tests module - -Scope rules used for this report: - -- "Fully green" means every executed test in the module passed in the latest run. -- Skipped and disabled upgrade coverage is listed separately and is not counted as green. - -## Fully Green Modules - -| Module | Passing tests | Covered surface | -|---|---:|---| -| `tests/test_cli_agents_e2e.py` | 6 | Public `agents` CLI behavior, including default-target expansion, root override reporting, invalid-config handling, and read-only target selection | -| `tests/test_cli_artifact_tools_e2e.py` | 9 | Artifact utility commands: deprecated `generate-resources`, `get-content` error/read-only paths, `pdsl validate`, and TOC dry-run/write flows | -| `tests/test_cli_example_kits_e2e.py` | 48 | End-to-end kit lifecycle across canonical, mixed, and legacy fixtures: install, register, normalize, validate, generate, update, interactive overwrite/prune flows, and Git/GitHub provenance | -| `tests/test_cli_gitignore_e2e.py` | 5 | Managed `.gitignore` behavior for init, installed kits, generated public skills, and generated agent proxies | -| `tests/test_cli_kit_utility_e2e.py` | 18 | `chunk-input` happy/error/dry-run paths, PDSL stdin/file contracts, TOC multi-file mutation behavior, local kit install/update, deprecated `kit migrate`, and manifest-driven agent generation | -| `tests/test_cli_map_public_e2e.py` | 6 | Public `cfs map` JSON/HTML outputs, sidecar behavior, dangling IDs, federated workspace nodes, and invalid-config failure path | -| `tests/test_cli_navigation_e2e.py` | 5 | Navigation/read behavior for single-project read-only mode, workspace-root source queries, and negative/positional flows for `list-ids`, `where-defined`, and `where-used` | -| `tests/test_cli_router_e2e.py` | 3 | Top-level CLI router behavior for help JSON, alias routing, and unknown-command errors | -| `tests/test_cli_setup_e2e.py` | 16 | Setup/config surfaces: `info`, `resolve-vars`, update option validation, read-only error/degraded paths, and `generate-agents` discovery/dry-run/show-layers/legacy cleanup flows | -| `tests/test_cli_update_e2e.py` | 3 | Exact `update` command error handling and dry-run non-mutation guarantees | -| `tests/test_cli_validate_e2e.py` | 13 | Exact `validate` public CLI scenarios, including output-file mode, source/artifact mismatch paths, local-only/workspace behavior, cross-artifact reference pass/fail, and surfaced self-check/workspace-config failures | -| `tests/test_cli_validation_e2e.py` | 22 | `validate-toc`, `spec-coverage`, `check-language`, and alias forwarding across pass/fail/warn/verbose/output-path/threshold/error combinations | -| `tests/test_cli_workspace_diag_e2e.py` | 32 | Workspace init/add/info/sync permutations, delegate dry-run/non-dry-run/error paths, and `doctor` healthy/degraded/JSON exception/fail outputs | - -## Not Runnable In Current Workspace - -| Module | Current status | Reason | -|---|---|---| -| `tests/test_kit_upgrade_e2e.py` | Skipped | Raises `SkipTest` because local `kits/sdlc/` is not present; the kit moved to a separate repo | -| `tests/test_core_upgrade_e2e.py` | Disabled | `TestCoreUpgradeE2E` is decorated with `@unittest.skip(...)` for the unsupported `v3.x -> 4.0.0` breaking transition | - -## Coverage Shape - -The suite is still centered on public CLI behavior and observable filesystem effects: - -- Read-only validation and diagnostics paths -- Bounded-write setup and bootstrap flows -- Kit lifecycle management, including complex update and overwrite decisions -- Provider/agent generation and managed `.gitignore` integration -- Map generation and workspace/delegation diagnostics -- Error-path guarantees for missing roots, bad selectors, invalid configs, and unreachable workspace sources - -The heaviest area is now `tests/test_cli_example_kits_e2e.py` with `48` passing tests, followed by `tests/test_cli_workspace_diag_e2e.py` with `32`. - -## Notes - -- The report previously reflected older mixed/failing snapshots; the current runnable `*_e2e.py` suite is fully green. -- This version is intentionally a runtime status report for the latest observed suite result, not a static catalog of intended behaviors. diff --git a/tests/test_cli_setup_e2e.py b/tests/test_cli_setup_e2e.py index 05081a75..ae7918a7 100644 --- a/tests/test_cli_setup_e2e.py +++ b/tests/test_cli_setup_e2e.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) from studio.cli import main +from studio.utils import toml_utils @contextmanager @@ -47,8 +48,6 @@ def _run_main_json(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: def _write_toml(path: Path, data: dict) -> None: - from studio.utils import toml_utils - path.parent.mkdir(parents=True, exist_ok=True) toml_utils.dump(data, path) @@ -132,6 +131,77 @@ def _bootstrap_legacy_cypilot_outputs(root: Path) -> None: ) +def _make_test_cache(cache_dir: Path) -> Path: + from _test_helpers import make_test_cache + + make_test_cache(cache_dir) + (cache_dir / "whatsnew.toml").write_text( + '[whatsnew."v1.0.0"]\nsummary = "Initial"\ndetails = ""\n', + encoding="utf-8", + ) + (cache_dir / "version.toml").write_text( + '[cfs]\nversion = "v1.0.0"\n', + encoding="utf-8", + ) + return cache_dir + + +def _bootstrap_legacy_project(root: Path, legacy_dir: str = "cypilot", version: str = "3.9.0") -> Path: + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir(exist_ok=True) + (root / "AGENTS.md").write_text( + '\n' + '```toml\n' + f'cypilot_path = "{legacy_dir}"\n' + '```\n' + '\n' + '\n' + '# Project rules\n', + encoding="utf-8", + ) + (root / "CLAUDE.md").write_text( + '\n' + '```toml\n' + f'cypilot_path = "{legacy_dir}"\n' + '```\n' + '\n', + encoding="utf-8", + ) + legacy_root = root / legacy_dir + config = legacy_root / "config" + config.mkdir(parents=True, exist_ok=True) + (config / "core.toml").write_text( + "# Cypilot project configuration\n" + 'version = "1.0"\n' + 'project_root = ".."\n' + "\n" + "[kits]\n" + "[kits.sdlc]\n" + 'format = "CFS"\n' + 'path = "config/kits/sdlc"\n' + 'version = "1.0.0"\n' + 'source = "github:cyberfabric/cyber-pilot-kit-sdlc"\n', + encoding="utf-8", + ) + (config / "artifacts.toml").write_text( + "# Cypilot artifacts registry\n" + "\n" + "[[systems]]\n" + 'name = "App"\n' + 'slug = "app"\n' + 'kit = "sdlc"\n', + encoding="utf-8", + ) + (config / "AGENTS.md").write_text( + "These rules are loaded alongside `{cypilot_path}/.gen/AGENTS.md`.\n", + encoding="utf-8", + ) + version_file = legacy_root / ".core" / "skills" / "cypilot" / "scripts" / "cypilot" / "__init__.py" + version_file.parent.mkdir(parents=True, exist_ok=True) + version_file.write_text(f'__version__ = "{version}"\n', encoding="utf-8") + return legacy_root + + class TestInfoAndResolveVarsE2E(unittest.TestCase): def test_info_no_project_root_returns_not_found_without_writes(self): with TemporaryDirectory() as td: @@ -214,6 +284,41 @@ def test_info_legacy_registry_json_fallback_is_reported(self): self.assertEqual(out["artifacts_registry"]["systems"][0]["slug"], "legacy") self.assertEqual(_snapshot_tree(root), before) + def test_info_cf_studio_root_override_returns_same_project_without_writes(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir() + cfs_root = root / "Cypilot" + cfs_root.mkdir() + (cfs_root / "AGENTS.md").write_text("# Cypilot Core\n", encoding="utf-8") + (cfs_root / "requirements").mkdir() + (cfs_root / "workflows").mkdir() + adapter = root / ".cypilot-adapter" + (adapter / "config" / "rules").mkdir(parents=True, exist_ok=True) + (adapter / "AGENTS.md").write_text( + "# Constructor Studio Adapter: RealProject\n\n" + "**Extends**: `../Cypilot/AGENTS.md`\n", + encoding="utf-8", + ) + _write_toml(adapter / "config" / "core.toml", {"version": "1.0", "project_root": "..", "kits": {}}) + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json( + ["info", "--root", str(root), "--cf-studio-root", str(cfs_root)], + cwd=root, + ) + + self.assertEqual(rc, 0) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "FOUND") + self.assertEqual(out["project_root"], root.resolve().as_posix()) + self.assertEqual(out["project_name"], "RealProject") + self.assertEqual(out["relative_path"], ".cypilot-adapter") + self.assertTrue(out["has_config"]) + self.assertEqual(out["studio_dir"], adapter.resolve().as_posix()) + self.assertEqual(_snapshot_tree(root), before) + def test_resolve_vars_flat_success_via_main(self): with TemporaryDirectory() as td: root = Path(td) / "proj" @@ -410,6 +515,93 @@ def test_update_dry_run_accepts_explicit_option_matrix(self): self.assertEqual(core_toml_path.read_text(encoding="utf-8"), core_toml_before) self.assertEqual(_snapshot_tree(root), before) + def test_update_success_has_bounded_filesystem_diff(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + _write_toml( + adapter / "config" / "core.toml", + { + "version": "1.0", + "project_root": "..", + "install": { + "version_source": "project_config", + "runtime_tracking": "ignored", + "agent_tracking": "ignored", + "kit_tracking": "tracked", + }, + "kits": {}, + }, + ) + _write_toml( + adapter / "config" / "artifacts.toml", + {"version": "1.0", "project_root": "..", "kits": {}, "systems": []}, + ) + (adapter / ".core" / "obsolete.txt").write_text("old\n", encoding="utf-8") + (adapter / "whatsnew.toml").write_text('[whatsnew."v0.9.0"]\nsummary = "Old"\n', encoding="utf-8") + (adapter / "version.toml").write_text('[cfs]\nversion = "v0.9.0"\n', encoding="utf-8") + before = _snapshot_tree(root) + cache_dir = _make_test_cache(Path(td) / "cache") + + with patch("studio.commands.update.CACHE_DIR", cache_dir): + rc, out, stderr = _run_main_json( + ["update", "--project-root", str(root), "--yes", "--migrate-from-cypilot", "no", "--update-legacy-studio", "no"], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertIn("What's new in Studio", stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["actions"]["gitignore"], "created") + self.assertEqual(out["actions"]["root_agents"], "updated") + self.assertEqual(out["actions"]["root_claude"], "created") + self.assertEqual(out["actions"]["config_readme"], "created") + self.assertEqual(out["actions"]["config_skill"], "created") + self.assertEqual(out["actions"]["kits"]["status"], "skipped") + self.assertEqual(out["validate_kits"]["status"], "PASS") + + after = _snapshot_tree(root) + added_paths = sorted(set(after) - set(before)) + removed_paths = sorted(set(before) - set(after)) + changed_paths = sorted(path for path in set(before) & set(after) if before[path] != after[path]) + + self.assertEqual( + added_paths, + sorted( + [ + ".gitignore", + "CLAUDE.md", + "adapter/.core/README.md", + "adapter/.core/requirements", + "adapter/.core/requirements/README.md", + "adapter/.core/schemas", + "adapter/.core/schemas/README.md", + "adapter/.core/skills", + "adapter/.core/skills/README.md", + "adapter/.core/workflows", + "adapter/.core/workflows/README.md", + "adapter/.gen/AGENTS.md", + "adapter/.gen/README.md", + "adapter/config/README.md", + "adapter/config/SKILL.md", + "adapter/config/core.toml.lock", + ] + ), + ) + self.assertEqual(removed_paths, ["adapter/.core/obsolete.txt"]) + self.assertEqual( + changed_paths, + sorted( + [ + "AGENTS.md", + "adapter/config/core.toml", + "adapter/version.toml", + "adapter/whatsnew.toml", + ] + ), + ) + self.assertFalse((adapter / ".core" / "obsolete.txt").exists()) + class TestAgentsAndGenerateAgentsE2E(unittest.TestCase): def test_generate_agents_show_layers_legacy_via_main(self): @@ -569,7 +761,6 @@ def test_generate_agents_yes_remove_cypilot_cleans_legacy_outputs(self): ) self.assertEqual(rc, 0, stderr) - self.assertEqual(stderr, "") self.assertEqual(out["status"], "PASS") claude = out["results"]["claude"] @@ -614,5 +805,200 @@ def test_agents_openai_flag_stays_read_only(self): self.assertFalse((root / ".agents").exists()) +class TestValidateKitsAndInitE2E(unittest.TestCase): + def test_validate_kits_kit_filter_validates_only_selected_registered_kit(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + adapter = _bootstrap_adapter_project(root) + _write_toml( + adapter / "config" / "core.toml", + { + "version": "1.0", + "project_root": "..", + "kits": { + "alpha": {"format": "CFS", "path": "config/kits/alpha"}, + "beta": {"format": "CFS", "path": "config/kits/beta"}, + }, + }, + ) + _write_toml( + adapter / "config" / "artifacts.toml", + { + "version": "1.0", + "project_root": "..", + "kits": { + "alpha": {"format": "CFS", "path": "config/kits/alpha"}, + "beta": {"format": "CFS", "path": "config/kits/beta"}, + }, + "systems": [], + }, + ) + for slug in ("alpha", "beta"): + kit_root = adapter / "config" / "kits" / slug + kit_root.mkdir(parents=True, exist_ok=True) + toml_utils.dump({"artifacts": {"REQ": {"identifiers": {"req": {"required": True}}}}}, kit_root / "constraints.toml") + before = _snapshot_tree(root) + + rc, out, stderr = _run_main_json(["validate-kits", "--kit", "beta"], cwd=root) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_validated"], 1) + self.assertEqual(out["error_count"], 0) + self.assertEqual(_snapshot_tree(root), before) + + def test_init_project_name_writes_custom_registry_name(self): + with TemporaryDirectory() as td: + root = Path(td) / "my-proj" + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir() + cache_dir = _make_test_cache(Path(td) / "cache") + + with ( + patch("studio.commands.init.CACHE_DIR", cache_dir), + patch("studio.commands.init._install_default_kit", return_value={}), + ): + rc, out, stderr = _run_main_json( + ["init", "--yes", "--project-root", str(root), "--project-name", "Custom Name"], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["root_system"], {"name": "MyProj", "slug": "my-proj"}) + registry = toml_utils.load(root / ".cf-studio" / "config" / "artifacts.toml") + self.assertEqual(registry["systems"][0]["name"], "Custom Name") + self.assertEqual(registry["systems"][0]["slug"], "custom-name") + + def test_init_from_dir_migrates_explicit_legacy_directory(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_legacy_project(root, legacy_dir=".bootstrap") + + with patch("studio.commands.migrate_from_cypilot._run_followup_update", return_value=(0, {"status": "PASS"})): + rc, out, stderr = _run_main_json( + [ + "init", + "--yes", + "--project-root", + str(root), + "--from-dir", + ".bootstrap", + "--migrate-from-cypilot", + "yes", + ], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["from_dir"], ".bootstrap") + self.assertEqual(out["actions"]["update"], "PASS") + self.assertTrue((root / ".cf-studio" / "config" / "core.toml").is_file()) + self.assertIn('cf-studio-path = ".cf-studio"', (root / "AGENTS.md").read_text(encoding="utf-8")) + + def test_init_force_replaces_runtime_and_creates_backup(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir(parents=True, exist_ok=True) + (root / ".git").mkdir() + cache_dir = _make_test_cache(Path(td) / "cache") + + with ( + patch("studio.commands.init.CACHE_DIR", cache_dir), + patch("studio.commands.init._install_default_kit", return_value={}), + ): + first_rc, _first_out, first_stderr = _run_main_json( + ["init", "--yes", "--project-root", str(root), "--install-dir", ".bootstrap"], + cwd=root, + ) + self.assertEqual(first_rc, 0, first_stderr) + + stale = root / ".bootstrap" / ".core" / "stale.txt" + stale.write_text("remove me\n", encoding="utf-8") + + rc, out, stderr = _run_main_json( + ["init", "--yes", "--force", "--project-root", str(root), "--install-dir", ".bootstrap"], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(stderr, "") + self.assertEqual(out["status"], "PASS") + self.assertIn("backups", out) + self.assertTrue(out["backups"]) + self.assertFalse(stale.exists()) + backup_dir = Path(out["backups"][0]) + self.assertTrue((backup_dir / ".core" / "stale.txt").is_file()) + + def test_init_migrate_yes_migrates_legacy_project_without_prompt(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_legacy_project(root, legacy_dir="cypilot", version="3.9.0") + + with patch("studio.commands.migrate_from_cypilot._run_followup_update", return_value=(0, {"status": "PASS"})): + rc, out, stderr = _run_main_json( + [ + "init", + "--yes", + "--project-root", + str(root), + "--migrate-from-cypilot", + "yes", + ], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["from_dir"], "cypilot") + self.assertEqual(out["actions"]["update"], "PASS") + self.assertTrue((root / ".cf-studio" / "config" / "core.toml").is_file()) + self.assertIn('cf-studio-path = ".cf-studio"', (root / "AGENTS.md").read_text(encoding="utf-8")) + + def test_init_update_legacy_studio_yes_updates_baseline_then_migrates(self): + with TemporaryDirectory() as td: + root = Path(td) / "proj" + _bootstrap_legacy_project(root, legacy_dir="cypilot", version="3.8.4") + + def _upgrade_legacy(project_root: Path): + version_file = ( + project_root + / "cypilot" + / ".core" + / "skills" + / "cypilot" + / "scripts" + / "cypilot" + / "__init__.py" + ) + version_file.write_text('__version__ = "3.10.0"\n', encoding="utf-8") + return {"status": "PASS", "returncode": 0} + + with ( + patch("studio.commands.migrate_from_cypilot._run_legacy_update_to_baseline", side_effect=_upgrade_legacy), + patch("studio.commands.migrate_from_cypilot._run_followup_update", return_value=(0, {"status": "PASS"})), + ): + rc, out, stderr = _run_main_json( + [ + "init", + "--yes", + "--project-root", + str(root), + "--migrate-from-cypilot", + "yes", + "--update-legacy-studio", + "yes", + ], + cwd=root, + ) + + self.assertEqual(rc, 0, stderr) + self.assertEqual(out["status"], "PASS") + self.assertEqual(out["normalized_legacy_version"], "3.10.0") + self.assertEqual(out["actions"]["update"], "PASS") + self.assertTrue((root / ".cf-studio" / "config" / "core.toml").is_file()) + + if __name__ == "__main__": unittest.main() From 8b9076220ea7a3e2eec0ecbfac077a7447bad692 Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 01:06:53 +0300 Subject: [PATCH 06/14] feat: enhance E2E tests with additional coverage for CLI commands and workspace diagnostics Signed-off-by: ainetx --- .../studio/scripts/studio/commands/agents.py | 208 +++++++++++++++--- skills/studio/scripts/studio/commands/kit.py | 36 +-- .../scripts/studio/commands/workspace_info.py | 33 ++- tests/test_cli_artifact_tools_e2e.py | 131 ++++++++++- tests/test_cli_contracts_e2e.py | 9 +- tests/test_cli_example_kits_e2e.py | 5 + tests/test_cli_human_mode_e2e.py | 2 +- tests/test_cli_setup_e2e.py | 7 +- tests/test_cli_workspace_diag_e2e.py | 21 ++ 9 files changed, 393 insertions(+), 59 deletions(-) diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 4bec30f5..8a01f5c7 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -4845,13 +4845,17 @@ def _resolve_includes_for_layers(layers: List, project_root: Path) -> Tuple[List # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-discover-flag -def _run_discover_flag(args: Any, project_root: Path, studio_root: Path) -> None: +def _run_discover_flag(args: Any, project_root: Path, studio_root: Path) -> Optional[str]: """Run --discover: scan dirs and write new entries to manifest.toml.""" discovered = discover_components(project_root) manifest_out = studio_root / "config" / "manifest.toml" + manifest_existed = manifest_out.exists() if not args.dry_run: write_discovered_manifest(discovered, manifest_out) sys.stderr.write(f"INFO: wrote discovered manifest to {manifest_out}\n") + if not manifest_existed and manifest_out.is_file(): + return _safe_relpath(manifest_out, project_root) + return None # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-discover-flag @@ -4880,23 +4884,23 @@ def _confirm_v2_generation( preview_create: int, preview_update: int, preview_delete: int = 0, -) -> bool: - """Return True if generation should proceed, False if user aborted. +) -> str: + """Return the next generate-agents action after preview confirmation. Handles: dry_run short-circuit, no-changes case, JSON-mode bypass, --yes flag, and interactive prompt. """ if args.dry_run: - return False + return "SKIP" if not preview_create and not preview_update and not preview_delete: ui.info("No changes needed — agent files are up to date.") - return False + return "NO_CHANGES" from ..utils.ui import is_json_mode if not is_json_mode(): auto_approve = getattr(args, "yes", False) if not auto_approve: if not sys.stdin.isatty(): - return True # non-interactive: proceed + return "PROCEED" # non-interactive: proceed sys.stdout.write( f"Will create {preview_create} file(s), update {preview_update} file(s), " f"delete {preview_delete} file(s).\n" @@ -4908,9 +4912,8 @@ def _confirm_v2_generation( sys.stdout.flush() answer = sys.stdin.readline().strip().lower() if answer and answer not in ("y", "yes"): - ui.info("Aborted.") - return False - return True + return "ABORTED" + return "PROCEED" def _run_v2_pipeline( @@ -5027,8 +5030,9 @@ def cmd_generate_agents(argv: List[str]) -> int: # Step 4: Handle --discover flag # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step4-discover + discover_created_path = None if getattr(args, "discover", False): - _run_discover_flag(args, project_root, studio_root) + discover_created_path = _run_discover_flag(args, project_root, studio_root) discovered_layers = _discover_layers(project_root, studio_root) layers = [layer for layer in discovered_layers if layer.scope == "kit"] resolved_layers, has_v2_errors = _resolve_includes_for_layers(layers, project_root) @@ -5076,7 +5080,12 @@ def cmd_generate_agents(argv: List[str]) -> int: preview_v2_create = 0 preview_v2_update = 0 preview_v2_delete = 0 - preview_gitignore_action = _refresh_managed_gitignore(project_root, studio_root, dry_run=True) + preview_gitignore_action = _refresh_managed_gitignore( + project_root, + studio_root, + dry_run=True, + extra_paths=[discover_created_path] if discover_created_path else None, + ) preview_agents: Dict[str, Dict[str, Any]] = {} preview_skills: Dict[str, Dict[str, Any]] = {} legacy_preview: Dict[str, Any] = {} @@ -5148,7 +5157,10 @@ def cmd_generate_agents(argv: List[str]) -> int: # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step8-dry-run-report # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step8-confirm-execute - if not _confirm_v2_generation(args, preview_v2_create, preview_v2_update, preview_v2_delete): + confirm_action = _confirm_v2_generation(args, preview_v2_create, preview_v2_update, preview_v2_delete) + if confirm_action == "ABORTED": + return _emit_generate_agents_aborted() + if confirm_action != "PROCEED": return 0 results, has_errors = _run_v2_pipeline( @@ -5164,7 +5176,12 @@ def cmd_generate_agents(argv: List[str]) -> int: trusted_roots=_trusted_roots, remove_cypilot=remove_cypilot, ) - gitignore_action = _refresh_managed_gitignore(project_root, studio_root, dry_run=False) + gitignore_action = _refresh_managed_gitignore( + project_root, + studio_root, + dry_run=False, + extra_paths=[discover_created_path] if discover_created_path else None, + ) agents_result = _build_result( results, agents_to_process, @@ -5197,12 +5214,9 @@ def cmd_generate_agents(argv: List[str]) -> int: return 0 # Handle --discover flag in legacy mode + discover_created_path = None if getattr(args, "discover", False): - discovered = discover_components(project_root) - manifest_out = studio_root / "config" / "manifest.toml" - if not args.dry_run: - write_discovered_manifest(discovered, manifest_out) - sys.stderr.write(f"INFO: wrote discovered manifest to {manifest_out}\n") + discover_created_path = _run_discover_flag(args, project_root, studio_root) # Step 1: Dry run to preview changes # @cpt-begin:cpt-studio-flow-agent-integration-generate:p1:inst-for-each-agent @@ -5223,7 +5237,12 @@ def cmd_generate_agents(argv: List[str]) -> int: total_create = 0 total_update = 0 total_delete = 0 - preview_gitignore_action = _refresh_managed_gitignore(project_root, studio_root, dry_run=True) + preview_gitignore_action = _refresh_managed_gitignore( + project_root, + studio_root, + dry_run=True, + extra_paths=[discover_created_path] if discover_created_path else None, + ) for r in preview_results.values(): wf = r.get("workflows", {}) sk = r.get("skills", {}) @@ -5339,11 +5358,7 @@ def cmd_generate_agents(argv: List[str]) -> int: except (EOFError, KeyboardInterrupt): answer = "n" if answer and answer not in ("y", "yes"): - ui.result( - {"status": "ABORTED", "message": "Cancelled by user"}, - human_fn=lambda d: (ui.warn("Aborted."), ui.blank()), - ) - return 1 + return _emit_generate_agents_aborted() # Step 3: Execute the actual write # @cpt-begin:cpt-studio-flow-agent-integration-generate:p1:inst-for-each-agent @@ -5365,7 +5380,12 @@ def cmd_generate_agents(argv: List[str]) -> int: # @cpt-end:cpt-studio-flow-agent-integration-generate:p1:inst-for-each-agent # @cpt-begin:cpt-studio-flow-agent-integration-generate:p1:inst-return-report - gitignore_action = _refresh_managed_gitignore(project_root, studio_root, dry_run=False) + gitignore_action = _refresh_managed_gitignore( + project_root, + studio_root, + dry_run=False, + extra_paths=[discover_created_path] if discover_created_path else None, + ) agents_result = _build_result( results, agents_to_process, @@ -5508,24 +5528,146 @@ def _build_result( # @cpt-end:cpt-studio-algo-agent-integration-generate-shims:p1:inst-format-output +def _write_expected_gitignore_block( + project_root: Path, + expected_block: str, + *, + dry_run: bool, +) -> str: + """Write or update the managed Constructor Studio .gitignore block.""" + from .init import GITIGNORE_MARKER_END, GITIGNORE_MARKER_START + + gitignore_path = project_root / ".gitignore" + if not gitignore_path.is_file(): + if not dry_run: + gitignore_path.write_text(expected_block + "\n", encoding="utf-8") + return "created" + + content = gitignore_path.read_text(encoding="utf-8") + has_start = GITIGNORE_MARKER_START in content + has_end = GITIGNORE_MARKER_END in content + if has_start != has_end: + raise ValueError(".gitignore contains a malformed Constructor Studio managed block") + if has_start and has_end: + start_idx = content.index(GITIGNORE_MARKER_START) + end_idx = content.index(GITIGNORE_MARKER_END) + if end_idx < start_idx: + raise ValueError(".gitignore contains a malformed Constructor Studio managed block") + end_idx += len(GITIGNORE_MARKER_END) + current_block = content[start_idx:end_idx] + if current_block == expected_block: + return "unchanged" + new_content = content[:start_idx] + expected_block + content[end_idx:] + else: + prefix = content.rstrip("\n") + new_content = (prefix + "\n\n" if prefix else "") + expected_block + "\n" + + if not dry_run: + gitignore_path.write_text(new_content, encoding="utf-8") + return "updated" + + +def _build_managed_gitignore_block( + paths: List[str], +) -> Optional[str]: + """Return a managed .gitignore block for the supplied project-relative paths.""" + from .init import GITIGNORE_MARKER_END, GITIGNORE_MARKER_START + + normalized: List[str] = [] + seen: Set[str] = set() + for raw_path in paths: + if not isinstance(raw_path, str): + continue + path = raw_path.replace("\\", "/").strip("/") + if not path or path in seen: + continue + seen.add(path) + normalized.append(path) + if not normalized: + return None + return "\n".join( + [ + GITIGNORE_MARKER_START, + "# Generated Constructor Studio runtime and agent integration files.", + "# Files matched here are owned by Constructor Studio and may be overwritten.", + *normalized, + GITIGNORE_MARKER_END, + ] + ) + + +def _merge_gitignore_extra_paths( + expected_block: str, + extra_paths: List[str], +) -> str: + """Append extra managed file paths to an existing managed .gitignore block.""" + lines = expected_block.splitlines() + if not lines: + return expected_block + merged_lines = list(lines[:-1]) + seen = {line for line in merged_lines if line and not line.startswith("#")} + for raw_path in extra_paths: + path = raw_path.replace("\\", "/").strip("/") + if not path or path in seen: + continue + seen.add(path) + merged_lines.append(path) + merged_lines.append(lines[-1]) + return "\n".join(merged_lines) + + def _refresh_managed_gitignore( project_root: Path, studio_root: Path, dry_run: bool, + extra_paths: Optional[List[str]] = None, ) -> Optional[str]: """Refresh the managed Constructor Studio .gitignore block when possible.""" core_toml_path = studio_root / "config" / "core.toml" - if not core_toml_path.is_file(): + extra_entries = [ + _normalize_managed_output_path(project_root, path) + for path in (extra_paths or []) + if isinstance(path, str) and path.strip() + ] + if core_toml_path.is_file(): + from .init import _compute_gitignore_block, _read_install_tracking, _read_kit_tracking, _ignored_kit_paths, _write_gitignore_block + + if not extra_entries: + return _write_gitignore_block( + project_root, + _safe_relpath(studio_root, project_root), + core_toml_path, + _read_kit_tracking(core_toml_path, default="tracked"), + dry_run=dry_run, + ) + expected_block = _compute_gitignore_block( + project_root, + _safe_relpath(studio_root, project_root), + _ignored_kit_paths(project_root, core_toml_path, default=_read_kit_tracking(core_toml_path, default="tracked")), + runtime_tracking=_read_install_tracking(core_toml_path, "runtime_tracking", default="ignored"), + agent_tracking=_read_install_tracking(core_toml_path, "agent_tracking", default="ignored"), + ) + return _write_expected_gitignore_block( + project_root, + _merge_gitignore_extra_paths(expected_block, extra_entries), + dry_run=dry_run, + ) + + expected_block = _build_managed_gitignore_block( + list_managed_agent_output_paths(project_root, studio_root) + extra_entries + ) + if expected_block is None: return None - from .init import _read_kit_tracking, _write_gitignore_block + return _write_expected_gitignore_block(project_root, expected_block, dry_run=dry_run) - return _write_gitignore_block( - project_root, - _safe_relpath(studio_root, project_root), - core_toml_path, - _read_kit_tracking(core_toml_path, default="tracked"), - dry_run=dry_run, + +def _emit_generate_agents_aborted() -> int: + """Emit the standard user-cancel contract for generate-agents.""" + ui.result( + {"status": "ABORTED", "message": "Cancelled by user"}, + human_fn=lambda _d: (ui.warn("Aborted."), ui.blank()), ) + return 0 # --------------------------------------------------------------------------- # Human-friendly formatters diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index eb78cb75..7d95750c 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -3353,6 +3353,19 @@ def _collect_kit_update_partial_reasons(results: List[Dict[str, Any]]) -> List[D return partials +def _count_kit_update_actions( + results: List[Dict[str, Any]], + *actions: str, +) -> int: + """Count normalized kit update actions in ``results``.""" + wanted = {action.strip().lower() for action in actions} + return sum( + 1 + for result in results + if _normalize_kit_update_action(result.get("action")) in wanted + ) + + # @cpt-flow:cpt-studio-flow-kit-update-cli:p1 def cmd_kit_update(argv: List[str]) -> int: """Update installed kits from their registered sources or a local path. @@ -3616,17 +3629,10 @@ def cmd_kit_update(argv: List[str]) -> int: # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-regen-gen # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-format-output - n_updated = sum( - 1 - for r in all_results - if _normalize_kit_update_action(r.get("action")) - not in ("current", "dry_run", "aborted", "failed") - ) + n_updated = _count_kit_update_actions(all_results, "updated", "created") + n_partial = _count_kit_update_actions(all_results, "partial") command_failed = has_failed_updates - command_incomplete = any( - _normalize_kit_update_action(r.get("action")) == "partial" - for r in all_results - ) + command_incomplete = n_partial > 0 interactive_partial_success = bool(interactive and command_incomplete and not command_failed) if command_failed: status = "FAIL" @@ -3641,6 +3647,7 @@ def cmd_kit_update(argv: List[str]) -> int: output: Dict[str, Any] = { "status": status, "kits_updated": n_updated, + "kits_partially_updated": n_partial, "results": all_results, } partial_reasons = _collect_kit_update_partial_reasons(all_results) @@ -3772,7 +3779,7 @@ def cmd_kit_check_updates(argv: List[str]) -> int: if _normalize_kit_update_action(r.get("action")) == "failed" ] output: Dict[str, Any] = { - "status": "WARN" if failures else "PASS", + "status": "FAIL" if failures else "PASS", "updates_available": len(updates), "results": results, } @@ -3787,7 +3794,7 @@ def cmd_kit_check_updates(argv: List[str]) -> int: for r in failures ] ui.result(output, human_fn=_human_kit_check_updates) - return 0 + return 2 if failures else 0 # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-format-output @@ -3915,8 +3922,9 @@ def cmd_kit_normalize(argv: List[str]) -> int: and not args.output ): raise ValueError( - "Refusing to overwrite the source multi-kit manifest with only a selected subset; " - "use --output, --dry-run, or --stdout", + "Refusing to overwrite the source multi-kit manifest with only the selected subset. " + "Use --stdout to print just that subset, --dry-run to preview it without writing, " + "or --output to write it to a different file.", ) # @cpt-end:cpt-studio-flow-kit-normalize-cli:p1:inst-normalize-load-source except ValueError as exc: diff --git a/skills/studio/scripts/studio/commands/workspace_info.py b/skills/studio/scripts/studio/commands/workspace_info.py index f52fc090..da5ca143 100644 --- a/skills/studio/scripts/studio/commands/workspace_info.py +++ b/skills/studio/scripts/studio/commands/workspace_info.py @@ -12,6 +12,21 @@ from ..utils.workspace import WorkspaceConfig +def _source_warning_for_result(info: dict) -> Optional[str]: + """Return a normalized top-level warning string for a source info record.""" + warning = info.get("warning") + if not warning: + return None + return f"{info.get('name', '?')}: {warning}" + + +def _collect_workspace_warnings(sources_info: List[dict], config_warnings: List[str]) -> List[str]: + """Collect top-level workspace warnings from source and config degradation.""" + warnings = [_source_warning_for_result(source) for source in sources_info] + warnings.extend(f"config: {warning}" for warning in config_warnings) + return [warning for warning in warnings if warning] + + def _probe_source_adapter(resolved: Path, explicit_adapter: Optional[Path]) -> Optional[Path]: """Find the adapter directory for a reachable source. @@ -61,6 +76,8 @@ def _build_source_info(ws_cfg: WorkspaceConfig, name: str) -> dict: info["adapter_found"] = found_adapter is not None if found_adapter is not None: _enrich_with_artifact_counts(info, found_adapter) + elif src.adapter: + info["warning"] = f"Configured adapter not found: {src.adapter}" return info @@ -147,9 +164,15 @@ def cmd_workspace_info(argv: List[str]) -> int: }, } - config_errors = ws_cfg.validate() - if config_errors: - result["config_warnings"] = config_errors + config_warnings = ws_cfg.validate() + if config_warnings: + result["config_warnings"] = config_warnings + + warnings = _collect_workspace_warnings(sources_info, config_warnings) + result["degraded"] = bool(warnings) + result["warning_count"] = len(warnings) + if warnings: + result["warnings"] = warnings # @cpt-end:cpt-studio-flow-workspace-info:p1:inst-info-build-result @@ -214,7 +237,9 @@ def _fmt_status(data: dict) -> None: ui.blank() status = data.get("status", "") - if status == "OK": + if status == "OK" and data.get("degraded"): + ui.warn(f"Workspace is degraded ({data.get('warning_count', 0)} warnings)") + elif status == "OK": ui.success("Workspace is configured") elif status == "ERROR": ui.error(data.get("message", "")) diff --git a/tests/test_cli_artifact_tools_e2e.py b/tests/test_cli_artifact_tools_e2e.py index 4fb37190..dafd2885 100644 --- a/tests/test_cli_artifact_tools_e2e.py +++ b/tests/test_cli_artifact_tools_e2e.py @@ -192,6 +192,127 @@ def test_get_content_code_mode_paths_are_read_only(self): self.assertEqual(payload["inst"], "validate") self.assertIn("def validate():", payload["text"]) + def test_get_content_code_mode_missing_inst_falls_back_to_id_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_content_project(root) + baseline = _snapshot_tree(root) + code_path = root / "src" / "web" / "handlers.py" + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "get-content", + "--code", + str(code_path), + "--id", + "cpt-web-flow-login", + "--inst", + "missing-inst", + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "FOUND") + self.assertEqual(payload["id"], "cpt-web-flow-login") + self.assertEqual(payload["inst"], "missing-inst") + self.assertIn("def validate():", payload["text"]) + + def test_get_content_code_mode_missing_inst_and_id_returns_not_found_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_content_project(root) + baseline = _snapshot_tree(root) + code_path = root / "src" / "web" / "handlers.py" + + exit_code, stdout, stderr = _run_main( + [ + "--json", + "get-content", + "--code", + str(code_path), + "--id", + "cpt-web-flow-missing", + "--inst", + "missing-inst", + ], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 2) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual( + payload, + { + "status": "NOT_FOUND", + "id": "cpt-web-flow-missing", + "inst": "missing-inst", + }, + ) + + def test_get_content_code_mode_missing_file_is_non_mutating_error(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_content_project(root) + baseline = _snapshot_tree(root) + code_path = root / "src" / "web" / "missing.py" + + exit_code, stdout, stderr = _run_main( + ["--json", "get-content", "--code", str(code_path), "--id", "cpt-web-flow-login"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual( + payload, + { + "status": "ERROR", + "message": f"Code file not found: {code_path.resolve()}", + }, + ) + + def test_get_content_code_mode_parse_failure_is_non_mutating_error(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_content_project(root) + code_path = root / "src" / "web" / "broken.py" + code_path.write_bytes(b"\xff\xfe\x00broken") + baseline = _snapshot_tree(root) + + exit_code, stdout, stderr = _run_main( + ["--json", "get-content", "--code", str(code_path), "--id", "cpt-web-flow-login"], + cwd=root, + ) + after = _snapshot_tree(root) + + self.assertEqual(exit_code, 1) + self.assertEqual(stderr, "") + self.assertEqual(after, baseline) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "ERROR") + self.assertIn("Failed to parse code file:", payload["message"]) + self.assertIn("Failed to read", payload["message"]) + self.assertIn(str(code_path.resolve()), payload["message"]) + + def test_get_content_code_mode_missing_id_returns_not_found_without_writes(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_content_project(root) + baseline = _snapshot_tree(root) + code_path = root / "src" / "web" / "handlers.py" + exit_code, stdout, stderr = _run_main( ["--json", "get-content", "--code", str(code_path), "--id", "cpt-web-flow-missing"], cwd=root, @@ -201,8 +322,14 @@ def test_get_content_code_mode_paths_are_read_only(self): self.assertEqual(stderr, "") self.assertEqual(after, baseline) payload = json.loads(stdout) - self.assertEqual(payload["status"], "NOT_FOUND") - self.assertEqual(payload["id"], "cpt-web-flow-missing") + self.assertEqual( + payload, + { + "status": "NOT_FOUND", + "id": "cpt-web-flow-missing", + "inst": None, + }, + ) def test_get_content_missing_selector_is_non_mutating_error(self): with TemporaryDirectory() as tmpdir: diff --git a/tests/test_cli_contracts_e2e.py b/tests/test_cli_contracts_e2e.py index 05af1091..40faa957 100644 --- a/tests/test_cli_contracts_e2e.py +++ b/tests/test_cli_contracts_e2e.py @@ -356,7 +356,8 @@ def test_kit_update_partial_json_contract_reports_top_level_partial_reasons(self self.assertEqual(rc, 2) self.assertEqual(out["status"], "WARN") - self.assertEqual(out["kits_updated"], 1) + self.assertEqual(out["kits_updated"], 0) + self.assertEqual(out["kits_partially_updated"], 1) self.assertEqual(out["results"][0]["kit"], "example-v2") self.assertEqual(out["results"][0]["action"], "partial") self.assertEqual(out["results"][0]["declined"], ["artifacts/PRD/template.md"]) @@ -373,7 +374,7 @@ def test_kit_update_partial_json_contract_reports_top_level_partial_reasons(self ], ) - def test_kit_check_updates_warn_contract_keeps_exit_zero_for_mixed_degraded_results(self): + def test_kit_check_updates_fail_contract_uses_nonzero_exit_for_mixed_degraded_results(self): with TemporaryDirectory() as td: temp_root = Path(td) cache = _make_cache(temp_root) @@ -411,9 +412,9 @@ def test_kit_check_updates_warn_contract_keeps_exit_zero_for_mixed_degraded_resu cwd=project_root, ) - self.assertEqual(rc, 0) + self.assertEqual(rc, 2) self.assertEqual(stderr, "") - self.assertEqual(out["status"], "WARN") + self.assertEqual(out["status"], "FAIL") self.assertEqual(out["updates_available"], 1) self.assertEqual(out["message"], "Kit updates available") self.assertEqual(out["commands"], ["cfs kit update beta"]) diff --git a/tests/test_cli_example_kits_e2e.py b/tests/test_cli_example_kits_e2e.py index e6475f55..104a4ccb 100644 --- a/tests/test_cli_example_kits_e2e.py +++ b/tests/test_cli_example_kits_e2e.py @@ -647,6 +647,9 @@ def test_kit_normalize_multi_kit_subset_write_refusal_is_e2e_visible(self): self.assertEqual(rc, 2, stderr) self.assertEqual(out["status"], "FAIL") self.assertIn("Refusing to overwrite the source multi-kit manifest", out["message"]) + self.assertIn("--stdout", out["message"]) + self.assertIn("--dry-run", out["message"]) + self.assertIn("--output ", out["message"]) self.assertEqual((multi / ".cf-studio-kit.toml").read_text(encoding="utf-8"), before) def test_kit_normalize_multi_kit_writes_full_manifest_when_all_selected(self): @@ -2627,6 +2630,8 @@ def test_example_v2_generic_git_subdir_install_and_update_preserves_subdirectory self.assertEqual(rc, 2, stderr) self.assertEqual(out["status"], "WARN") result = out["results"][0] + self.assertEqual(out["kits_updated"], 0) + self.assertEqual(out["kits_partially_updated"], 1) self.assertEqual(result["action"], "partial") self.assertEqual(result["accepted"], ["agents/planner-helper.md", "agents/reviewer-helper.md"]) self.assertEqual(result["declined"], ["artifacts/FEATURE/example.md"]) diff --git a/tests/test_cli_human_mode_e2e.py b/tests/test_cli_human_mode_e2e.py index b2281d60..65d4b707 100644 --- a/tests/test_cli_human_mode_e2e.py +++ b/tests/test_cli_human_mode_e2e.py @@ -158,7 +158,7 @@ def test_generate_agents_human_preview_abort_preserves_tree(self): after = _snapshot_tree(root) combined = stdout + stderr - self.assertEqual(rc, 1) + self.assertEqual(rc, 0) self.assertEqual(after, before) self.assertIn("Generate Agent Integration", combined) self.assertIn("Reply with `y` to write these generated files or `n` to abort.", combined) diff --git a/tests/test_cli_setup_e2e.py b/tests/test_cli_setup_e2e.py index ae7918a7..a7507601 100644 --- a/tests/test_cli_setup_e2e.py +++ b/tests/test_cli_setup_e2e.py @@ -674,7 +674,11 @@ def test_generate_agents_discover_writes_manifest_via_main(self): f'source = "{(root / ".claude" / "agents" / "local-reviewer.md").resolve().as_posix()}"', manifest_text, ) - self.assertFalse(gitignore_path.exists()) + self.assertTrue(gitignore_path.exists()) + gitignore_text = gitignore_path.read_text(encoding="utf-8") + self.assertIn("config/manifest.toml", gitignore_text) + self.assertIn(".claude/skills/cf/SKILL.md", gitignore_text) + self.assertIn(".claude/skills/cypilot-generate/SKILL.md", gitignore_text) after = _snapshot_tree(root) added_paths = sorted(set(after) - set(before)) removed_paths = sorted(set(before) - set(after)) @@ -700,6 +704,7 @@ def test_generate_agents_discover_writes_manifest_via_main(self): ".claude/skills/cypilot-analyze/SKILL.md", ".claude/skills/cypilot-generate", ".claude/skills/cypilot-generate/SKILL.md", + ".gitignore", "config/manifest.toml", ] ) diff --git a/tests/test_cli_workspace_diag_e2e.py b/tests/test_cli_workspace_diag_e2e.py index e7a12e11..c62f479d 100644 --- a/tests/test_cli_workspace_diag_e2e.py +++ b/tests/test_cli_workspace_diag_e2e.py @@ -325,6 +325,9 @@ def test_workspace_init_add_info_round_trip_with_bounded_writes(self): info_payload = json.loads(stdout) self.assertEqual(info_payload["status"], "OK") + self.assertFalse(info_payload["degraded"]) + self.assertEqual(info_payload["warning_count"], 0) + self.assertNotIn("warnings", info_payload) self.assertEqual(info_payload["sources_count"], 2) self.assertFalse(info_payload["is_inline"]) self.assertFalse(info_payload["context_loaded"]) @@ -561,6 +564,12 @@ def test_workspace_info_git_source_not_cloned_reports_warning_without_network(se self.assertEqual(after, before) payload = json.loads(stdout) self.assertEqual(payload["status"], "OK") + self.assertTrue(payload["degraded"]) + self.assertEqual(payload["warning_count"], 1) + self.assertEqual( + payload["warnings"], + ["remote-docs: Source not cloned — run 'workspace-sync' to fetch: https://gitlab.com/acme/docs.git"], + ) source = payload["sources"][0] self.assertFalse(source["reachable"]) self.assertIn("Source not cloned", source["warning"]) @@ -593,15 +602,24 @@ def test_workspace_info_invalid_adapter_reports_adapter_found_false(self): self.assertEqual(stderr, "") self.assertEqual(after, before) payload = json.loads(stdout) + self.assertEqual(payload["status"], "OK") + self.assertTrue(payload["degraded"]) + self.assertEqual(payload["warning_count"], 1) source = payload["sources"][0] self.assertTrue(source["reachable"]) self.assertFalse(source["adapter_found"]) + self.assertEqual(source["warning"], "Configured adapter not found: missing-adapter") + self.assertEqual( + payload["warnings"], + ["docs-repo: Configured adapter not found: missing-adapter"], + ) def test_workspace_info_config_warning_is_exposed(self): with TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "workspace-root" _make_repo(root) _write_core_config(root, '[project]\nname = "workspace-root"\n') + _make_adapter_repo(root / "docs-repo", role_dir="architecture") before = _snapshot_tree(root) with patch("studio.utils.workspace.find_workspace_config") as mock_find: @@ -627,7 +645,10 @@ def test_workspace_info_config_warning_is_exposed(self): self.assertEqual(after, before) payload = json.loads(stdout) self.assertEqual(payload["status"], "OK") + self.assertTrue(payload["degraded"]) + self.assertEqual(payload["warning_count"], 1) self.assertEqual(payload["config_warnings"], ["synthetic warning from test"]) + self.assertEqual(payload["warnings"], ["config: synthetic warning from test"]) def test_workspace_info_no_workspace_error_is_read_only(self): with TemporaryDirectory() as tmpdir: From 405bf141c1a00035f279a8e7477312d9d307b15a Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 01:46:25 +0300 Subject: [PATCH 07/14] Refactor CLI command handling and improve workspace source validation - Updated the CLI command routing in `cli.py` to enhance command parsing and execution flow. - Simplified context management by removing unnecessary variables in `agents.py`. - Improved error handling for workspace source paths in `workspace_add.py`, allowing unreachable paths to be added with appropriate status updates. - Enhanced JSON output formatting for various commands, ensuring consistent user feedback. - Adjusted tests to reflect changes in command behavior, including accepting unreachable paths and updating expected return codes. - Added new comments for better code clarity and maintainability. Signed-off-by: ainetx --- architecture/DESIGN.md | 12 +- architecture/features/agent-integration.md | 2 +- architecture/features/core-infra.md | 12 +- .../researches/contracts-doc-gaps-2026-06.md | 75 +++ architecture/specs/artifacts-registry.md | 2 +- architecture/specs/cli.md | 87 ++- architecture/specs/kit/kit.md | 43 +- architecture/specs/sysprompts.md | 495 ++++++------------ guides/AGENT-TOOLS.md | 2 + guides/CONFIGURATION.md | 2 +- guides/USAGE-GUIDE.md | 6 +- skills/studio/scripts/studio/cli.py | 22 +- .../scripts/studio/commands/__init__.py | 5 + .../studio/scripts/studio/commands/agents.py | 37 +- skills/studio/scripts/studio/commands/kit.py | 18 + .../scripts/studio/commands/workspace_add.py | 21 - skills/studio/scripts/studio/utils/context.py | 10 +- skills/studio/studio.clispec | 39 +- src/studio_proxy/cli.py | 7 + tests/test_agents_coverage.py | 5 +- tests/test_cli_workspace_diag_e2e.py | 23 +- tests/test_generate_manifest_agents.py | 10 +- tests/test_kit.py | 4 +- tests/test_workspace.py | 13 +- 24 files changed, 481 insertions(+), 471 deletions(-) create mode 100644 architecture/researches/contracts-doc-gaps-2026-06.md diff --git a/architecture/DESIGN.md b/architecture/DESIGN.md index 45deabbb..fbd30fe5 100644 --- a/architecture/DESIGN.md +++ b/architecture/DESIGN.md @@ -460,7 +460,7 @@ Validation rules cannot be bypassed or weakened in STRICT mode. The deterministi - **Identifiers & Traceability**: [traceability.md](./specs/traceability.md) — ID formats, naming conventions, task markers, code traceability markers, validation - **CDSL**: [CDSL.md](./specs/CDSL.md) — behavioral specification language syntax - **Artifacts registry**: [artifacts-registry.md](./specs/artifacts-registry.md) — artifacts.toml structure and agent operations -- **System prompts**: [sysprompts.md](./specs/sysprompts.md) — `{cf-studio-path}/config/sysprompts/` and `config/AGENTS.md` format +- **Project rules and root navigation**: [sysprompts.md](./specs/sysprompts.md) — `{cf-studio-path}/config/rules/`, `config/AGENTS.md`, and root managed block format - **Workspace**: core architecture in [DESIGN.md §1.2](#multi-repo-workspace-federation); implementation details in [features/workspace.md](./features/workspace.md) — workspace config, source resolution, algorithms (`resolve-git-url`, `resolve-adapter-context`, `determine-target`, `infer-role`) **Core Entities**: @@ -986,9 +986,9 @@ sequenceDiagram end ``` -**Description**: User initializes Studio in a project. The skill engine asks for install directory, agent selection, and kit tracking policy. It defines a **root system** (name and slug derived from the project directory name), creates core configs (`core.toml` with root system, pinned Studio metadata, and kit tracking policy; `artifacts.toml` with default autodetect rules), writes the managed `.gitignore` block, generates agent entry points, and sets up `{cf-studio-path}/config/AGENTS.md` with default WHEN rules. After core setup, the tool prompts `Install SDLC kit? [a]ccept [d]ecline`. If accepted, the kit is downloaded from GitHub. If the kit source contains canonical `.cf-studio-kit.toml` or legacy `manifest.toml`, the Kit Manager normalizes and validates the model, reads declared resources, prompts the user for destination paths or copy/register mode when required, copies or registers each resource, preserves template variables in kit files, and records effective install state in `core.toml` for runtime resolution. If no manifest-style input is present, files are copied to the default kit config directory. If declined, the user can install kits later via `cfs kit install`. Repeat `cfs init` repairs generated surfaces using the pinned metadata and does not upgrade implicitly. +**Description**: User initializes Studio in a project. The skill engine asks for install directory, agent selection, and kit tracking policy. It defines a **root system** (name and slug derived from the project directory name), creates core configs (`core.toml` with root system, pinned Studio metadata, and kit tracking policy; `artifacts.toml` with default autodetect rules), writes the managed `.gitignore` block, generates agent entry points, and sets up `{cf-studio-path}/config/AGENTS.md` with project navigation rules that point at `{cf-studio-path}/config/rules/*.md` and selected project docs. After core setup, the tool may offer default kit installation. If a kit is accepted, it is downloaded or copied from the selected source. If the kit source contains canonical `.cf-studio-kit.toml` or legacy `manifest.toml`, the Kit Manager normalizes and validates the model, reads declared resources, prompts the user for destination paths or copy/register mode when required, copies or registers each resource, preserves template variables in kit files, and records effective install state in `core.toml` for runtime resolution. If no manifest-style input is present, files are copied to the default kit config directory. Repeat `cfs init` repairs generated surfaces using the pinned metadata and does not upgrade implicitly. -**Root AGENTS.md / CLAUDE.md injection**: During initialization (and verified on every CLI invocation), the engine ensures the project root `AGENTS.md` and `CLAUDE.md` files contain the same managed block with only the configured adapter path: +**Root AGENTS.md / CLAUDE.md injection**: During initialization and other explicit setup or repair flows, the engine ensures the project root `AGENTS.md` and `CLAUDE.md` files contain the same managed block with only the configured adapter path: ````markdown @@ -998,9 +998,9 @@ cf-studio-path = ".bootstrap" ```` -The block is inserted at the **beginning** of each file. If a file does not exist, it is created. The managed content is a TOML fence that declares only `cf-studio-path`, and the path reflects the actual install directory. Content between the `` and `` comment markers is fully managed by Studio — it is overwritten on every check, so manual edits inside the block are discarded. +The block is inserted at the **beginning** of each file. If a file does not exist, it is created. The managed content is a TOML fence that declares only `cf-studio-path`, and the path reflects the actual install directory. Content between the `` and `` comment markers is fully managed by Studio, so manual edits inside the block are discarded when a setup or repair flow rewrites it. -**Integrity invariant**: every Studio CLI command (not just `init`) verifies the root `AGENTS.md` and `CLAUDE.md` blocks exist and are correct before proceeding. If a block is missing or the path is stale (e.g., install directory changed), the engine silently re-injects it. This guarantees that root agent files always expose the current `cf-studio-path` without duplicating navigation rules. +**Integrity invariant**: project-skill routing depends on the root managed block, but ordinary read-only commands do not silently rewrite root files. Repair of missing or stale root blocks belongs to explicit setup flows such as `init`, repeat `init` repair mode, `update`, and legacy migration. #### Artifact Validation @@ -1459,5 +1459,5 @@ The following design domains do not require dedicated architecture sections. Eac | Identifiers & Traceability | [specs/traceability.md](./specs/traceability.md) | `cpt-studio-fr-core-traceability`, `cpt-studio-component-traceability-engine` | | CDSL | [specs/CDSL.md](./specs/CDSL.md) | `cpt-studio-fr-core-cdsl` | | Artifacts Registry | [specs/artifacts-registry.md](./specs/artifacts-registry.md) | `cpt-studio-fr-core-config`, `cpt-studio-component-config-manager` | -| System Prompts | [specs/sysprompts.md](./specs/sysprompts.md) | `cpt-studio-fr-core-config`, `cpt-studio-fr-core-workflows` | +| Project Rules and Root Navigation | [specs/sysprompts.md](./specs/sysprompts.md) | `cpt-studio-fr-core-config`, `cpt-studio-fr-core-workflows`, `cpt-studio-fr-core-init` | | Workspace (inline) | [DESIGN.md §1.2 Multi-Repo Workspace Federation](#multi-repo-workspace-federation) | `cpt-studio-fr-core-workspace`, `cpt-studio-fr-core-workspace-git-sources`, `cpt-studio-fr-core-workspace-cross-repo-editing`; algorithms: `cpt-studio-algo-workspace-resolve-git-url`, `cpt-studio-algo-workspace-resolve-adapter-context`, `cpt-studio-algo-workspace-determine-target`, `cpt-studio-algo-workspace-infer-role` | diff --git a/architecture/features/agent-integration.md b/architecture/features/agent-integration.md index f2fdcd47..b0933edc 100644 --- a/architecture/features/agent-integration.md +++ b/architecture/features/agent-integration.md @@ -145,7 +145,7 @@ Without this feature, users would need to manually create and maintain agent-spe - [x] `p1` - **ID**: `cpt-studio-algo-agent-integration-discover-agents` -1. [x] - `p1` - Define agent registry: windsurf, cursor, claude, copilot, openai. Detection uses Constructor Studio-specific generated files per agent (e.g. `.claude/skills/cf/SKILL.md`, `.windsurf/workflows/cf.md`, `.cursor/commands/cf.md`, `.github/.constructor-studio-installed` or legacy Studio-managed `copilot-instructions.md` for Copilot, `.codex/.constructor-studio-installed` or `.codex/agents/` with content or legacy `.agents/skills/cf/SKILL.md` for OpenAI) — not generic tool directories. The shared OpenAI fallback is valid only when no other agent's primary or legacy Studio marker is present. User-authored files are never overwritten, and legacy manifest skill files are removed only when they are provably generated copies or pure generated stubs. - `inst-define-registry` +1. [x] - `p1` - Define agent registry: windsurf, cursor, claude, copilot, openai. Detection uses Constructor Studio-specific generated files per agent (e.g. `.claude/skills/cf/SKILL.md`, `.windsurf/workflows/cf.md`, `.cursor/commands/cf.md`, `.github/.constructor-studio-installed` or legacy Studio-managed `copilot-instructions.md` for Copilot, `.codex/.cf-installed` or `.codex/agents/` with content or legacy `.agents/skills/cf/SKILL.md` for OpenAI) — not generic tool directories. The shared OpenAI fallback is valid only when no other agent's primary or legacy Studio marker is present. User-authored files are never overwritten, and legacy manifest skill files are removed only when they are provably generated copies or pure generated stubs. - `inst-define-registry` 2. - `p1` - **IF** `--agent` flag provided, filter to single agent - `inst-if-filter` 3. - `p1` - **RETURN** list of agents to generate for - `inst-return-agents` 4. [x] - `p1` - Resolve config/kits/ directory and registered kit dirs from core.toml for workflow/skill discovery - `inst-resolve-kits` diff --git a/architecture/features/core-infra.md b/architecture/features/core-infra.md index 0f3e7b4c..b7f4d4b9 100644 --- a/architecture/features/core-infra.md +++ b/architecture/features/core-infra.md @@ -328,7 +328,7 @@ Enables users to install Studio globally, initialize it in any project with sens 3. [x] - `p1` - **IF** handler not found - `inst-if-no-handler` 1. [x] - `p1` - **RETURN** error JSON: `{error: "Unknown command"}` (exit 1) - `inst-return-unknown` 4. [x] - `p1` - Parse remaining arguments per handler's argument spec - `inst-parse-args` -5. [x] - `p1` - Verify root AGENTS.md integrity (re-inject if missing/stale) - `inst-verify-agents` +5. [x] - `p1` - Read root AGENTS.md managed metadata for routing without mutating root files during ordinary command dispatch - `inst-read-root-agents` 6. [x] - `p1` - Execute handler with parsed arguments - `inst-execute-handler` 7. [x] - `p1` - Serialize handler result to JSON on stdout - `inst-serialize-json` 8. [x] - `p1` - **RETURN** exit code from handler (0=PASS, 1=error, 2=FAIL) - `inst-return-code` @@ -384,7 +384,7 @@ Enables users to install Studio globally, initialize it in any project with sens 2. [x] - `p1` - Add concrete `config/kits//` ignore entries only for kits whose `tracking = ignored`; keep other kits tracked - `inst-ignore-kits-by-policy` 3. [ ] - `p1` - Add only generator-owned agent paths and patterns, never broad parent directories such as `.github/`, `.claude/`, `.cursor/`, `.codex/`, or `.agents/` - `inst-ignore-agent-generated-only` 4. [ ] - `p1` - Include safe shared skill patterns: `.agents/skills/cf/`, `.agents/skills/cf-*/`, and legacy Studio-owned `studio-*`, `cypilot-*`, `cf-constructor-*` skill dirs - `inst-ignore-shared-skills` -5. [ ] - `p1` - Include safe Codex patterns: `.codex/agents/cf*.toml`, legacy Studio-owned Codex TOML names, and `.codex/.constructor-studio-installed` - `inst-ignore-codex-generated` +5. [ ] - `p1` - Include safe Codex patterns: `.codex/agents/cf*.toml`, legacy Studio-owned Codex TOML names, and `.codex/.cf-installed` - `inst-ignore-codex-generated` 6. [ ] - `p1` - Include safe Claude/Cursor/Copilot/Windsurf generated names with `cf*` patterns and install markers, plus legacy Studio-owned `studio-*`, `cypilot-*`, and `cf-constructor-*` names where generated by prior versions - `inst-ignore-tool-generated` 7. [x] - `p1` - Write a warning comment that ignored entries are generated surfaces and may be regenerated or overwritten by `cfs init`, `cfs update`, or `cfs generate-agents`; include concrete ignored kit paths only when any kit has `tracking = ignored` - `inst-write-overwrite-warning` 8. [ ] - `p1` - Replace the managed `.gitignore` block only when start/end markers are intact; on malformed, partial, or nested markers, return warning/error without overwriting user content - `inst-gitignore-marker-safety` @@ -733,7 +733,11 @@ The command **MUST** also write the managed `.gitignore` footprint block. `.core - [x] `p1` - **ID**: `cpt-studio-dod-core-infra-agents-integrity` -The system **MUST** verify the root `AGENTS.md` managed block on every CLI invocation (not just init). If the `` block is missing, stale, or the file does not exist, the system silently re-injects it with the correct block pointing to the `{cf-studio-path}/` directory. +The system **MUST** treat the root `AGENTS.md` / `CLAUDE.md` managed block as +setup metadata, not as a file that ordinary read-only commands silently repair. +Project-skill routing reads the block on ordinary invocations. Explicit +setup/repair flows such as `init`, repeat `init` repair mode, `update`, and +legacy migration own block creation or refresh when it is missing or stale. **Implements**: - `cpt-studio-algo-core-infra-inject-root-agents` @@ -876,6 +880,6 @@ See ADR-0020 (`architecture/ADR/0020-cpt-studio-adr-rebrand-and-mirror-override- - [x] `cfs update [VERSION|BRANCH]` downloads specified version/branch/SHA into cache - [x] Download failure produces actionable error message with HTTP status - [x] All commands output JSON to stdout and use exit codes 0/1/2 -- [x] Root `AGENTS.md` managed block is verified and re-injected on every CLI invocation +- [x] Root `AGENTS.md` managed block is read during ordinary CLI routing and refreshed only by explicit setup or repair flows - [x] Background version check does not block command execution - [x] `{cf-studio-path}/config/AGENTS.md` is created with default WHEN rules for artifacts registry diff --git a/architecture/researches/contracts-doc-gaps-2026-06.md b/architecture/researches/contracts-doc-gaps-2026-06.md new file mode 100644 index 00000000..b670fd0b --- /dev/null +++ b/architecture/researches/contracts-doc-gaps-2026-06.md @@ -0,0 +1,75 @@ +# Contract/Doc Gaps Review — 2026-06 + + + +- [Overview](#overview) +- [Gap 1: Root Runtime Metadata Lifecycle Spec](#gap-1-root-runtime-metadata-lifecycle-spec) +- [Gap 2: Generated Agent Output Contract Spec](#gap-2-generated-agent-output-contract-spec) +- [Gap 3: Human vs JSON Output Contract Spec](#gap-3-human-vs-json-output-contract-spec) + + + +## Overview + +This note captures architecture-level gaps that are materially exercised by +contracts and `_e2e` tests but are still scattered across multiple docs instead +of being specified once as authoritative behavior. + +## Gap 1: Root Runtime Metadata Lifecycle Spec + +Why it matters: + +- `_e2e` tests enforce that commands such as `info`, `agents`, and + `validate-toc` are read-only. +- setup and migration flows explicitly rewrite root `AGENTS.md` and `CLAUDE.md` + managed blocks. +- legacy migration and repair semantics already include dirty-file and backup + behavior. + +What is missing: + +- one dedicated spec for root managed block format, lifecycle, rewrite triggers, + dirty-file safety, migration from legacy markers, and read-only invariants + +Recommended home: + +- new spec under `architecture/specs/root-runtime-metadata.md` + +## Gap 2: Generated Agent Output Contract Spec + +Why it matters: + +- `generate-agents` and `agents` have a stable public contract across multiple + providers +- canonical kit manifests now include `public`, `generated_targets`, and nested + `subagents` +- `_e2e` covers partial-success reporting, provider-specific skip reasons, + generated path ownership, legacy cleanup, and `.gitignore` integration + +What is missing: + +- one authoritative spec for generated output locations, ownership model, + detection markers, provider capability matrix, partial-result schema, and + cleanup rules + +Recommended home: + +- new spec under `architecture/specs/generated-agent-outputs.md` + +## Gap 3: Human vs JSON Output Contract Spec + +Why it matters: + +- `_e2e` distinguishes human-mode summaries from machine JSON contracts +- statuses such as `OK`, `FOUND`, `NOT_FOUND`, `PASS`, `WARN`, `FAIL`, + `PARTIAL`, and `CONFIG_ERROR` are externally observable +- several commands guarantee read-only behavior in both modes + +What is missing: + +- one normalized status taxonomy and output-shape spec across JSON mode and + human mode, including when partial success is fatal vs non-fatal + +Recommended home: + +- new spec under `architecture/specs/output-contracts.md` diff --git a/architecture/specs/artifacts-registry.md b/architecture/specs/artifacts-registry.md index 03b8438b..dbf06c81 100644 --- a/architecture/specs/artifacts-registry.md +++ b/architecture/specs/artifacts-registry.md @@ -1014,7 +1014,7 @@ extensions = [".ts"] **CLI**: `skills/studio/studio.clispec` **Related**: -- `sysprompts.md` - Project system prompts (`{cf-studio-path}/config/sysprompts/` + `config/AGENTS.md`) +- `sysprompts.md` - Project rules and root navigation (`{cf-studio-path}/config/rules/` + `config/AGENTS.md`) --- diff --git a/architecture/specs/cli.md b/architecture/specs/cli.md index 69c2e16d..f973e3d4 100644 --- a/architecture/specs/cli.md +++ b/architecture/specs/cli.md @@ -194,20 +194,20 @@ cfs init [--project-root ROOT] [--install-dir DIR] [--from-dir DIR] [--yes] [--d - Autodetect rules for standard artifact kinds: `PRD.md`, `DESIGN.md`, `ADR/*.md`, `DECOMPOSITION.md`, `features/*.md` — all with default traceability levels and glob patterns - Default codebase entry: `path = "src"`, common extensions - Default ignore patterns: `vendor/*`, `node_modules/*`, `.git/*` -7. Install all available kits by copying kit files into `{cf-studio-path}/config/kits//` (constraints, artifacts, workflows, SKILL.md) and registering in `core.toml`. +7. Offer default kit installation according to the current setup flow. The command may install a default kit, skip kit installation, or leave kit installation for a later explicit `cfs kit install`. 8. Generate agent entry points for selected agents. 9. Inject root `AGENTS.md` entry: insert managed `` block at the beginning of `{project_root}/AGENTS.md` (create file if absent). -10. Create `{cf-studio-path}/config/AGENTS.md` with default WHEN rules for standard system prompts. +10. Create `{cf-studio-path}/config/AGENTS.md` with project navigation rules that point at `{cf-studio-path}/config/rules/*.md` and other relevant project docs. 11. Output prompt suggestion: `cf on` or `cf help` (these are agent chat prompts, not CLI commands). -**Root AGENTS.md integrity**: every CLI invocation (not just `init`) verifies the `` block in root `AGENTS.md` exists and contains the correct path. If missing or stale, the block is silently re-injected. See [sysprompts.md](./sysprompts.md) for full format. +**Root AGENTS.md integrity**: project-skill routing reads the managed `` block in root `AGENTS.md` and uses its `cf-studio-path` value to locate the project install. Commands that explicitly repair or rewrite project runtime metadata (`init`, `update`, migration flows) refresh this block. Ordinary CLI dispatch does not silently rewrite root files. See [sysprompts.md](./sysprompts.md) for full format. **Output** (JSON): ```json { - "status": "ok", + "status": "PASS", "install_dir": ".cf-studio", - "kits_installed": ["sdlc"], + "kits_installed": [], "agents_configured": ["windsurf", "cursor", "claude", "copilot", "openai"], "systems": [{"name": "my-project", "slug": "my-project", "kit": "sdlc"}] } @@ -235,12 +235,12 @@ cfs update [--project-root P] [--dry-run] [--no-interactive] [-y/--yes] **Behavior**: 1. Resolve project root and Constructor Studio directory. 2. Replace `.core/` from cache (always force-overwrite). -3. For each kit in cache: compare kit version (skip same, file-level diff if newer, copy on first install), update kit files in `config/kits/{slug}/` via interactive diff prompts. +3. Refresh project-owned runtime surfaces. Kit file updates are separate by default and only run when the update flow explicitly opts into them. 4. Write aggregate `.gen/AGENTS.md` from collected kit rule parts. 5. Ensure `config/` scaffold files exist (create only if missing). 6. Re-inject root `AGENTS.md` and `CLAUDE.md` managed blocks. 7. Auto-regenerate agent integration files if real changes happened. -8. Run `validate-kits` to verify kit integrity; include result in report (WARN if failed). +8. Run `validate-kits` to verify kit integrity; include result in report. 9. Return update report. **Output** (JSON): @@ -256,7 +256,7 @@ cfs update [--project-root P] [--dry-run] [--no-interactive] [-y/--yes] "gen_agents": "updated", "gen_readme": "updated" }, - "self_check": {"status": "PASS", "kits_checked": 1, "templates_checked": 9} + "validate_kits": {"status": "PASS", "kits_checked": 1, "templates_checked": 9} } ``` @@ -471,16 +471,15 @@ cfs info **Output** (JSON): ```json { + "status": "FOUND", + "project_root": "/path/to/project", "relative_path": ".cf-studio", - "artifacts_toml": ".cf/config/artifacts.toml", + "has_config": true, "systems": [ { "name": "MyApp", "slug": "my-app", - "kit": "sdlc", - "artifacts_root": "architecture", - "artifacts_found": 3, - "codebase_paths": ["src/"] + "kit": "sdlc" } ], "kits": [ @@ -516,7 +515,7 @@ cfs resolve-vars [--root ROOT] [--kit KIT] [--flat] Show generated agent integration files without writing anything. ``` -cfs agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [--config PATH] +cfs agents [--agent AGENT | --openai] [--root PATH] [--cf-studio-root PATH] [--config PATH] ``` | Option | Description | @@ -524,7 +523,8 @@ cfs agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [--config P | `--agent AGENT` | Limit output to a specific agent: `windsurf`, `cursor`, `claude`, `copilot`, `openai` | | `--openai` | Shortcut for `--agent openai` | | `--root PATH` | Project root directory to search from (default: current directory) | -| `--cf-root PATH` | Explicit Constructor Studio core root (optional override) | +| `--cf-studio-root PATH` | Explicit Constructor Studio core root (optional override) | +| `--cf-constructor-root PATH` | Legacy alias for `--cf-studio-root` | | `--config PATH` | Path to agents config JSON (optional; built-in defaults used when omitted) | **Behavior**: @@ -533,6 +533,12 @@ cfs agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [--config P 3. Inspect generated workflow proxies, skill shims, and subagent files for the selected agents. 4. Return a read-only per-agent listing; no files are written. +**Status contract**: + +- `OK` on success +- `NOT_FOUND` when project root or Studio root cannot be resolved +- `CONFIG_ERROR` when the explicit config file cannot be parsed + **Exit**: 0. --- @@ -542,7 +548,7 @@ cfs agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [--config P Generate or update agent integration files. ``` -cfs generate-agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [--config PATH] [--dry-run] +cfs generate-agents [--agent AGENT | --openai] [--root PATH] [--cf-studio-root PATH] [--config PATH] [--dry-run] ``` | Option | Description | @@ -550,7 +556,8 @@ cfs generate-agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [- | `--agent AGENT` | Generate for a specific agent only: `windsurf`, `cursor`, `claude`, `copilot`, `openai` | | `--openai` | Shortcut for `--agent openai` | | `--root PATH` | Project root directory to search from (default: current directory) | -| `--cf-root PATH` | Explicit Constructor Studio core root (optional override) | +| `--cf-studio-root PATH` | Explicit Constructor Studio core root (optional override) | +| `--cf-constructor-root PATH` | Legacy alias for `--cf-studio-root` | | `--config PATH` | Path to agents config JSON (optional; built-in defaults used when omitted) | | `--dry-run` | Compute planned changes without writing files | @@ -562,7 +569,16 @@ cfs generate-agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [- 3. Generate workflow entry points in each agent's native format. 4. Generate skill shims referencing the composed SKILL.md. 5. Generate tool-specific subagent files where supported. -6. Full overwrite on each invocation (no merge with existing files). +6. Refresh the managed `.gitignore` block so all generated paths are ignored consistently. +7. Full overwrite on each invocation (no merge with existing files). + +**Discover mode**: +- `--discover` may extend or create `config/manifest.toml` from discovered layers before generating outputs. +- Any newly generated agent, skill, workflow, or subagent surface created by discover mode is also added to the managed `.gitignore` block. + +**Abort semantics**: +- If the user explicitly declines the interactive confirmation step, the command returns `ABORTED`, writes nothing, and exits `0`. +- A no-op preview or no-change run also exits `0`. **Generated surfaces**: | Agent | Generated files/directories | @@ -571,7 +587,7 @@ cfs generate-agents [--agent AGENT | --openai] [--root PATH] [--cf-root PATH] [- | Cursor | `.cursor/commands/`, `.cursor/agents/`, `.agents/skills/` (shared) | | Claude | `.claude/skills/`, `.claude/agents/` | | Copilot | `.github/prompts/`, `.github/copilot-instructions.md`, `.github/agents/`, `.agents/skills/` (shared) | -| OpenAI | `.agents/skills/` (shared), `.codex/.constructor-studio-installed` (marker), `.codex/agents/` | +| OpenAI | `.agents/skills/` (shared), `.codex/.cf-installed` (marker), `.codex/agents/` | **Detection model** (used by `info` and `update --auto-regenerate`): Each agent is detected via Constructor Studio-specific generated files, not generic tool directories. @@ -579,7 +595,7 @@ Each agent is detected via Constructor Studio-specific generated files, not gene - **Windsurf**: `.windsurf/workflows/cf.md` (primary) or legacy `.windsurf/skills/studio/SKILL.md` / `.windsurf/skills/cf/SKILL.md` with `{cf-studio-path}/` follow-link - **Cursor**: `.cursor/commands/cf.md` (primary) or legacy `.cursor/rules/studio.mdc` / `.cursor/rules/cf.mdc` with `{cf-studio-path}/` follow-link - **Copilot**: `.github/.constructor-studio-installed` (primary), `.github/prompts/cf.prompt.md` / legacy `.github/prompts/studio.prompt.md`, or `.github/copilot-instructions.md` starting with `# Constructor Studio` / `# Studio` (legacy). User-authored `copilot-instructions.md` files are never overwritten. -- **OpenAI**: `.codex/.constructor-studio-installed` (primary; legacy `.codex/.studio-installed` still recognized), `.codex/agents/` with Constructor Studio content (legacy mixed-install), or `.agents/skills/cf/SKILL.md` only when no other agent's primary or legacy marker is present (legacy pure) +- **OpenAI**: `.codex/.cf-installed` (primary), legacy Studio-owned Codex markers when present, `.codex/agents/` with Constructor Studio content, or `.agents/skills/cf/SKILL.md` only when no other agent's primary marker is present **Skill file model**: - **Kit workflow skills**: Generated as shared `.agents/skills/{id}/SKILL.md` for all non-Claude agents @@ -589,6 +605,14 @@ All non-Claude agents read from the shared `.agents/skills/` directory, but agen Legacy per-tool manifest skill files are migrated away only when they match generated content or are pure generated stubs; customized legacy files are preserved. +**Partial-result contract**: + +- `generate-agents` may return `PARTIAL` while still applying valid writes +- partial results report per-agent section details for `workflows`, `skills`, and + `subagents` +- unsupported provider capabilities are reported through skip metadata instead of + silent omission + **Exit**: 0. --- @@ -1007,7 +1031,7 @@ cfs workspace-add --name NAME (--path PATH | --url URL) [--branch BRANCH] [--rol | Option | Description | |--------|-------------| | `--name NAME` | Source name (human-readable key, required) | -| `--path PATH` | Path to the source repo (relative to workspace file or project root). Validated at add-time; returns error if directory not found. | +| `--path PATH` | Path to the source repo (relative to workspace file or project root). Stored as provided; reachability is checked later by `workspace-info` and `validate`. | | `--url URL` | Git remote URL (HTTPS or SSH) for the source | | `--branch BRANCH` | Git branch/ref to checkout | | `--role ROLE` | Source role: `artifacts`, `codebase`, `kits`, `full` (default: `full`) | @@ -1063,6 +1087,12 @@ cfs workspace-info ```json { "status": "OK", + "degraded": true, + "warning_count": 2, + "warnings": [ + "Source not cloned: repo-b", + "Config validation produced warnings" + ], "version": "1.0", "config_path": ".studio-workspace.toml", "is_inline": false, @@ -1107,7 +1137,10 @@ cfs workspace-info | Field | Type | Description | |-------|------|-------------| -| `status` | string | `"OK"` on success, `"ERROR"` on failure | +| `status` | string | `"OK"` on success, `"ERROR"` on failure; warning-only runs remain `"OK"` for compatibility | +| `degraded` | bool | Present when success includes warnings or unreachable sources | +| `warning_count` | int | Total warning count aggregated across config and sources | +| `warnings` | string[] | Flattened top-level warning list for human and machine consumers | | `version` | string | Workspace config version | | `config_path` | string | Path to workspace config file | | `is_inline` | bool | Whether workspace is inline in `core.toml` | @@ -1243,22 +1276,22 @@ CI pipelines should check for exit code 2 to detect validation failures. ### Project (per repository) ``` -{cf-studio-path}/ # Install directory (default: .cf-studio/, configurable via --dir) +{cf-studio-path}/ # Install directory (default: .cf-studio/, configurable via init flags or root managed block) .core/ # Read-only core files (copied from cache) skills/ # Skill bundle workflows/ # Core workflows (generate.md, analyze.md) requirements/ # Core requirement specs schemas/ # JSON schemas .gen/ # Auto-generated aggregate files (do not edit) - AGENTS.md # Generated WHEN rules + system prompt content + AGENTS.md # Generated navigation aggregates SKILL.md # Navigation hub routing to per-kit skills README.md # Generated README config/ # User-editable configuration - AGENTS.md # Project-level navigation (WHEN → sysprompt) + AGENTS.md # Project-level navigation (WHEN → rules/docs) SKILL.md # User-editable skill extensions core.toml # Core config (systems, kits, ignore) artifacts.toml # Artifact registry - sysprompts/ # Project-specific system prompts + rules/ # Project-specific rule docs kits/ sdlc/ conf.toml # Kit version metadata @@ -1294,7 +1327,7 @@ CI pipelines should check for exit code 2 to detect validation failures. .codex/ agents/ # OpenAI Codex sub-agent files - .constructor-studio-installed + .cf-installed ``` --- diff --git a/architecture/specs/kit/kit.md b/architecture/specs/kit/kit.md index 1d4a07e8..3ab524fb 100644 --- a/architecture/specs/kit/kit.md +++ b/architecture/specs/kit/kit.md @@ -31,19 +31,24 @@ drivers: A **Kit** is a file package that provides domain-specific artifact and codebase definitions for Studio. Each kit contains ready-to-use files — rules, templates, checklists, examples, constraints, workflows, and skill extensions — maintained directly by kit authors. -**What a kit provides** (installed into `{cf-studio-path}/config/kits//`): +**What a kit provides** (installed into `{cf-studio-path}/config/kits//` or +registered in place via manifest-driven install modes): - Per-artifact files: `artifacts//` containing `template.md`, `rules.md`, `checklist.md`, `examples/example.md` - Codebase files: `codebase/` containing `rules.md`, `checklist.md` - Kit-wide: `constraints.toml` (structural validation rules), `conf.toml` (version metadata) - Workflow files: `workflows/{name}.md` - SKILL.md — kit skill extensions for AI agent discoverability - Scripts: `scripts/` — kit-specific scripts and prompts -- Optional: `manifest.toml` — declarative installation manifest (see below) +- Optional canonical `.cf-studio-kit.toml` — declarative installation manifest +- Optional legacy `manifest.toml` — compatibility input normalized into the + canonical manifest model **Key properties**: - Kit registration (slug, version, config path, resolved resource bindings) is stored in `{cf-studio-path}/config/core.toml`; persisted resource binding paths are always project-relative (never absolute), and register-mode kits re-derive effective resource locations from the manifest at runtime instead of relying on persisted path anchors -- All kit files are user-editable after installation -- User modifications are preserved across kit updates via file-level diff with interactive prompts +- Tracked kit files are user-editable after installation +- Ignored kit files remain overwriteable generated surfaces +- User modifications to tracked kit files are preserved across kit updates via + file-level diff with interactive prompts - Kit version is stored in `{cf-studio-path}/config/kits//conf.toml` > **Plugin system** (CLI subcommands, validation hooks, generation hooks) is planned for p2 and not covered in this specification. @@ -58,7 +63,8 @@ When a kit is installed, all files are copied to `{cf-studio-path}/config/kits/{ ``` {cf-studio-path}/config/kits// -├── manifest.toml # (optional) Declarative installation manifest +├── .cf-studio-kit.toml # (optional) Canonical declarative installation manifest +├── manifest.toml # (optional) Legacy compatibility manifest ├── conf.toml # Kit version metadata (slug, version, name) ├── SKILL.md # Per-kit skill instructions (user-editable) ├── constraints.toml # Kit-wide structural constraints (user-editable) @@ -86,7 +92,7 @@ Top-level `.gen/` retains only aggregate files: `AGENTS.md`, `SKILL.md`, `README **Flow**: 1. `cfs init` / `cfs kit install` installs kit files from source: - - **If `manifest.toml` present**: validate manifest, prompt user for `user_modifiable` resource paths (offering defaults), copy or register each resource at its effective path, preserve `{identifier}` template variables in kit files for read-time resolution, and record effective install state in `core.toml` (`[kits.{slug}.resources]` only for non-register installs) + - **If canonical `.cf-studio-kit.toml` or legacy `manifest.toml` is present**: normalize to the manifest model, validate resources, prompt for `user_modifiable` destinations when required, copy or register each resource at its effective path, preserve `{identifier}` template variables in installed files, and record effective install state in `core.toml` (`[kits.{slug}.resources]` only for non-register installs) - **If no `manifest.toml`**: copy all kit files from source to `{cf-studio-path}/config/kits/{slug}/` (legacy behavior) - Register kit in `core.toml` 2. Regenerate `.gen/AGENTS.md` to include public kit rules; generate skill/workflow entrypoints through agent integration files @@ -98,7 +104,27 @@ Top-level `.gen/` retains only aggregate files: `AGENTS.md`, `SKILL.md`, `README | Mode | Command | Behavior | |------|---------|----------| | **Force** | `cfs kit update --force` | Overwrites all kit files in `{cf-studio-path}/config/kits/{slug}/`. User edits are discarded. | -| **Interactive** (default) | `cfs kit update` | File-level diff: for each file, compare new version against user's installed copy. **IF** identical → no action. **IF** different → present unified diff with `[a]ccept / [d]ecline / [A]ccept all / [D]ecline all / [m]odify` prompts. | +| **Interactive** (default) | `cfs kit update` | File-level diff: for each file, compare new version against user's installed copy. **IF** identical → no action. **IF** different → present unified diff with `[a]ccept / [d]ecline / [A]ccept all / [D]ecline all / [m]odify` prompts. Files accepted by the user are counted as fully updated; declined files keep the kit in a partial-update outcome instead of being counted as fully updated. | + +**Interactive partial outcome semantics**: +- When a user accepts some changes and declines others, the command reports a partial outcome rather than inflating `kits_updated`. +- Machine-readable output distinguishes full and partial success via separate counters such as `kits_updated` and `kits_partially_updated`. +- Interactive partial updates are user-confirmed outcomes, not write failures; the command surfaces the partial status explicitly so callers can distinguish it from a clean full update. + +**Canonical public-component semantics**: + +- Canonical manifests may declare `public = true` resources. +- Public resources may declare `generated_targets = [...]` to constrain which + agent hosts receive generated outputs. +- Public agent resources may declare nested `subagents`. +- `generate-agents` consumes these public resources to produce shared + `.agents/skills/*`, host-native proxies, and supported subagent outputs. +- Unsupported host capabilities are surfaced as partial/skip metadata rather + than silently dropped. + +**Check-updates semantics**: +- `cfs kit check-updates` succeeds only when every inspected kit source check succeeds. +- If at least one kit cannot be checked because its source is invalid, unreachable, or otherwise errors, the command returns a failing result and non-zero exit even if other kits were checked successfully. --- @@ -118,7 +144,8 @@ Each kit file is authored directly by kit authors and user-editable after instal | `workflows/{name}.md` | `workflows/` | Workflow definitions | — | | `SKILL.md` | kit root | Kit skill extensions | — | | `conf.toml` | kit root | Kit version metadata | — | -| `manifest.toml` | kit root (optional) | Declarative installation manifest: resource identifiers, default paths, types, user-modifiability flags. Governs installation and update when present | [kit.md](#kit-overview) | +| `.cf-studio-kit.toml` | kit root (optional) | Canonical declarative installation manifest: resource identifiers, install paths, types, public generation settings, and user-modifiability flags | [kit.md](#kit-overview) | +| `manifest.toml` | kit root (optional) | Legacy compatibility manifest normalized into the canonical model | [kit.md](#kit-overview) | --- diff --git a/architecture/specs/sysprompts.md b/architecture/specs/sysprompts.md index 828058fe..a948536a 100644 --- a/architecture/specs/sysprompts.md +++ b/architecture/specs/sysprompts.md @@ -1,438 +1,239 @@ --- studio: true type: spec -name: Project Extension Specification -version: 1.0 -purpose: Define how projects extend Constructor Studio behavior through {cf-studio-path}/config/sysprompts and config/AGENTS.md with operation-scoped system prompts +name: Project Rules and Root Navigation Specification +version: 2.0 +purpose: Define the project-owned prompt-asset surfaces under {cf-studio-path}/config/, the managed root navigation blocks, and the read vs repair lifecycle observed by the CLI drivers: - cpt-studio-fr-core-config - cpt-studio-fr-core-workflows + - cpt-studio-fr-core-init + - cpt-studio-fr-core-agents --- -# Project Extension Specification - +# Project Rules and Root Navigation Specification - [Overview](#overview) - [Runtime Contract](#runtime-contract) -- [Extension Directory](#extension-directory) -- [Root AGENTS.md Entry](#root-agentsmd-entry) +- [Project-Owned Prompt Assets](#project-owned-prompt-assets) +- [Root Managed Blocks](#root-managed-blocks) +- [Read vs Repair Lifecycle](#read-vs-repair-lifecycle) - [config/AGENTS.md](#configagentsmd) - - [Required Structure](#required-structure) - - [WHEN Rule Format](#when-rule-format) -- [System Prompt Files](#system-prompt-files) - - [Format](#format) - - [Standard System Prompts](#standard-system-prompts) - - [Content Principles](#content-principles) -- [System Prompt Loading](#system-prompt-loading) - - [When Prompts Are Loaded](#when-prompts-are-loaded) - - [Loading Algorithm](#loading-algorithm) - - [Interaction with Kit Prompts](#interaction-with-kit-prompts) -- [System Prompt Discovery](#system-prompt-discovery) -- [Validation](#validation) - - [AGENTS.md Validation](#agentsmd-validation) - - [System Prompt File Validation](#system-prompt-file-validation) -- [Error Handling](#error-handling) - - [System Prompt Not Found](#system-prompt-not-found) - - [AGENTS.md Not Found](#agentsmd-not-found) - - [Invalid WHEN Format](#invalid-when-format) -- [Example](#example) +- [Rule Files](#rule-files) +- [Validation and Error Handling](#validation-and-error-handling) - [References](#references) ---- ---- - ## Overview -Projects extend Constructor Studio behavior by placing **system prompts** in `{cf-studio-path}/config/sysprompts/` and registering them via `{cf-studio-path}/config/AGENTS.md`. These prompts are loaded by workflows during generate, analyze, and code operations, providing project-specific context without modifying kit files or core configuration. +This spec defines the current project-extension model used by Constructor +Studio. -**Key properties**: -- System prompts live in `{cf-studio-path}/config/sysprompts/*.md` — plain Markdown files -- `AGENTS.md` at `{cf-studio-path}/config/AGENTS.md` maps prompts to operations via `WHEN` rules -- Prompts are loaded at runtime — no code generation, no build step -- Project-specific: conventions, tech stack, domain model, patterns, etc. -- Complementary to kit files: kit rules define artifact structure, project system prompts define project context +The legacy `config/sysprompts/` model is obsolete for this repository's +documented runtime. Project-owned instruction assets now live primarily in: + +- `{cf-studio-path}/config/AGENTS.md` +- `{cf-studio-path}/config/rules/*.md` +- `{cf-studio-path}/config/SKILL.md` + +`config/AGENTS.md` declares action-scoped `ALWAYS open and follow ... WHEN ...` +rules. Those rules point at project rule files and selected architecture or +contributor docs. Root `AGENTS.md` and `CLAUDE.md` contain only the managed +`cf-studio-path` handoff block and are not general-purpose prompt bundles. ## Runtime Contract ```pdsl -UNIT SyspromptClassification +UNIT ProjectRuleClassification PURPOSE: - Define project sysprompt surfaces as prompt assets with controller-owned - loading authority. + Define project-owned prompt assets as controller-loaded instruction surfaces. DO: - LOAD {cf-studio-path}/.core/architecture/specs/shared-context-pack.md - CONTINUE SharedContextPackLifecycle RULES: - - ALWAYS `{cf-studio-path}/config/AGENTS.md` and - `{cf-studio-path}/config/sysprompts/*.md` remain the project prompt-asset - family for this shared ownership contract - - ALWAYS When loaded into `SHARED_CONTEXT_PACK`, those assets ALWAYS be - recorded with `origin = "project"` + - ALWAYS treat `{cf-studio-path}/config/AGENTS.md`, + `{cf-studio-path}/config/rules/*.md`, and selected project docs referenced + from AGENTS rules as the project prompt-asset family + - ALWAYS record those assets with `origin = "project"` when loaded into + `SHARED_CONTEXT_PACK` ``` ```pdsl -UNIT SyspromptLoading +UNIT ProjectRuleLoading PURPOSE: - Make project sysprompt selection explicit and shared-context-pack aware. + Make project-rule selection explicit and controller-owned. DO: - REQUIRE operation context is resolved - REQUIRE controller reads `{cf-studio-path}/config/AGENTS.md` - - REQUIRE controller evaluates action-based `WHEN` rules against the current context - - REQUIRE controller loads matching system prompt files in declaration order - - REQUIRE controller publishes matched prompt text into `SHARED_CONTEXT_PACK` - - REQUIRE controller synthesizes a final dispatch prompt for any - prompt-consuming sub-agent dispatch + - REQUIRE controller evaluates action-based `WHEN` rules in declaration order + - REQUIRE controller loads the referenced rule or doc files for matching rules + - REQUIRE controller publishes matched instruction text into `SHARED_CONTEXT_PACK` RULES: - - ALWAYS keep kit prompts and project sysprompts separate prompt-asset families - - ALWAYS load only the prompt assets required by the active operation - - ALWAYS treat missing required prompt context as a controller error rather than - a license for direct file reads -``` - -```pdsl -UNIT SyspromptValidationAndErrors - -PURPOSE: - Define deterministic validation and warning behavior for project sysprompts. - -ON_ERROR: - orphaned_when_rule -> - EMIT "Orphaned WHEN rule: sysprompts/{name}.md not found" - CONTINUE without that rule - - missing_project_agents -> - EMIT "Project AGENTS.md not found: {cf-studio-path}/config/AGENTS.md" - CONTINUE with kit-level prompts only - - invalid_when_rule -> - EMIT "Invalid WHEN rule format in AGENTS.md" - CONTINUE after skipping the invalid rule -``` - -**What goes here vs. in kit files**: + - ALWAYS keep controller-owned prompt loading separate from target task files + - ALWAYS load only the rule and doc assets relevant to the active operation + - NEVER allow prompt-consuming sub-agents to reopen those prompt files directly +``` + +## Project-Owned Prompt Assets + +```text +{cf-studio-path}/ + config/ + AGENTS.md # Project navigation rules + SKILL.md # Project skill extensions + core.toml + artifacts.toml + rules/ + tech-stack.md + conventions.md + project-structure.md + domain-model.md + testing.md + build-deploy.md + architecture.md + patterns.md + anti-patterns.md + ... +``` + +Typical division of responsibility: | Concern | Location | |---------|----------| -| Artifact structure, ID kinds, heading rules | Kit files (`rules.md`, `constraints.toml`, `template.md`) | -| Project tech stack, naming conventions | `{cf-studio-path}/config/sysprompts/tech-stack.md` | -| Domain model, entity relationships | `{cf-studio-path}/config/sysprompts/domain-model.md` | -| API contract format | `{cf-studio-path}/config/sysprompts/api-contracts.md` | - ---- - -## Extension Directory - -``` -{cf-studio-path}/ # Install directory (default: .cf/) -└── config/ - ├── AGENTS.md # Navigation rules (WHEN → spec file) - ├── core.toml # Core config - ├── artifacts.toml # Artifact registry - └── sysprompts/ # Project-specific system prompts - ├── tech-stack.md - ├── conventions.md - ├── domain-model.md - ├── patterns.md - ├── testing.md - └── ... -``` - -All sysprompt files are optional. Only files referenced in `AGENTS.md` are loaded. - ---- +| Project-level action routing | `{cf-studio-path}/config/AGENTS.md` | +| Topic-focused project guidance | `{cf-studio-path}/config/rules/*.md` | +| Project-specific skill extensions | `{cf-studio-path}/config/SKILL.md` | +| Artifact structure and validation rules | kit files under `config/kits//` | -## Root AGENTS.md Entry +## Root Managed Blocks -Constructor Studio injects the same managed block into the **project root** `AGENTS.md` and `CLAUDE.md`, exposing only the configured install path: +Constructor Studio injects a managed block into project-root `AGENTS.md` and +`CLAUDE.md`: ````markdown ```toml -cf-path = ".cf" +cf-studio-path = ".bootstrap" ``` ```` -**Behavior**: -- Inserted at the **beginning** of the root `AGENTS.md` and `CLAUDE.md` files -- If a file does not exist, it is created -- The path reflects the actual install directory via `cf-path` -- Content between the `` and `` markers is **fully managed** by Constructor Studio — overwritten on every check -- Manual edits inside the block are discarded +Rules: -**Integrity check**: every Constructor Studio CLI invocation (not just `init`) verifies both blocks exist and the path is correct. If a block is missing or stale, it is silently re-injected. This ensures any agent that opens the project is immediately routed to Constructor Studio's navigation entry point. +- The managed payload is only the TOML fence declaring `cf-studio-path`. +- The block is inserted at the beginning of the file. +- Missing root files may be created by setup or migration flows. +- Manual edits inside the managed markers are discarded when a setup or repair + flow rewrites the block. +- Project-skill routing reads this block; ordinary read-only commands do not + silently rewrite it. ---- +## Read vs Repair Lifecycle -## config/AGENTS.md +Observed CLI behavior splits cleanly into two modes. -**Location**: `{cf-studio-path}/config/AGENTS.md` +**Read-only / inspect flows** -`{cf-studio-path}/config/AGENTS.md` is the project-level navigation file. It declares which system prompts to load for which operations. Agents reach this file via the root `AGENTS.md` entry above. +- `cfs info` +- `cfs agents` +- `cfs validate-toc` +- ordinary project-skill routing -Kit workflow commands are **not** placed here — they are exposed via agent entry points (e.g., `.windsurf/workflows/cf-*.md`) generated from kit workflow files (see [kit.md](kit/kit.md)). +These commands read root metadata and project config but are expected to leave +the filesystem unchanged when no explicit write action was requested. -### Required Structure +**Repair / write flows** -```markdown -# Constructor Studio: {Project Name} +- `cfs init` +- repeat `cfs init` repair mode +- `cfs update` +- legacy migration flows -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/tech-stack.md` WHEN writing code, choosing technologies, or adding dependencies -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/conventions.md` WHEN writing code, naming files/functions/variables, or reviewing code -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/domain-model.md` WHEN working with entities, data structures, or business logic -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/testing.md` WHEN writing tests, reviewing test coverage, or debugging -``` +These flows may refresh or recreate: -### WHEN Rule Format - -``` -ALWAYS open and follow `{sysprompt-path}` WHEN {action-description} -``` +- root `AGENTS.md` managed block +- root `CLAUDE.md` managed block +- `{cf-studio-path}/.core/` +- `{cf-studio-path}/.gen/` +- managed `.gitignore` block +- generated agent integration outputs -- `{sysprompt-path}` — relative to `{cf-studio-path}/config/` (e.g., `sysprompts/tech-stack.md`) -- `{action-description}` — action-based description of WHEN to load the system prompt +The architecture contract is therefore: -**Rules MUST be action-based** — they describe what the agent is doing, not which artifact kind is active: +- root managed blocks are refreshed by explicit setup/repair commands +- root managed blocks are not silently repaired by unrelated read-only commands -| Correct | Incorrect | -|---------|-----------| -| `WHEN writing code, choosing technologies` | `WHEN generating DESIGN` | -| `WHEN working with entities, data structures` | `WHEN Constructor Studio uses kit sdlc` | -| `WHEN writing tests, reviewing coverage` | `WHEN working on project` | - ---- - -## System Prompt Files +## config/AGENTS.md -System prompt files are plain Markdown documents in `{cf-studio-path}/config/sysprompts/`. Each file provides project-specific context that agents load during operations. +`{cf-studio-path}/config/AGENTS.md` is the project navigation file. It declares +which rule or doc files the controller should load for a given activity. -### Format +Example: ```markdown -# {Spec Name} - -## Overview -{Brief description of what this spec covers and why it matters} - -## {Content Sections} -{Domain-specific directives, constraints, and examples} - ---- -**Source**: {Where this knowledge was discovered — DESIGN.md, ADRs, codebase, etc.} -**Last Updated**: {Date} -``` - -### Standard System Prompts - -| System Prompt | WHEN Rule | Contains | -|-----------|-----------|----------| -| `tech-stack.md` | writing code, choosing technologies, adding dependencies | Languages, frameworks, databases, infrastructure constraints | -| `conventions.md` | writing code, naming files/functions/variables, reviewing code | Naming conventions, code style, file organization | -| `project-structure.md` | creating files, adding modules, navigating codebase | Directory layout, module organization, entry points | -| `domain-model.md` | working with entities, data structures, business logic | Core concepts, entity relationships, invariants | -| `testing.md` | writing tests, reviewing test coverage, debugging | Test frameworks, patterns, coverage requirements | -| `patterns.md` | implementing features, designing components, refactoring | Architecture patterns, design patterns, state management | -| `api-contracts.md` | creating/consuming APIs, defining endpoints, handling requests | Contract format, endpoint patterns, protocols | -| `build-deploy.md` | building, deploying, configuring CI/CD | Build commands, CI/CD pipeline, deployment procedures | -| `security.md` | handling authentication, authorization, sensitive data | Auth mechanisms, data classification, encryption | -| `performance.md` | optimizing, caching, working with high-load components | SLAs, caching strategy, optimization patterns | -| `reliability.md` | handling errors, implementing retries, adding health checks | Error handling, recovery, circuit breakers | - -Not all system prompts apply to all projects. Create only what is relevant. - -### Content Principles - -- **Actionable**: not just descriptions, but what to do -- **Project-specific**: conventions that differ from kit defaults -- **Source-referenced**: note where knowledge came from (DESIGN.md, ADRs, codebase) -- **No artifact content**: no PRD requirements, no ADR rationale — those belong in artifacts - ---- - -## System Prompt Loading +# Constructor Studio Adapter: Constructor Studio -### When Prompts Are Loaded - -Workflows load project system prompts at specific points: - -| Operation | Loaded System Prompts (via WHEN matching) | -|-----------|-------------------------------------------| -| `cf generate PRD` | Prompts matching "working with entities", "writing requirements" | -| `cf generate DESIGN` | Prompts matching "designing components", "choosing technologies" | -| `{cfs_cmd} validate` | Prompts matching relevant artifact content | -| Code generation/review | `tech-stack.md`, `conventions.md`, `patterns.md` | - -### Loading Algorithm - -1. Determine current operation context (generate, analyze, code, etc.) -2. Read `{cf-studio-path}/config/AGENTS.md` -3. For each `WHEN` rule, match the action description against current context -4. Load matching system prompt files in declaration order -5. Publish matching prompt text into `SHARED_CONTEXT_PACK` as `origin = "project"` assets -6. Synthesize the final dispatch prompt for any prompt-consuming sub-agent - -### Interaction with Kit Prompts - -Project system prompts are **additive** — they don't replace kit-level prompts. Loading order: - -1. Kit rules and prompts (from `rules.md`, `SKILL.md`) — artifact-kind-level directives -2. Project `{cf-studio-path}/config/sysprompts/*.md` (from AGENTS.md WHEN rules) — project-level context - -If a project system prompt contradicts a kit prompt, the project system prompt takes precedence (project-specific overrides generic). - -Prompt-consuming sub-agents receive the relevant project context through the -controller-synthesized final dispatch prompt; they MUST NOT reopen project -sysprompt files directly. - ---- - -## System Prompt Discovery - -For existing projects, Constructor Studio can auto-discover system prompt candidates: - -```bash -{cfs_cmd} init --discover -``` - -**Discovery process**: -1. Scan project for signals (config files, package manifests, CI configs, test directories) -2. Propose system prompt files based on findings -3. Generate draft system prompts with discovered information -4. User reviews and confirms - -**Discovery signals**: - -| Signal | Produces | -|--------|----------| -| `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` | `tech-stack.md` | -| `.eslintrc`, `.prettierrc`, `ruff.toml`, `.editorconfig` | `conventions.md` | -| Test directories, `pytest.ini`, `jest.config.js` | `testing.md` | -| `Makefile`, `.github/workflows/`, `Dockerfile` | `build-deploy.md` | -| `openapi.yml`, `*.proto` | `api-contracts.md` | -| Schema/model directories, DESIGN.md domain section | `domain-model.md` | -| Auth middleware, security configs | `security.md` | - ---- - -## Validation - -### AGENTS.md Validation - -| # | Check | Required | How to Verify | -|---|-------|----------|---------------| -| A.1 | `{cf-studio-path}/config/AGENTS.md` exists | YES | File exists | -| A.2 | Has project name heading | YES | `# Constructor Studio: {name}` present | -| A.3 | All WHEN rules use action-based format | YES | Pattern: `WHEN {verb}ing ...` | -| A.4 | No orphaned WHEN rules | YES | All referenced system prompt files exist | - -### System Prompt File Validation - -| # | Check | Required | How to Verify | -|---|-------|----------|---------------| -| S.1 | Has H1 heading | YES | `# {name}` present | -| S.2 | Has Overview section | YES | `## Overview` present | -| S.3 | Has Source reference | YES | `**Source**:` present | -| S.4 | No artifact content (PRD, ADR rationale) | YES | No requirement IDs, no decision rationale | -| S.5 | Content is actionable | YES | Contains directives, not just descriptions | - -**Validation command**: -```bash -{cfs_cmd} validate --sysprompts +ALWAYS open and follow `{cf-studio-path}/config/rules/tech-stack.md` WHEN writing code, choosing technologies, or adding dependencies +ALWAYS open and follow `{cf-studio-path}/config/rules/conventions.md` WHEN writing code, naming files/functions/variables, or reviewing code +ALWAYS open and follow `{cf-studio-path}/config/rules/architecture.md` WHEN modifying architecture, adding components, or refactoring module boundaries +ALWAYS open and follow `CONTRIBUTING.md#making-changes` WHEN making code changes, architecture changes, or kit blueprint changes ``` ---- +Requirements: -## Error Handling +- Rules must be action-based. +- Paths may target project rule files or other project docs. +- Declaration order matters for load order. +- Kit workflow entry points are not declared here; they are exposed through + generated agent surfaces. -### System Prompt Not Found +## Rule Files -``` -⚠️ Orphaned WHEN rule: sysprompts/{name}.md not found -→ Referenced in: {cf-studio-path}/config/AGENTS.md -→ Fix: Create the sysprompt file OR remove the WHEN rule -``` -**Action**: WARN — workflow continues without the missing spec. +Rule files are plain Markdown guidance documents under +`{cf-studio-path}/config/rules/`. -### AGENTS.md Not Found +Recommended topics match the auto-config output model: -``` -⚠️ Project AGENTS.md not found: {cf-studio-path}/config/AGENTS.md -→ No project-level system prompts will be loaded -→ Fix: Run `cfs init` to create AGENTS.md -``` -**Action**: WARN — workflows proceed with kit-level system prompts only. +- `tech-stack.md` +- `conventions.md` +- `project-structure.md` +- `domain-model.md` +- `testing.md` +- `build-deploy.md` +- `architecture.md` +- `patterns.md` +- `anti-patterns.md` -### Invalid WHEN Format +These files are project-owned guidance, not generated runtime-only shims. -``` -⚠️ Invalid WHEN rule format in AGENTS.md -→ Line: "ALWAYS open and follow `specs/tech-stack.md` WHEN working on project" -→ Expected: action-based description (WHEN writing code, WHEN designing, etc.) -→ Fix: Use specific action verbs -``` -**Action**: WARN — rule is skipped during loading. - ---- +## Validation and Error Handling -## Example +Validation expectations: -A complete project extension for a TypeScript web application: +- `{cf-studio-path}/config/AGENTS.md` must exist in initialized projects. +- Referenced `config/rules/*.md` files should exist for active project rules. +- Missing rule files are configuration defects, not permission for a sub-agent to + read unrelated files directly. -`{cf-studio-path}/config/AGENTS.md`: -```markdown -# Constructor Studio: MyApp - -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/tech-stack.md` WHEN writing code, choosing technologies, or adding dependencies -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/conventions.md` WHEN writing code, naming files/functions/variables, or reviewing code -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/domain-model.md` WHEN working with entities, data structures, or business logic -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/testing.md` WHEN writing tests, reviewing test coverage, or debugging -ALWAYS open and follow `{cf-studio-path}/config/sysprompts/api-contracts.md` WHEN creating/consuming APIs, defining endpoints, or handling requests -``` +Relevant observed error/read contracts: -`{cf-studio-path}/config/sysprompts/tech-stack.md`: -```markdown -# Tech Stack - -## Overview -MyApp is a TypeScript monorepo using Next.js for the frontend and Fastify for the API. - -## Languages -- **TypeScript** 5.x — all application code -- **SQL** — database migrations (raw SQL, no ORM) - -## Frameworks -- **Next.js** 14 — frontend (App Router, Server Components) -- **Fastify** 4 — API server -- **Drizzle ORM** — database access - -## Database -- **PostgreSQL** 16 — primary datastore -- **Redis** 7 — caching and sessions - -## Infrastructure -- **Docker** — local development -- **Vercel** — frontend deployment -- **Fly.io** — API deployment - ---- -**Source**: DESIGN.md (Section 2.1 Technology Stack) -**Last Updated**: 2026-02-23 -``` - ---- +- read-only commands such as `info` return `FOUND` / `NOT_FOUND` style statuses + without mutating root files +- `agents` returns `OK`, `NOT_FOUND`, or `CONFIG_ERROR` and remains read-only +- setup/update flows own the responsibility for root-block repair ## References -- **Kit specification**: `specs/kit/kit.md` — kit structure, file reference, update model -- **Rules format**: `specs/kit/rules.md` — workflow entry point -- **CLI**: `specs/cli.md` — `init`, `agents`, `validate --sysprompts` commands +- [cli.md](./cli.md) +- [shared-context-pack.md](./shared-context-pack.md) +- [kit/kit.md](./kit/kit.md) +- [../DESIGN.md](../DESIGN.md) diff --git a/guides/AGENT-TOOLS.md b/guides/AGENT-TOOLS.md index ecefce6e..d81638ef 100644 --- a/guides/AGENT-TOOLS.md +++ b/guides/AGENT-TOOLS.md @@ -89,6 +89,8 @@ Claude Code is the **canonical full-fidelity format** for generated subagents. Other tools receive the best adaptation their host format supports, with **graceful degradation** where a capability has no equivalent. +Read-only inspection uses `cfs agents`, which reports the same surface model without writing files. Write-capable generation uses `cfs generate-agents`, which may return a partial result when some host-specific capabilities are skipped or some generated outputs are intentionally preserved. + Typical setup: 🖥 **Terminal**: diff --git a/guides/CONFIGURATION.md b/guides/CONFIGURATION.md index 2a2e8239..f27cba75 100644 --- a/guides/CONFIGURATION.md +++ b/guides/CONFIGURATION.md @@ -709,5 +709,5 @@ cfs mirror clear --yes | CDSL language | `architecture/specs/CDSL.md` | | CLI commands | `architecture/specs/cli.md` | | Mirror overrides | `architecture/ADR/0020-cpt-studio-adr-rebrand-and-mirror-override-v1.md` | -| System prompts | `architecture/specs/sysprompts.md` | +| Project rules and root navigation | `architecture/specs/sysprompts.md` | | Workspace config | `schemas/workspace.schema.json` | diff --git a/guides/USAGE-GUIDE.md b/guides/USAGE-GUIDE.md index 88484087..9ea3d989 100644 --- a/guides/USAGE-GUIDE.md +++ b/guides/USAGE-GUIDE.md @@ -206,11 +206,11 @@ For a first trial, it is usually safe to accept the default project root, keep t `cfs init` sets up Constructor Studio in the repository. If you run it again in a repository that is already initialized, it repairs generated Studio runtime files and agent integrations using the version already pinned in that project. -`cfs generate-agents` adds the AI coding tool integration files for that repository. +`cfs generate-agents` adds or refreshes the AI coding tool integration files for that repository. -`cfs generate-agents` may preview the files it will create and ask you to confirm before writing them. +`cfs generate-agents` may preview the files it will create and ask you to confirm before writing them. It can also report partial success when some provider-specific capabilities are skipped or some generated outputs are intentionally preserved. -In a normal project, this creates a setup directory `.cf-studio/`, generated host integration files, and user-editable configuration under `config/` inside that setup directory. +In a normal project, this creates a setup directory `.cf-studio/`, generated host integration files, and user-editable configuration under `config/` inside that setup directory. Project task guidance normally lives in `config/AGENTS.md`, `config/SKILL.md`, and `config/rules/`. Generated runtime files such as `.cf-studio/.core/` and `.cf-studio/.gen/` are gitignored by default. Generated host integration files are also gitignored by default. Kit files are tracked, ignored, or registered per kit: tracked kits are editable repository content, ignored kits are generated local content that Studio may repair or overwrite, and registered local kits stay in place with bindings recorded in `core.toml`. diff --git a/skills/studio/scripts/studio/cli.py b/skills/studio/scripts/studio/cli.py index 68948e7a..d0e21649 100644 --- a/skills/studio/scripts/studio/cli.py +++ b/skills/studio/scripts/studio/cli.py @@ -190,14 +190,17 @@ def main(argv: Optional[List[str]] = None) -> int: def _main_impl(argv_list: List[str]) -> int: """Dispatch a command after global flags have been handled.""" + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-parse-command # Load best-effort context on startup. This may resolve to a direct # StudioContext, a WorkspaceContext discovered from a workspace root, or # remain None when the current directory is not initialized. from .utils.context import ensure_context, set_context set_context(None) - ctx = ensure_context(Path.cwd()) + ensure_context(Path.cwd()) # Context may be None if Constructor Studio not initialized - that's OK for some commands like init + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-parse-command + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-lookup-handler # Define all available commands analysis_commands = ["validate", "validate-kits", "validate-toc", "spec-coverage", "check-language"] legacy_aliases = ["validate-code", "validate-rules"] @@ -223,10 +226,12 @@ def _main_impl(argv_list: List[str]) -> int: + workspace_commands + utility_commands + delegation_commands + diagnostics_commands + visualization_commands + legacy_aliases ) + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-lookup-handler # Handle --help / -h at top level (or no subcommand) if not argv_list or argv_list[0] in ("-h", "--help"): from .utils.ui import ui, is_json_mode + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-parse-args _cmd_descriptions = { "validate": "Validate artifacts and code traceability", "validate-kits": "Validate kit structure, templates, and examples", @@ -256,6 +261,8 @@ def _main_impl(argv_list: List[str]) -> int: "doctor": "Run environment health checks", "map": "Build interactive markdown↔source dependency map via cpt identifiers", } + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-parse-args + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-execute-handler _sections = [ ("Setup & Configuration", ["init", "update", "info", "resolve-vars", "generate-agents", "agents"]), ("Validation", ["validate", "validate-kits", "validate-toc", "spec-coverage", "check-language"]), @@ -267,14 +274,18 @@ def _main_impl(argv_list: List[str]) -> int: ("Diagnostics", ["doctor"]), ("Visualization", ["map"]), ] + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-execute-handler if is_json_mode(): + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-serialize-json import json # pylint: disable=import-outside-toplevel # lazy: only needed in JSON output mode print(json.dumps({ "usage": "cfs [options]", "commands": _cmd_descriptions, "sections": dict(_sections), }, indent=2, ensure_ascii=False)) + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-serialize-json else: + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-return-code ui.header("Constructor Studio CLI") ui.info("Artifact validation, traceability, and kit management tool.") ui.blank() @@ -290,6 +301,7 @@ def _main_impl(argv_list: List[str]) -> int: ui.hint("Run 'cfs --help' for command-specific options.") ui.hint("Legacy aliases: validate-code → validate, validate-rules/self-check → validate-kits") ui.blank() + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-return-code return 0 # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-route-helpers @@ -309,6 +321,7 @@ def _main_impl(argv_list: List[str]) -> int: # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-serialize-json # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-return-code # Dispatch to appropriate command handler + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-route-helpers if cmd == "validate": return _cmd_validate(rest) if cmd == "validate-code": @@ -320,6 +333,8 @@ def _main_impl(argv_list: List[str]) -> int: return _cmd_init(rest) if cmd == "update": return _cmd_update(rest) + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-route-helpers + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-parse-command if cmd == "list-ids": return _cmd_list_ids(rest) if cmd == "list-id-kinds": @@ -338,6 +353,8 @@ def _main_impl(argv_list: List[str]) -> int: return _cmd_agents(rest) if cmd == "generate-agents": return _cmd_generate_agents(rest) + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-parse-command + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-route-helpers if cmd == "kit": return _cmd_kit(rest) if cmd == "generate-resources": @@ -348,6 +365,8 @@ def _main_impl(argv_list: List[str]) -> int: return _cmd_validate_toc(rest) if cmd == "spec-coverage": return _cmd_spec_coverage(rest) + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-route-helpers + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-parse-command if cmd == "chunk-input": return _cmd_chunk_input(rest) if cmd == "workspace-init": @@ -368,6 +387,7 @@ def _main_impl(argv_list: List[str]) -> int: return _cmd_pdsl(rest) if cmd == "map": return _cmd_map(rest) + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-parse-command # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-if-no-handler # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-return-unknown from .utils.ui import ui diff --git a/skills/studio/scripts/studio/commands/__init__.py b/skills/studio/scripts/studio/commands/__init__.py index e69de29b..047525e9 100644 --- a/skills/studio/scripts/studio/commands/__init__.py +++ b/skills/studio/scripts/studio/commands/__init__.py @@ -0,0 +1,5 @@ +"""Command package for Constructor Studio CLI handlers.""" + +# @cpt-algo:cpt-studio-algo-core-infra-route-command:p1 +# This package groups command handler modules that the CLI router imports and dispatches. +__all__: list[str] = [] diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 8a01f5c7..63fc1464 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -3963,8 +3963,6 @@ class ManagedOutput: path: str provider: str owner_kind: str - owner_id: str = "" - bundle_id: str = "" managed: bool = True gitignore: bool = True @@ -3993,21 +3991,13 @@ def _provider_for_output_path(path: str) -> str: return "unknown" -def _bundle_id_for_output_path(path: str) -> str: - normalized = path.replace("\\", "/").strip("/") - name = Path(normalized).name - for suffix in (".agent.md", ".prompt.md", ".toml", ".md"): - if name.endswith(suffix): - return name[: -len(suffix)] - return name - - def _managed_outputs_from_section( project_root: Path, section: Dict[str, Any], *, owner_kind: str, ) -> List[ManagedOutput]: + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-agent-output-paths outputs: List[ManagedOutput] = [] for path in sorted(_collect_managed_result_paths(project_root, section)): provider = _provider_for_output_path(path) @@ -4016,15 +4006,15 @@ def _managed_outputs_from_section( path=path, provider=provider, owner_kind=owner_kind, - owner_id=path, - bundle_id=_bundle_id_for_output_path(path), ) ) return outputs + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-agent-output-paths def _scan_owned_generated_outputs(project_root: Path) -> List[ManagedOutput]: """Return generated outputs provably owned by Constructor Studio on disk.""" + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-determine-agent-path scan_roots = { "claude": [project_root / ".claude" / "agents", project_root / ".claude" / "skills"], "cursor": [project_root / ".cursor" / "agents", project_root / ".cursor" / "commands"], @@ -4033,7 +4023,9 @@ def _scan_owned_generated_outputs(project_root: Path) -> List[ManagedOutput]: "windsurf": [project_root / ".windsurf" / "workflows"], "studio": [project_root / ".agents" / "skills"], } + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-determine-agent-path outputs: List[ManagedOutput] = [] + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-iterate-agents for provider, roots in scan_roots.items(): for root in roots: if not root.is_dir(): @@ -4046,6 +4038,7 @@ def _scan_owned_generated_outputs(project_root: Path) -> List[ManagedOutput]: rel = _safe_relpath(path, project_root) owner_kind = "generated" owned = False + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-check-skip if rel.endswith(".toml"): owned = _is_studio_managed_toml_output(content) owner_kind = "agent" @@ -4061,18 +4054,20 @@ def _scan_owned_generated_outputs(project_root: Path) -> List[ManagedOutput]: owner_kind = "skill" elif "/workflows/" in rel or "/commands/" in rel or "/prompts/" in rel: owner_kind = "workflow" + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-check-skip if not owned: continue + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-track-agent-results outputs.append( ManagedOutput( path=rel, provider=provider, owner_kind=owner_kind, - owner_id=rel, - bundle_id=_bundle_id_for_output_path(rel), ) ) + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-track-agent-results return outputs + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-iterate-agents def collect_managed_outputs( @@ -4083,20 +4078,21 @@ def collect_managed_outputs( cfg = _default_agents_config() managed: Dict[str, ManagedOutput] = {} + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-agent-output-paths for marker_path, _marker_content in _INSTALL_MARKERS.values(): normalized = marker_path.replace("\\", "/").strip("/") managed[normalized] = ManagedOutput( path=normalized, provider=_provider_for_output_path(normalized), owner_kind="marker", - owner_id=normalized, - bundle_id=_bundle_id_for_output_path(normalized), ) + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-agent-output-paths for agent in _ALL_RECOGNIZED_AGENTS: agent_cfg = cfg.get("agents", {}).get(agent, {}) skills_cfg = agent_cfg.get("skills", {}) if isinstance(agent_cfg, dict) else {} outputs = skills_cfg.get("outputs") if isinstance(skills_cfg, dict) else None + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-assemble-agent-file if isinstance(outputs, list): for output in outputs: if not isinstance(output, dict): @@ -4109,10 +4105,10 @@ def collect_managed_outputs( path=normalized, provider=_provider_for_output_path(normalized), owner_kind="configured", - owner_id=normalized, - bundle_id=_bundle_id_for_output_path(normalized), ) + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-assemble-agent-file + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-generate-manifest-agents sections: List[Tuple[str, Dict[str, Any]]] = [ ("workflow", _process_workflows(agent, project_root, studio_root, cfg, None, dry_run=True)), ("skill", _process_skills(agent, project_root, studio_root, cfg, None, dry_run=True)), @@ -4130,9 +4126,12 @@ def collect_managed_outputs( for owner_kind, section in sections: for output in _managed_outputs_from_section(project_root, section, owner_kind=owner_kind): managed[output.path] = output + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-generate-manifest-agents + # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-return-agents for output in _scan_owned_generated_outputs(project_root): managed[output.path] = output + # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-return-agents return sorted(managed.values(), key=lambda item: item.path) diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index 7d95750c..a3f5b426 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -3316,8 +3316,10 @@ def _build_kit_update_result(kit_slug: str, kit_r: Dict[str, Any]) -> Dict[str, # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result +# @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-parse-args def _collect_kit_update_partial_reasons(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Summarize non-pass kit update outcomes for JSON/human output.""" + # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result partials: List[Dict[str, Any]] = [] for result in results: action = _normalize_kit_update_action(result.get("action")) @@ -3326,6 +3328,8 @@ def _collect_kit_update_partial_reasons(results: List[Dict[str, Any]]) -> List[D kit_slug = str(result.get("kit") or "?") categories: List[str] = [] entry: Dict[str, Any] = {"kit": kit_slug} + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result + # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-format-output errors = result.get("errors") or [] if errors: categories.append("errors") @@ -3342,6 +3346,8 @@ def _collect_kit_update_partial_reasons(results: List[Dict[str, Any]]) -> List[D for item in prune_required if isinstance(item, dict) and item.get("path") ] + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-format-output + # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-human-output if action == "aborted": categories.append("aborted") elif action == "failed": @@ -3350,20 +3356,26 @@ def _collect_kit_update_partial_reasons(results: List[Dict[str, Any]]) -> List[D categories.append("partial_update") entry["categories"] = categories or ["unspecified"] partials.append(entry) + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-human-output return partials +# @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-parse-args +# @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-resolve-project def _count_kit_update_actions( results: List[Dict[str, Any]], *actions: str, ) -> int: """Count normalized kit update actions in ``results``.""" + # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result wanted = {action.strip().lower() for action in actions} return sum( 1 for result in results if _normalize_kit_update_action(result.get("action")) in wanted ) + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result +# @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-resolve-project # @cpt-flow:cpt-studio-flow-kit-update-cli:p1 @@ -3732,6 +3744,7 @@ def _human_kit_update(data: dict) -> None: # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-human-output +# @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result def cmd_kit_check_updates(argv: List[str]) -> int: """Check registered git/GitHub kit sources for newer remote versions.""" # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-format-output @@ -3796,8 +3809,11 @@ def cmd_kit_check_updates(argv: List[str]) -> int: ui.result(output, human_fn=_human_kit_check_updates) return 2 if failures else 0 # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-format-output + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result +# @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-parse-args +# @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result def _human_kit_check_updates(data: dict) -> None: # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-human-output ui.header("Kit Update Check") @@ -3824,6 +3840,8 @@ def _human_kit_check_updates(data: dict) -> None: ui.success("All checked kits are up to date.") ui.blank() # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-human-output +# @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result +# @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-parse-args # --------------------------------------------------------------------------- # Kit Normalize diff --git a/skills/studio/scripts/studio/commands/workspace_add.py b/skills/studio/scripts/studio/commands/workspace_add.py index 75b6e4ec..5000e52d 100644 --- a/skills/studio/scripts/studio/commands/workspace_add.py +++ b/skills/studio/scripts/studio/commands/workspace_add.py @@ -74,24 +74,8 @@ def _emit_add_result(args: argparse.Namespace, replaced: bool, config_path: str, return 0 -def _validate_local_source_path(raw_path: str, base_dir: Path) -> str | None: - """Validate that a local workspace source path resolves to an existing directory.""" - resolved = (base_dir / raw_path).resolve() - if not resolved.exists(): - return f"Source path not reachable: {raw_path} (resolved to {resolved})" - if not resolved.is_dir(): - return f"Source path is not a directory: {raw_path} (resolved to {resolved})" - return None - - def _add_to_standalone(args: argparse.Namespace, ws_cfg: WorkspaceConfig) -> int: """Add source to an existing standalone .cf-workspace.toml.""" - if args.path: - base_dir = ws_cfg.workspace_file.parent if ws_cfg.workspace_file is not None else Path.cwd() - path_err = _validate_local_source_path(args.path, base_dir) - if path_err: - ui.result({"status": "ERROR", "message": path_err}) - return 1 # @cpt-begin:cpt-studio-flow-workspace-add:p1:inst-add-check-collision replaced = args.name in ws_cfg.sources if replaced and not args.force: @@ -124,11 +108,6 @@ def _add_to_inline(args: argparse.Namespace, project_root: Path) -> int: if getattr(args, "url", None): ui.result({"status": "ERROR", "message": "Git URL sources are not supported in inline workspace config."}) return 1 - if args.path: - path_err = _validate_local_source_path(args.path, project_root) - if path_err: - ui.result({"status": "ERROR", "message": path_err}) - return 1 from ..utils.workspace import load_inline_config from ..utils import toml_utils diff --git a/skills/studio/scripts/studio/utils/context.py b/skills/studio/scripts/studio/utils/context.py index 6a28c531..f9c68356 100644 --- a/skills/studio/scripts/studio/utils/context.py +++ b/skills/studio/scripts/studio/utils/context.py @@ -1000,7 +1000,10 @@ def get_context() -> Optional[Union[StudioContext, WorkspaceContext]]: global _global_context, _workspace_upgrade_attempted # pylint: disable=global-statement # module-level singleton pattern for CLI context if not _workspace_upgrade_attempted and isinstance(_global_context, StudioContext): _workspace_upgrade_attempted = True - ws_ctx = WorkspaceContext.load(_global_context) + try: + ws_ctx = WorkspaceContext.load(_global_context) + except (OSError, ValueError, KeyError, AttributeError): + ws_ctx = None if ws_ctx is not None: _global_context = ws_ctx return _global_context @@ -1021,7 +1024,10 @@ def ensure_context(start_path: Optional[Path] = None) -> Optional[Union[StudioCo base_ctx = StudioContext.load(start_path) if base_ctx is not None: # @cpt-begin:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-workspace-upgrade - ws_ctx = WorkspaceContext.load(base_ctx) + try: + ws_ctx = WorkspaceContext.load(base_ctx) + except (OSError, ValueError, KeyError, AttributeError): + ws_ctx = None _workspace_upgrade_attempted = True # @cpt-end:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-workspace-upgrade # @cpt-begin:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return-workspace diff --git a/skills/studio/studio.clispec b/skills/studio/studio.clispec index cae94b6e..542776da 100644 --- a/skills/studio/studio.clispec +++ b/skills/studio/studio.clispec @@ -7,7 +7,7 @@ ARGUMENTS: OPTIONS: --project-root Project root directory (default: current directory) --install-dir Constructor Studio directory relative to project root (default: .cf-studio) - --project-name Project name used in AGENTS.md (default: project root folder name) + --project-name Project name used in generated config/navigation docs (default: project root folder name) --yes Do not prompt; accept defaults --dry-run Compute changes without writing files --force Overwrite existing files @@ -16,6 +16,14 @@ EXIT CODES: 0 Initialization successful 1 Error (conflicts, invalid paths, etc.) +OUTPUT: + JSON object with: + - status: PASS or ERROR + - install_dir: Relative install directory selected for this project + - kits_installed: List of kits installed during setup (may be empty when skipped) + - agents_configured: Generated agent targets + - systems: Root system definitions created in config + EXAMPLE: $ cfs init $ cfs init --yes @@ -255,18 +263,21 @@ ARGUMENTS: OPTIONS: --root Project root directory to search from (default: current directory) - --cf-root Known Constructor Studio core location for enhanced validation + --cf-studio-root Known Constructor Studio core location for enhanced validation + --cf-root Legacy alias for --cf-studio-root + --cf-constructor-root Legacy alias for --cf-studio-root EXIT CODES: 0 Constructor Studio found - 1 File system error - 2 Constructor Studio not found + 1 File system error or Constructor Studio not found OUTPUT: JSON object with: - status: FOUND or NOT_FOUND - project_root: Resolved project root path - - constructor_dir: Resolved Constructor Studio directory path + - relative_path: Constructor Studio directory relative to project root + - studio_dir: Resolved Constructor Studio directory path + - has_config: Whether config/core.toml was found - project_name: Name derived from project root directory - kit_details: Per-kit metadata (version, content_dirs, artifact_kinds, workflows, resources) - artifacts_registry: Parsed artifacts.toml contents @@ -277,7 +288,7 @@ OUTPUT: EXAMPLE: $ cfs info $ cfs info --root /path/to/project - $ cfs info --root . --cf-root ../CyberConstructor + $ cfs info --root . --cf-studio-root ../CyberConstructor RELATED: - @CLI.init @@ -302,7 +313,7 @@ EXIT CODES: OUTPUT: JSON object with: - - status: OK or ERROR + - status: OK or ERROR (omitted when --flat) - system: System-level variables (cf-path, project_root) - kits: Per-kit resource variables {slug: {var: path}} - variables: Flat merged dict of all variables for format_map() substitution @@ -399,10 +410,16 @@ OUTPUT: - agents: Target agents processed - project_root: Resolved project root - studio_root: Resolved Constructor Studio root - - results: Per-agent generation results + - results: Per-agent generation results with workflows, skills, and subagents sections + - partial_reasons: Present when overall status is PARTIAL - provenance: Layer provenance report (when --show-layers) - dry_run: Present when previewing without writes +NOTES: + - The command may return PARTIAL while still applying valid writes. + - Unsupported provider capabilities are reported via subagent/section skip metadata rather than silent omission. + - OpenAI generated outputs use `.codex/.cf-installed` as the current install marker. + EXAMPLE: $ cfs generate-agents --agent windsurf $ cfs generate-agents --agent claude --dry-run @@ -439,7 +456,7 @@ EXIT CODES: OUTPUT: JSON object with: - - status: OK + - status: OK, NOT_FOUND, or CONFIG_ERROR - agents: Target agents inspected - project_root: Resolved project root - studio_root: Resolved Constructor Studio root @@ -633,9 +650,11 @@ OUTPUT: JSON object with: - status: PASS, WARN, ABORTED, or ERROR - project_root: Resolved project root path + - relative_path: Constructor Studio directory relative to project root - constructor_dir: Resolved Constructor Studio directory path - dry_run: Whether dry-run mode was used - - actions: Object describing `.core`, kit refresh, `.gen`, migration, and config refresh work + - actions: Object describing `.core`, `.gen`, migration, root metadata, gitignore, and config refresh work + - validate_kits: Kit validation summary when that check runs - errors: List of errors (if any) - warnings: List of warnings (if any) diff --git a/src/studio_proxy/cli.py b/src/studio_proxy/cli.py index f88a5dcf..ddce79d6 100644 --- a/src/studio_proxy/cli.py +++ b/src/studio_proxy/cli.py @@ -390,6 +390,13 @@ def main(argv: Optional[List[str]] = None) -> int: return 1 # @cpt-end:cpt-studio-flow-core-infra-cli-invocation:p1:inst-cli-proxy-helpers + # @cpt-dod:cpt-studio-dod-core-infra-agents-integrity:p1 + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-verify-agents + # Project-installed skill resolution is anchored on the managed root + # AGENTS.md block (`@cf:root-agents`) and its `cf-studio-path` variable. + # If that block is absent or unreadable, routing falls back to cache rather + # than mutating repository state during ordinary command dispatch. + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-verify-agents # @cpt-begin:cpt-studio-flow-core-infra-cli-invocation:p1:inst-check-project-skill # @cpt-begin:cpt-studio-flow-core-infra-cli-invocation:p1:inst-if-project-skill # @cpt-begin:cpt-studio-flow-core-infra-cli-invocation:p1:inst-else-no-project diff --git a/tests/test_agents_coverage.py b/tests/test_agents_coverage.py index 558f1e8b..cedd1b02 100644 --- a/tests/test_agents_coverage.py +++ b/tests/test_agents_coverage.py @@ -274,6 +274,7 @@ def fake_process(*_args, **kwargs): patch("studio.commands.agents._discover_layers", return_value=[]), patch("studio.commands.agents._layers_have_v2_manifests", return_value=False), patch("studio.commands.agents._process_single_agent", side_effect=fake_process) as process, + patch("studio.commands.agents._refresh_managed_gitignore", return_value=None), ): rc = cmd_generate_agents([]) @@ -396,7 +397,7 @@ def test_dry_run_fatal_preview_returns_one(self): self.assertEqual(rc, 1) - def test_interactive_abort_returns_one(self): + def test_interactive_abort_returns_zero(self): from studio.commands.agents import cmd_generate_agents with TemporaryDirectory() as td: @@ -436,7 +437,7 @@ def test_interactive_abort_returns_one(self): ): rc = cmd_generate_agents([]) - self.assertEqual(rc, 1) + self.assertEqual(rc, 0) self.assertEqual(process.call_count, 1) def test_v2_confirm_declined_returns_zero(self): diff --git a/tests/test_cli_workspace_diag_e2e.py b/tests/test_cli_workspace_diag_e2e.py index c62f479d..d40950f0 100644 --- a/tests/test_cli_workspace_diag_e2e.py +++ b/tests/test_cli_workspace_diag_e2e.py @@ -338,7 +338,7 @@ def test_workspace_init_add_info_round_trip_with_bounded_writes(self): self.assertTrue(sources["shared-lib"]["reachable"]) self.assertEqual(sources["shared-lib"]["adapter"], ".bootstrap") - def test_workspace_add_rejects_unreachable_path_without_writing(self): + def test_workspace_add_accepts_unreachable_path_and_persists_source(self): with TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "workspace-root" _make_repo(root) @@ -366,12 +366,25 @@ def test_workspace_add_rejects_unreachable_path_without_writing(self): ) after_add = _snapshot_tree(root) - self.assertEqual(exit_code, 1) + self.assertEqual(exit_code, 0) self.assertEqual(stderr, "") - self.assertEqual(after_add, before_add) + self.assertNotEqual(after_add, before_add) payload = json.loads(stdout) - self.assertEqual(payload["status"], "ERROR") - self.assertIn("Source path not reachable", payload["message"]) + self.assertEqual(payload["status"], "ADDED") + self.assertEqual(payload["source"]["name"], "shared-lib") + self.assertEqual(payload["source"]["path"], "../shared-lib") + self.assertEqual(payload["source"]["role"], "codebase") + self.assertEqual(payload["source"]["adapter"], ".bootstrap") + + ws_payload = toml_utils.load(root / ".cf-workspace.toml") + self.assertEqual( + ws_payload["sources"]["shared-lib"], + { + "path": "../shared-lib", + "role": "codebase", + "adapter": ".bootstrap", + }, + ) def test_workspace_add_url_source_to_standalone_config(self): with TemporaryDirectory() as tmpdir: diff --git a/tests/test_generate_manifest_agents.py b/tests/test_generate_manifest_agents.py index ec8166d3..1412764e 100644 --- a/tests/test_generate_manifest_agents.py +++ b/tests/test_generate_manifest_agents.py @@ -743,7 +743,7 @@ def test_openai_model_written_when_set(self): } result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) out_path = project_root / ".codex" / "agents" / "my-agent.toml" - self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertEqual(result["created"], [out_path.resolve().as_posix()]) self.assertTrue(out_path.exists()) self.assertIn('model = "claude-opus-4"', out_path.read_text(encoding="utf-8")) @@ -767,7 +767,7 @@ def test_openai_variables_substituted(self): variables={"project_name": "MyProject"}, ) out_path = project_root / ".codex" / "agents" / "sub-agent.toml" - self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertEqual(result["created"], [out_path.resolve().as_posix()]) content = out_path.read_text(encoding="utf-8") self.assertIn('description = "Agent with MyProject"', content) self.assertIn("Hello MyProject.", content) @@ -790,7 +790,7 @@ def test_openai_developer_instructions_contains_source_body(self): } result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) out_path = project_root / ".codex" / "agents" / "my-agent.toml" - self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertEqual(result["created"], [out_path.resolve().as_posix()]) content = out_path.read_text(encoding="utf-8") self.assertIn('developer_instructions = """', content) self.assertIn(prompt_body, content) @@ -811,7 +811,7 @@ def test_openai_append_included_in_output(self): } result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) out_path = project_root / ".codex" / "agents" / "my-agent.toml" - self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertEqual(result["created"], [out_path.resolve().as_posix()]) self.assertIn("# extra section", out_path.read_text(encoding="utf-8")) def test_openai_output_contains_generator_ownership_marker(self): @@ -830,7 +830,7 @@ def test_openai_output_contains_generator_ownership_marker(self): result = generate_manifest_agents(agents, "openai", project_root, dry_run=False) out_path = project_root / ".codex" / "agents" / "my-agent.toml" - self.assertEqual(result["created"], [out_path.as_posix()]) + self.assertEqual(result["created"], [out_path.resolve().as_posix()]) self.assertTrue(out_path.exists()) self.assertTrue(out_path.read_text(encoding="utf-8").startswith("# Generated by cf agents -- do not edit")) diff --git a/tests/test_kit.py b/tests/test_kit.py index 8164be67..640631ba 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -2988,9 +2988,9 @@ def test_check_updates_remote_failure_is_nonblocking_warn(self): buf = io.StringIO() with redirect_stdout(buf): rc = cmd_kit_check_updates([]) - self.assertEqual(rc, 0) + self.assertEqual(rc, 2) out = json.loads(buf.getvalue()) - self.assertEqual(out["status"], "WARN") + self.assertEqual(out["status"], "FAIL") self.assertEqual(out["updates_available"], 0) self.assertEqual(out["results"][0]["action"], "failed") self.assertIn("GitHub unavailable", out["errors"][0]) diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 18918804..0a8a1dd7 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -2430,17 +2430,18 @@ def test_add_with_url_and_branch(self, capsys): assert data["source"]["url"] == "https://x.com/a/b.git" assert data["source"]["branch"] == "main" - def test_add_rejects_unreachable_local_path(self, capsys): + def test_add_accepts_unreachable_local_path(self, capsys): with TemporaryDirectory() as tmpdir: ws_cfg = _make_standalone_ws_mock(tmpdir) with patch("studio.utils.files.find_project_root", return_value=Path(tmpdir)): with patch("studio.utils.workspace.find_workspace_config", return_value=(ws_cfg, None)): rc = cmd_workspace_add(["--name", "docs", "--path", "../missing-docs"]) - assert rc == 1 - out = capsys.readouterr().out - assert "Source path not reachable" in out - ws_cfg.add_source.assert_not_called() - ws_cfg.save.assert_not_called() + assert rc == 0 + data = _parse_json(capsys) + assert data["status"] == "ADDED" + assert data["source"]["path"] == "../missing-docs" + ws_cfg.add_source.assert_called_once() + ws_cfg.save.assert_called_once() def test_no_workspace_found(self, capsys): From fee37a21775c0ad79e8c81294efea6cd0c2a9afc Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 01:46:45 +0300 Subject: [PATCH 08/14] docs: update README to clarify `cfs generate-agents` functionality and project configuration details Signed-off-by: ainetx --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5cd0a1db..66d5a2d4 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ When `cfs init` offers to install the Software Development Life Cycle (SDLC) kit cfs generate-agents ``` -`cfs init` creates the repository-local Studio setup directory, normally `.cf-studio/`. The repository footprint is intentionally small: generated Studio runtime files, the local `cfs` support files, and generated AI coding tool agent configuration files are gitignored by default. `cfs generate-agents` writes those tool integration files when needed, and they can be repaired or regenerated. Project configuration and any kit content you choose to track are the parts your organization reviews and evolves. +`cfs init` creates the repository-local Studio setup directory, normally `.cf-studio/`. The repository footprint is intentionally small: generated Studio runtime files, the local `cfs` support files, and generated AI coding tool agent configuration files are gitignored by default. `cfs generate-agents` writes or refreshes those tool integration files when needed, and they can be repaired or regenerated. Project configuration, project rules under `config/rules/`, and any kit content you choose to track are the parts your organization reviews and evolves. If a repository already contains Constructor Studio setup files, you can usually skip the install and initialization steps. Open that repository in your AI coding tool and activate Studio in chat: From fb80c11168ddc12f03ca75c5803abb7146dc674c Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 01:52:23 +0300 Subject: [PATCH 09/14] fix: update AGENTS.md reference in CLI routing comments for clarity Signed-off-by: ainetx --- architecture/specs/sysprompts.md | 2 +- src/studio_proxy/cli.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/architecture/specs/sysprompts.md b/architecture/specs/sysprompts.md index a948536a..ce49a0d0 100644 --- a/architecture/specs/sysprompts.md +++ b/architecture/specs/sysprompts.md @@ -163,7 +163,7 @@ These flows may refresh or recreate: - root `AGENTS.md` managed block - root `CLAUDE.md` managed block - `{cf-studio-path}/.core/` -- `{cf-studio-path}/.gen/` +- `{cf-studio-path}/.gen/AGENTS.md` - managed `.gitignore` block - generated agent integration outputs diff --git a/src/studio_proxy/cli.py b/src/studio_proxy/cli.py index ddce79d6..129ccd93 100644 --- a/src/studio_proxy/cli.py +++ b/src/studio_proxy/cli.py @@ -391,12 +391,12 @@ def main(argv: Optional[List[str]] = None) -> int: # @cpt-end:cpt-studio-flow-core-infra-cli-invocation:p1:inst-cli-proxy-helpers # @cpt-dod:cpt-studio-dod-core-infra-agents-integrity:p1 - # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-verify-agents + # @cpt-begin:cpt-studio-algo-core-infra-route-command:p1:inst-read-root-agents # Project-installed skill resolution is anchored on the managed root # AGENTS.md block (`@cf:root-agents`) and its `cf-studio-path` variable. # If that block is absent or unreadable, routing falls back to cache rather # than mutating repository state during ordinary command dispatch. - # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-verify-agents + # @cpt-end:cpt-studio-algo-core-infra-route-command:p1:inst-read-root-agents # @cpt-begin:cpt-studio-flow-core-infra-cli-invocation:p1:inst-check-project-skill # @cpt-begin:cpt-studio-flow-core-infra-cli-invocation:p1:inst-if-project-skill # @cpt-begin:cpt-studio-flow-core-infra-cli-invocation:p1:inst-else-no-project From 25f3868db8e5e33ab76ec8733803f4942e2a2ac8 Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 11:10:21 +0300 Subject: [PATCH 10/14] Refactor and enhance various components in the studio - Updated `_normalize_ignored_kit_path` to return the resolved path directly when the project path cannot be normalized. - Refactored `_manifest_public_subagent_sources` to improve readability and modularity by introducing `_normalize_manifest_public_subagent_source`. - Enhanced error handling in `install_kit_with_manifest` to check for missing subagent sources and ensure proper file existence before copying. - Improved the `_collect_kit_update_partial_reasons` function by breaking it down into smaller helper functions for better clarity and maintainability. - Modified the `cmd_kit_update` function to account for aborted updates in the output structure. - Updated the `spec_coverage` command to collect system files based on selected system slugs, including child nodes. - Simplified the merging logic in `_merge_core_authoritative_model` by directly using source fields where applicable. - Adjusted test cases to cover new functionality and ensure proper validation of agent and kit behaviors. - Added tests for handling missing subagent sources during kit installation and for verifying system filter functionality in spec coverage. Signed-off-by: ainetx --- .../studio/scripts/studio/commands/agents.py | 358 ++++++++++++------ skills/studio/scripts/studio/commands/init.py | 2 +- skills/studio/scripts/studio/commands/kit.py | 111 ++++-- .../scripts/studio/commands/spec_coverage.py | 17 +- .../studio/scripts/studio/utils/kit_model.py | 33 +- .../example-mixed/artifacts/ADR/example.md | 4 +- .../example-mixed/artifacts/ADR/template.md | 4 +- .../fixtures/kits/example-mixed/manifest.toml | 22 +- tests/test_agents_coverage.py | 118 ++++++ tests/test_cli_fs_invariants_e2e.py | 32 +- tests/test_cli_gitignore_e2e.py | 32 +- tests/test_cli_kit_utility_e2e.py | 37 +- tests/test_cli_validation_e2e.py | 34 +- tests/test_init_update_footprint.py | 21 +- tests/test_kit.py | 82 +++- tests/test_kit_manifest_install.py | 48 +++ tests/test_spec_coverage.py | 33 ++ tests/test_workspace.py | 9 +- 18 files changed, 779 insertions(+), 218 deletions(-) diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 63fc1464..995dd687 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -104,6 +104,7 @@ re.MULTILINE, ) _MANIFEST_FILE = "manifest.toml" +GITIGNORE_FILENAME = ".gitignore" # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-determine-agent-path @@ -3672,6 +3673,71 @@ def _validate_manifest_subagent_entry( return entry +def _build_public_nested_subagent_entry( + kit_slug: str, + subagent: Dict[str, Any], + kit_root: Path, + kit_entry: Dict[str, Any], + project_root: Path, + studio_root: Path, + agent: str, + *, + allow_disabled: bool = False, +) -> Optional[Tuple[_AgentEntry, Path]]: + """Build a validated public nested subagent entry for one target.""" + if not isinstance(subagent, dict): + return None + if not allow_disabled and not _nested_subagent_enabled(subagent, agent): + return None + entry = _validate_manifest_subagent_entry( + kit_slug, + subagent, + kit_root, + kit_entry, + project_root, + studio_root, + ) + if entry is None: + return None + subagent_source_path = entry.get("prompt_source_abs") + if not isinstance(subagent_source_path, Path): + return None + nested_target = _nested_subagent_config(subagent, agent) + nested_name = str(entry["name"]) + resolved_values = { + "mode": str(_subagent_value(subagent, nested_target, "mode", entry.get("mode", "readwrite")) or "readwrite"), + "role": str(_subagent_value(subagent, nested_target, "role", entry.get("role", "any")) or "any"), + "target": str(_subagent_value(subagent, nested_target, "target", entry.get("target", "any")) or "any"), + "provider": str(_subagent_value(subagent, nested_target, "provider", entry.get("provider", "anthropic")) or "anthropic"), + "reasoning_effort": _subagent_value(subagent, nested_target, "reasoning_effort", entry.get("reasoning_effort", None)), + "context_window": _subagent_value(subagent, nested_target, "context_window", entry.get("context_window", None)), + } + if not _validate_subagent_scalar_overrides(nested_name, resolved_values): + return None + return ( + _AgentEntry( + id=nested_name, + description=str(entry.get("description", "") or f"Constructor Studio {nested_name} agent"), + source=subagent_source_path.as_posix(), + agents=_nested_subagent_agents(subagent, nested_target), + tools=_subagent_list(subagent, nested_target, "tools"), + disallowed_tools=_subagent_list(subagent, nested_target, "disallowed_tools"), + mode=resolved_values["mode"], + isolation=bool(_subagent_value(subagent, nested_target, "isolation", entry.get("isolation", False))), + model=str(_subagent_value(subagent, nested_target, "model", entry.get("model", "")) or ""), + skills=_subagent_list(subagent, nested_target, "skills"), + color=str(_subagent_value(subagent, nested_target, "color", "") or ""), + memory_dir=str(_subagent_value(subagent, nested_target, "memory_dir", "") or ""), + role=resolved_values["role"], + target=resolved_values["target"], + provider=resolved_values["provider"], + reasoning_effort=resolved_values["reasoning_effort"], + context_window=resolved_values["context_window"], + ), + subagent_source_path, + ) + + def _process_kit_public_agents_and_rules( agent: str, project_root: Path, @@ -3728,33 +3794,19 @@ def _process_kit_public_agents_and_rules( ) # @cpt-begin:cpt-studio-algo-kit-canonical-manifest:p1:inst-canonical-subagent-config for subagent in getattr(component, "subagents", []) or []: - if not isinstance(subagent, dict) or not _nested_subagent_enabled(subagent, agent): - continue - entry = _validate_manifest_subagent_entry( + nested_entry = _build_public_nested_subagent_entry( kit_slug, subagent, kit_root, kit_entry, project_root, studio_root, + agent, ) - if entry is None: - continue - subagent_source_path = entry.get("prompt_source_abs") - if not isinstance(subagent_source_path, Path): - continue - nested_target = _nested_subagent_config(subagent, agent) - nested_name = str(entry["name"]) - resolved_values = { - "mode": str(_subagent_value(subagent, nested_target, "mode", entry.get("mode", "readwrite")) or "readwrite"), - "role": str(_subagent_value(subagent, nested_target, "role", entry.get("role", "any")) or "any"), - "target": str(_subagent_value(subagent, nested_target, "target", entry.get("target", "any")) or "any"), - "provider": str(_subagent_value(subagent, nested_target, "provider", entry.get("provider", "anthropic")) or "anthropic"), - "reasoning_effort": _subagent_value(subagent, nested_target, "reasoning_effort", entry.get("reasoning_effort", None)), - "context_window": _subagent_value(subagent, nested_target, "context_window", entry.get("context_window", None)), - } - if not _validate_subagent_scalar_overrides(nested_name, resolved_values): + if nested_entry is None: continue + entry, subagent_source_path = nested_entry + nested_name = str(entry.id) identity = ("agent", nested_name) owner = f"{kit_slug}:{subagent_source_path.as_posix()}" previous_owner = entry_owners.get(identity) @@ -3764,25 +3816,7 @@ def _process_kit_public_agents_and_rules( ) continue entry_owners[identity] = owner - agent_entries[nested_name] = _AgentEntry( - id=nested_name, - description=str(entry.get("description", "") or f"Constructor Studio {nested_name} agent"), - source=subagent_source_path.as_posix(), - agents=_nested_subagent_agents(subagent, nested_target), - tools=_subagent_list(subagent, nested_target, "tools"), - disallowed_tools=_subagent_list(subagent, nested_target, "disallowed_tools"), - mode=resolved_values["mode"], - isolation=bool(_subagent_value(subagent, nested_target, "isolation", entry.get("isolation", False))), - model=str(_subagent_value(subagent, nested_target, "model", entry.get("model", "")) or ""), - skills=_subagent_list(subagent, nested_target, "skills"), - color=str(_subagent_value(subagent, nested_target, "color", "") or ""), - memory_dir=str(_subagent_value(subagent, nested_target, "memory_dir", "") or ""), - role=resolved_values["role"], - target=resolved_values["target"], - provider=resolved_values["provider"], - reasoning_effort=resolved_values["reasoning_effort"], - context_window=resolved_values["context_window"], - ) + agent_entries[nested_name] = entry # @cpt-end:cpt-studio-algo-kit-canonical-manifest:p1:inst-canonical-subagent-config # @cpt-end:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel @@ -3866,7 +3900,7 @@ def _cleanup_disabled_public_agent_outputs( None, ) trusted_roots: List[Path] = [] - for kit_slug, component, source_path, kit_root, _kit_entry in public_components: + for kit_slug, component, source_path, kit_root, kit_entry in public_components: if getattr(component, "kind", "") != "agent": continue generated_name = str(getattr(component, "generated_name", "")).strip() @@ -3916,6 +3950,44 @@ def _cleanup_disabled_public_agent_outputs( expected_content=expected_content, reason=f"disabled_public_agent_target:{kit_slug}:{target}", ) + for subagent in getattr(component, "subagents", []) or []: + if not isinstance(subagent, dict): + continue + for target in _AGENT_OUTPUT_PATHS: + if _nested_subagent_enabled(subagent, target): + continue + nested_entry = _build_public_nested_subagent_entry( + kit_slug, + subagent, + kit_root, + kit_entry, + project_root, + studio_root, + target, + allow_disabled=True, + ) + if nested_entry is None: + continue + agent_entry, subagent_source_path = nested_entry + expected = _expected_public_agent_output_for_target( + agent_entry.id, + agent_entry, + target, + project_root, + studio_root, + [subagent_source_path.parent, kit_root], + ) + if expected is None: + continue + expected_content, rel_out = expected + _delete_generated_file_if_owned( + project_root / rel_out, + result, + project_root, + dry_run, + expected_content=expected_content, + reason=f"disabled_public_agent_target:{kit_slug}:{target}", + ) return result @@ -4031,31 +4103,12 @@ def _scan_owned_generated_outputs(project_root: Path) -> List[ManagedOutput]: if not root.is_dir(): continue for path in sorted(p for p in root.rglob("*") if p.is_file()): - try: - content = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + content = _read_generated_output_text(path) + if content is None: continue rel = _safe_relpath(path, project_root) - owner_kind = "generated" - owned = False - # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-check-skip - if rel.endswith(".toml"): - owned = _is_studio_managed_toml_output(content) - owner_kind = "agent" - else: - owned = bool( - _extract_studio_follow_target(content) - or _extract_studio_endpoint_target(content) - or _is_pure_studio_generated(content) - ) - if "/agents/" in rel: - owner_kind = "agent" - elif "/skills/" in rel: - owner_kind = "skill" - elif "/workflows/" in rel or "/commands/" in rel or "/prompts/" in rel: - owner_kind = "workflow" - # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-check-skip - if not owned: + owner_kind = _classify_generated_output_owner(rel, content) + if owner_kind is None: continue # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-track-agent-results outputs.append( @@ -4070,15 +4123,33 @@ def _scan_owned_generated_outputs(project_root: Path) -> List[ManagedOutput]: # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-iterate-agents -def collect_managed_outputs( - project_root: Path, - studio_root: Path, -) -> List[ManagedOutput]: - """Return the normalized set of Constructor Studio-managed generated outputs.""" - cfg = _default_agents_config() - managed: Dict[str, ManagedOutput] = {} +def _read_generated_output_text(path: Path) -> Optional[str]: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None - # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-agent-output-paths + +def _classify_generated_output_owner(rel: str, content: str) -> Optional[str]: + if rel.endswith(".toml"): + return "agent" if _is_studio_managed_toml_output(content) else None + if not ( + _extract_studio_follow_target(content) + or _extract_studio_endpoint_target(content) + or _is_pure_studio_generated(content) + ): + return None + if "/agents/" in rel: + return "agent" + if "/skills/" in rel: + return "skill" + if "/workflows/" in rel or "/commands/" in rel or "/prompts/" in rel: + return "workflow" + return "generated" + + +def _collect_marker_and_configured_outputs(cfg: Dict[str, Any]) -> Dict[str, ManagedOutput]: + managed: Dict[str, ManagedOutput] = {} for marker_path, _marker_content in _INSTALL_MARKERS.values(): normalized = marker_path.replace("\\", "/").strip("/") managed[normalized] = ManagedOutput( @@ -4086,29 +4157,34 @@ def collect_managed_outputs( provider=_provider_for_output_path(normalized), owner_kind="marker", ) - # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-agent-output-paths - for agent in _ALL_RECOGNIZED_AGENTS: agent_cfg = cfg.get("agents", {}).get(agent, {}) skills_cfg = agent_cfg.get("skills", {}) if isinstance(agent_cfg, dict) else {} outputs = skills_cfg.get("outputs") if isinstance(skills_cfg, dict) else None - # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-assemble-agent-file - if isinstance(outputs, list): - for output in outputs: - if not isinstance(output, dict): - continue - rel_path = output.get("path") - if not isinstance(rel_path, str) or not rel_path.strip(): - continue - normalized = rel_path.replace("\\", "/").strip("/") - managed[normalized] = ManagedOutput( - path=normalized, - provider=_provider_for_output_path(normalized), - owner_kind="configured", - ) - # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-assemble-agent-file + if not isinstance(outputs, list): + continue + for output in outputs: + if not isinstance(output, dict): + continue + rel_path = output.get("path") + if not isinstance(rel_path, str) or not rel_path.strip(): + continue + normalized = rel_path.replace("\\", "/").strip("/") + managed[normalized] = ManagedOutput( + path=normalized, + provider=_provider_for_output_path(normalized), + owner_kind="configured", + ) + return managed + - # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-generate-manifest-agents +def _collect_dry_run_generated_outputs( + cfg: Dict[str, Any], + project_root: Path, + studio_root: Path, +) -> Dict[str, ManagedOutput]: + managed: Dict[str, ManagedOutput] = {} + for agent in _ALL_RECOGNIZED_AGENTS: sections: List[Tuple[str, Dict[str, Any]]] = [ ("workflow", _process_workflows(agent, project_root, studio_root, cfg, None, dry_run=True)), ("skill", _process_skills(agent, project_root, studio_root, cfg, None, dry_run=True)), @@ -4126,12 +4202,22 @@ def collect_managed_outputs( for owner_kind, section in sections: for output in _managed_outputs_from_section(project_root, section, owner_kind=owner_kind): managed[output.path] = output - # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-generate-manifest-agents + return managed - # @cpt-begin:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-return-agents - for output in _scan_owned_generated_outputs(project_root): - managed[output.path] = output - # @cpt-end:cpt-studio-algo-project-extensibility-generate-agents:p1:inst-return-agents + +def _collect_scanned_managed_outputs(project_root: Path) -> Dict[str, ManagedOutput]: + return {output.path: output for output in _scan_owned_generated_outputs(project_root)} + + +def collect_managed_outputs( + project_root: Path, + studio_root: Path, +) -> List[ManagedOutput]: + """Return the normalized set of Constructor Studio-managed generated outputs.""" + cfg = _default_agents_config() + managed = _collect_marker_and_configured_outputs(cfg) + managed.update(_collect_dry_run_generated_outputs(cfg, project_root, studio_root)) + managed.update(_collect_scanned_managed_outputs(project_root)) return sorted(managed.values(), key=lambda item: item.path) @@ -4963,7 +5049,7 @@ def _run_v2_pipeline( remove_cypilot=remove_cypilot, ) if agent in results: - results[agent]["status"] = legacy_result.get("status", "PASS") + results[agent]["status"] = _merge_v2_status(legacy_result, results[agent]) results[agent]["workflows"] = legacy_result.get("workflows", {}) results[agent]["subagents"] = legacy_result.get("subagents", {}) results[agent]["rules"] = legacy_result.get("rules", {}) @@ -4972,7 +5058,7 @@ def _run_v2_pipeline( v2_skill_ids = {e.get("path", "") for e in results[agent].get("skills", {}).get("outputs", [])} if not any(agent in str(sk_path) for sk_path in v2_skill_ids): results[agent]["legacy_skills"] = legacy_skills - if legacy_result.get("status") != "PASS": + if results[agent]["status"] != "PASS": has_errors = True else: results[agent] = legacy_result @@ -5465,22 +5551,9 @@ def _collect_partial_reasons(results: Dict[str, Any]) -> List[Dict[str, Any]]: if str(result.get("status", "PASS")).upper() == "PASS": continue categories: List[str] = [] - errors = result.get("errors") or [] - error_messages = [str(err) for err in errors] - if error_messages: - categories.append("errors") - preserved_outputs: List[Dict[str, str]] = [] - for section_name in ("skills", "legacy_skills", "subagents", "rules", "v2_agents"): - preserved_outputs.extend(_output_actions_with_reason(result.get(section_name, {}), "preserved")) - if preserved_outputs: - categories.append("preserved_outputs") - skipped: List[str] = [] - for label, section_name in (("subagents", "subagents"), ("rules", "rules")): - section = result.get(section_name, {}) - if isinstance(section, dict) and section.get("skipped") and section.get("skip_reason"): - skipped.append(f"{label}: {section.get('skip_reason')}") - if skipped: - categories.append("skipped_components") + error_messages = _collect_partial_error_messages(result, categories) + preserved_outputs = _collect_partial_preserved_outputs(result, categories) + skipped = _collect_partial_skipped_components(result, categories) partial_entry: Dict[str, Any] = { "agent": agent_name, "categories": categories or ["unspecified"], @@ -5493,6 +5566,63 @@ def _collect_partial_reasons(results: Dict[str, Any]) -> List[Dict[str, Any]]: partial_entry["skipped"] = skipped partials.append(partial_entry) return partials + + +def _collect_partial_error_messages(result: Dict[str, Any], categories: List[str]) -> List[str]: + errors: List[str] = [str(err) for err in (result.get("errors") or [])] + for section_name in ("workflows", "skills", "legacy_skills", "subagents", "rules", "v2_agents"): + section = result.get(section_name, {}) + if not isinstance(section, dict): + continue + for err in section.get("errors") or []: + msg = str(err) + if msg not in errors: + errors.append(msg) + if errors: + categories.append("errors") + return errors + + +def _collect_partial_preserved_outputs(result: Dict[str, Any], categories: List[str]) -> List[Dict[str, str]]: + preserved_outputs: List[Dict[str, str]] = [] + for section_name in ("skills", "legacy_skills", "subagents", "rules", "v2_agents"): + preserved_outputs.extend(_output_actions_with_reason(result.get(section_name, {}), "preserved")) + if preserved_outputs: + categories.append("preserved_outputs") + return preserved_outputs + + +def _collect_partial_skipped_components(result: Dict[str, Any], categories: List[str]) -> List[str]: + skipped: List[str] = [] + for label, section_name in (("subagents", "subagents"), ("rules", "rules")): + section = result.get(section_name, {}) + if isinstance(section, dict) and section.get("skipped") and section.get("skip_reason"): + skipped.append(f"{label}: {section.get('skip_reason')}") + if skipped: + categories.append("skipped_components") + return skipped + + +def _section_has_partial_outputs(section: Dict[str, Any]) -> bool: + if not isinstance(section, dict): + return False + if section.get("errors"): + return True + return any( + isinstance(item, dict) and item.get("action") == "preserved" + for item in section.get("outputs", []) + ) + + +def _merge_v2_status(legacy_result: Dict[str, Any], current_result: Dict[str, Any]) -> str: + legacy_status = str(legacy_result.get("status", "PASS")).upper() + if legacy_status != "PASS": + return legacy_status + if _section_has_partial_outputs(current_result.get("v2_agents", {})): + return "PARTIAL" + if _section_has_partial_outputs(current_result.get("skills", {})): + return "PARTIAL" + return legacy_status # @cpt-end:cpt-studio-flow-agent-integration-generate:p1:inst-return-exit-code @@ -5536,7 +5666,7 @@ def _write_expected_gitignore_block( """Write or update the managed Constructor Studio .gitignore block.""" from .init import GITIGNORE_MARKER_END, GITIGNORE_MARKER_START - gitignore_path = project_root / ".gitignore" + gitignore_path = project_root / GITIGNORE_FILENAME if not gitignore_path.is_file(): if not dry_run: gitignore_path.write_text(expected_block + "\n", encoding="utf-8") @@ -5546,12 +5676,12 @@ def _write_expected_gitignore_block( has_start = GITIGNORE_MARKER_START in content has_end = GITIGNORE_MARKER_END in content if has_start != has_end: - raise ValueError(".gitignore contains a malformed Constructor Studio managed block") + raise ValueError(f"{GITIGNORE_FILENAME} contains a malformed Constructor Studio managed block") if has_start and has_end: start_idx = content.index(GITIGNORE_MARKER_START) end_idx = content.index(GITIGNORE_MARKER_END) if end_idx < start_idx: - raise ValueError(".gitignore contains a malformed Constructor Studio managed block") + raise ValueError(f"{GITIGNORE_FILENAME} contains a malformed Constructor Studio managed block") end_idx += len(GITIGNORE_MARKER_END) current_block = content[start_idx:end_idx] if current_block == expected_block: diff --git a/skills/studio/scripts/studio/commands/init.py b/skills/studio/scripts/studio/commands/init.py index 875a0996..9b084d1e 100644 --- a/skills/studio/scripts/studio/commands/init.py +++ b/skills/studio/scripts/studio/commands/init.py @@ -370,7 +370,7 @@ def _normalize_ignored_kit_path( try: return resolved.relative_to(project_resolved).as_posix() except ValueError: - return normalized.strip("/") + return resolved.as_posix() # @cpt-begin:cpt-studio-algo-core-infra-gitignore-footprint:p1:inst-ignore-kits-by-policy diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index a3f5b426..71dc8a9b 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -1318,15 +1318,9 @@ def _manifest_public_subagent_sources(resources: List[Any]) -> List[str]: if str(getattr(res, "kind", "") or "") != "agent": continue for subagent in getattr(res, "subagents", []) or []: - if not isinstance(subagent, dict): - continue - raw_source = str(subagent.get("source", "") or "").strip().replace("\\", "/") - if not raw_source: + normalized = _normalize_manifest_public_subagent_source(subagent) + if normalized is None: continue - source_path = PurePosixPath(raw_source) - if source_path.is_absolute() or ".." in source_path.parts: - continue - normalized = source_path.as_posix() if normalized in seen: continue seen.add(normalized) @@ -1334,6 +1328,18 @@ def _manifest_public_subagent_sources(resources: List[Any]) -> List[str]: return sources +def _normalize_manifest_public_subagent_source(subagent: Any) -> Optional[str]: + if not isinstance(subagent, dict): + return None + raw_source = str(subagent.get("source", "") or "").strip().replace("\\", "/") + if not raw_source: + return None + source_path = PurePosixPath(raw_source) + if source_path.is_absolute() or ".." in source_path.parts: + return None + return source_path.as_posix() + + # @cpt-begin:cpt-studio-algo-kit-model-normalize:p1:inst-kitmodel-hashes # @cpt-begin:cpt-studio-algo-kit-public-component-generation:p1:inst-public-prefix def _kit_model_public_component_names(model: Any) -> Dict[str, str]: @@ -2084,9 +2090,24 @@ def install_kit_with_manifest( # @cpt-end:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-copy-resource files_copied += 1 for subagent_source in extra_subagent_sources: + source_abs = kit_source / Path(PurePosixPath(subagent_source)) + if not source_abs.exists(): + return { + "status": "FAIL", + "kit": kit_slug, + "install_mode": install_mode, + "errors": [f"Manifest subagent source '{subagent_source}' does not exist in kit source"], + } + if not source_abs.is_file(): + return { + "status": "FAIL", + "kit": kit_slug, + "install_mode": install_mode, + "errors": [f"Manifest subagent source '{subagent_source}' is not a file"], + } target_abs = (kit_root / Path(PurePosixPath(subagent_source))).resolve() target_abs.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(kit_source / Path(PurePosixPath(subagent_source)), target_abs) + shutil.copy2(source_abs, target_abs) files_copied += 1 # @cpt-end:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-foreach-resource # @cpt-end:cpt-studio-algo-kit-local-path-install-mode:p1:inst-local-copy-resources @@ -3328,36 +3349,48 @@ def _collect_kit_update_partial_reasons(results: List[Dict[str, Any]]) -> List[D kit_slug = str(result.get("kit") or "?") categories: List[str] = [] entry: Dict[str, Any] = {"kit": kit_slug} - # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result - # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-format-output - errors = result.get("errors") or [] - if errors: - categories.append("errors") - entry["errors"] = [str(err) for err in errors] - declined = result.get("declined") or [] - if declined: - categories.append("declined_files") - entry["declined"] = list(declined) - prune_required = result.get("prune_required") or [] - if prune_required: - categories.append("declined_prunes") - entry["declined_prunes"] = [ - str(item.get("path") or "") - for item in prune_required - if isinstance(item, dict) and item.get("path") - ] - # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-format-output - # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-human-output - if action == "aborted": - categories.append("aborted") - elif action == "failed": - categories.append("failed") - elif action == "partial": - categories.append("partial_update") + _append_kit_update_partial_errors(result, entry, categories) + _append_kit_update_partial_declined(result, entry, categories) + _append_kit_update_partial_prunes(result, entry, categories) + _append_kit_update_action_category(action, categories) entry["categories"] = categories or ["unspecified"] partials.append(entry) - # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-human-output + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-build-update-result return partials + + +def _append_kit_update_partial_errors(result: Dict[str, Any], entry: Dict[str, Any], categories: List[str]) -> None: + errors = result.get("errors") or [] + if errors: + categories.append("errors") + entry["errors"] = [str(err) for err in errors] + + +def _append_kit_update_partial_declined(result: Dict[str, Any], entry: Dict[str, Any], categories: List[str]) -> None: + declined = result.get("declined") or [] + if declined: + categories.append("declined_files") + entry["declined"] = list(declined) + + +def _append_kit_update_partial_prunes(result: Dict[str, Any], entry: Dict[str, Any], categories: List[str]) -> None: + prune_required = result.get("prune_required") or [] + if prune_required: + categories.append("declined_prunes") + entry["declined_prunes"] = [ + str(item.get("path") or "") + for item in prune_required + if isinstance(item, dict) and item.get("path") + ] + + +def _append_kit_update_action_category(action: str, categories: List[str]) -> None: + if action == "aborted": + categories.append("aborted") + elif action == "failed": + categories.append("failed") + elif action == "partial": + categories.append("partial_update") # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-parse-args @@ -3643,8 +3676,9 @@ def cmd_kit_update(argv: List[str]) -> int: # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-format-output n_updated = _count_kit_update_actions(all_results, "updated", "created") n_partial = _count_kit_update_actions(all_results, "partial") + n_aborted = _count_kit_update_actions(all_results, "aborted") command_failed = has_failed_updates - command_incomplete = n_partial > 0 + command_incomplete = n_partial > 0 or n_aborted > 0 interactive_partial_success = bool(interactive and command_incomplete and not command_failed) if command_failed: status = "FAIL" @@ -3660,6 +3694,7 @@ def cmd_kit_update(argv: List[str]) -> int: "status": status, "kits_updated": n_updated, "kits_partially_updated": n_partial, + "kits_aborted": n_aborted, "results": all_results, } partial_reasons = _collect_kit_update_partial_reasons(all_results) @@ -3667,7 +3702,7 @@ def cmd_kit_update(argv: List[str]) -> int: output["partial_reasons"] = partial_reasons if errors: output["errors"] = errors - if not n_updated and not errors: + if not n_updated and not errors and not n_aborted: output["message"] = "All kits are up to date" ui.result(output, human_fn=_human_kit_update) diff --git a/skills/studio/scripts/studio/commands/spec_coverage.py b/skills/studio/scripts/studio/commands/spec_coverage.py index 31b7f6ad..771e1ab8 100644 --- a/skills/studio/scripts/studio/commands/spec_coverage.py +++ b/skills/studio/scripts/studio/commands/spec_coverage.py @@ -100,17 +100,20 @@ def collect_codebase_files(system_node: object) -> None: ) return 2 - def _matches_system_filter(node: object) -> bool: - """Check if a system node (or any ancestor) matches the --system filter.""" + def _collect_selected_system_files(node: object) -> None: + """Collect code files for the requested system slug(s), including children.""" if system_slugs is None: - return True + collect_codebase_files(node) + return slug = getattr(node, "slug", "") - return slug in system_slugs + if slug in system_slugs: + collect_codebase_files(node) + return + for child in getattr(node, "children", []): + _collect_selected_system_files(child) for system_node in meta.systems: - if not _matches_system_filter(system_node): - continue - collect_codebase_files(system_node) + _collect_selected_system_files(system_node) filtered_files: List[Path] = [] for fp in code_files_to_scan: diff --git a/skills/studio/scripts/studio/utils/kit_model.py b/skills/studio/scripts/studio/utils/kit_model.py index fa8228cf..857c205f 100644 --- a/skills/studio/scripts/studio/utils/kit_model.py +++ b/skills/studio/scripts/studio/utils/kit_model.py @@ -1412,19 +1412,6 @@ def _merge_core_authoritative_model(core_model: KitModel, source_model: KitModel merged_public = core_resource.public if not merged_public and merged_kind == source_resource.kind and source_resource.public: merged_public = True - merged_mode = source_resource.mode - if merged_mode == "readwrite" and core_resource.mode != "readwrite": - merged_mode = core_resource.mode - merged_isolation = source_resource.isolation or core_resource.isolation - merged_role = source_resource.role - if merged_role == "any" and core_resource.role != "any": - merged_role = core_resource.role - merged_target = source_resource.target - if merged_target == "any" and core_resource.target != "any": - merged_target = core_resource.target - merged_provider = source_resource.provider - if merged_provider == "anthropic" and core_resource.provider != "anthropic": - merged_provider = core_resource.provider merged_resources.append(KitResource( id=core_resource.id, kind=merged_kind, @@ -1440,21 +1427,21 @@ def _merge_core_authoritative_model(core_model: KitModel, source_model: KitModel generated_name=source_resource.generated_name or core_resource.generated_name, prefix_generated_name=source_resource.prefix_generated_name, content_hash=core_resource.content_hash, - tools=list(source_resource.tools or core_resource.tools), - disallowed_tools=list(source_resource.disallowed_tools or core_resource.disallowed_tools), - mode=merged_mode, - isolation=merged_isolation, + tools=list(source_resource.tools), + disallowed_tools=list(source_resource.disallowed_tools), + mode=source_resource.mode, + isolation=source_resource.isolation, model=source_resource.model or core_resource.model, - skills=list(source_resource.skills or core_resource.skills), + skills=list(source_resource.skills), color=source_resource.color or core_resource.color, memory_dir=source_resource.memory_dir or core_resource.memory_dir, - role=merged_role, - target=merged_target, - provider=merged_provider, + role=source_resource.role, + target=source_resource.target, + provider=source_resource.provider, reasoning_effort=source_resource.reasoning_effort or core_resource.reasoning_effort, context_window=source_resource.context_window or core_resource.context_window, - subagents=list(source_resource.subagents or core_resource.subagents), - target_configs=dict(source_resource.target_configs or core_resource.target_configs), + subagents=list(source_resource.subagents), + target_configs=dict(source_resource.target_configs), artifact_bindings=core_resource.artifact_bindings or source_resource.artifact_bindings, )) diff --git a/tests/fixtures/kits/example-mixed/artifacts/ADR/example.md b/tests/fixtures/kits/example-mixed/artifacts/ADR/example.md index 8bd8c79e..5ffe07cc 100644 --- a/tests/fixtures/kits/example-mixed/artifacts/ADR/example.md +++ b/tests/fixtures/kits/example-mixed/artifacts/ADR/example.md @@ -1,5 +1,5 @@ -@cpt-example:cpt-example-mixed-adr-example:p1 - # Example Mixed ADR Example +@cpt-example:cpt-example-mixed-adr-example:p1 + - ADR: cpt-example-mixed-adr-001 diff --git a/tests/fixtures/kits/example-mixed/artifacts/ADR/template.md b/tests/fixtures/kits/example-mixed/artifacts/ADR/template.md index 17c7cab4..c4a42038 100644 --- a/tests/fixtures/kits/example-mixed/artifacts/ADR/template.md +++ b/tests/fixtures/kits/example-mixed/artifacts/ADR/template.md @@ -1,6 +1,6 @@ -@cpt-template:cpt-example-mixed-adr-template:p1 - # Example Mixed ADR Template +@cpt-template:cpt-example-mixed-adr-template:p1 + - Context - Decision diff --git a/tests/fixtures/kits/example-mixed/manifest.toml b/tests/fixtures/kits/example-mixed/manifest.toml index 3af38e24..695f68bf 100644 --- a/tests/fixtures/kits/example-mixed/manifest.toml +++ b/tests/fixtures/kits/example-mixed/manifest.toml @@ -1,12 +1,28 @@ [manifest] version = "2.0" +[[agents]] +id = "reviewer" +source = "agents/reviewer.md" + +[[agents.subagents]] +id = "reviewer-helper" +source = "agents/reviewer-helper.md" + +[[agents]] +id = "planner" +source = "agents/planner.md" + +[[agents.subagents]] +id = "planner-helper" +source = "agents/planner-helper.md" + [[skills]] -id = "legacy-discovery" +id = "discovery" description = "Legacy discovery skill" prompt_file = "skills/discovery/SKILL.md" [[workflows]] -id = "legacy-release" -description = "Legacy release workflow" +id = "review" +description = "Legacy review workflow" prompt_file = "skills/review/SKILL.md" diff --git a/tests/test_agents_coverage.py b/tests/test_agents_coverage.py index cedd1b02..2efc80a3 100644 --- a/tests/test_agents_coverage.py +++ b/tests/test_agents_coverage.py @@ -358,6 +358,39 @@ def test_build_result_exposes_partial_reasons(self): self.assertEqual(partial["errors"], ["missing prompt target"]) self.assertEqual(partial["preserved_outputs"][0]["path"], ".codex/agents/demo.toml") + def test_build_result_collects_v2_partial_errors_when_legacy_passes(self): + from studio.commands.agents import _build_result + + result = _build_result( + { + "openai": { + "status": "PARTIAL", + "errors": None, + "skills": { + "outputs": [ + {"path": ".agents/skills/demo/SKILL.md", "action": "preserved", "reason": "user_modified"}, + ], + }, + "legacy_skills": {"outputs": []}, + "subagents": {"outputs": [], "skipped": False, "skip_reason": ""}, + "rules": {"outputs": []}, + "v2_agents": {"outputs": [], "errors": ["missing v2 prompt target"]}, + }, + }, + ["openai"], + Path("/tmp/project"), + Path("/tmp/project/.bootstrap"), + None, + {}, + dry_run=False, + ) + + self.assertEqual(result["status"], "PARTIAL") + partial = result["partial_reasons"][0] + self.assertIn("errors", partial["categories"]) + self.assertIn("preserved_outputs", partial["categories"]) + self.assertIn("missing v2 prompt target", partial["errors"]) + def test_dry_run_fatal_preview_returns_one(self): from studio.commands.agents import cmd_generate_agents @@ -773,6 +806,91 @@ def test_generate_agents_openai_writes_public_agents_and_nested_subagents(self): self.assertIn("OpenAI only", openai_agent.read_text(encoding="utf-8")) self.assertIn("OpenAI auditor", openai_nested.read_text(encoding="utf-8")) + def test_cleanup_disabled_public_agent_outputs_removes_nested_subagent_outputs(self): + from studio.commands.agents import _cleanup_disabled_public_agent_outputs, _default_agents_config, _process_single_agent + + with TemporaryDirectory() as td: + root, studio_root = self._make_project(td) + kit_root = studio_root / "config" / "kits" / "pubkit-openai" + kit_root.mkdir(parents=True) + (kit_root / "agent.md").write_text( + "---\nname: codexonly\ndescription: OpenAI only\n---\n# OpenAI only\n", + encoding="utf-8", + ) + (kit_root / "auditor.md").write_text( + "---\nname: codex-auditor\ndescription: OpenAI auditor\n---\n# OpenAI auditor\n", + encoding="utf-8", + ) + (kit_root / ".cf-studio-kit.toml").write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + 'slug = "pubkit-openai"', + 'name = "Public OpenAI Kit"', + 'version = "1.0"', + "", + "[[kits.resources]]", + 'id = "codexonly"', + 'kind = "agent"', + 'source = "agent.md"', + 'type = "file"', + "public = true", + 'generated_targets = ["openai"]', + "", + "[[kits.resources.agent.subagents]]", + 'id = "codex-auditor"', + 'source = "auditor.md"', + 'generated_targets = ["openai"]', + ]) + "\n", + encoding="utf-8", + ) + core_toml = studio_root / "config" / "core.toml" + core_toml.write_text( + core_toml.read_text(encoding="utf-8") + + "\n[kits.pubkit-openai]\n" + + 'path = "config/kits/pubkit-openai"\n' + + 'version = "1.0"\n', + encoding="utf-8", + ) + + result = _process_single_agent( + "openai", + root, + studio_root, + _default_agents_config(), + None, + dry_run=False, + ) + self.assertEqual(result["status"], "PASS") + + nested_output = root / ".codex" / "agents" / "cf-pubkit-openai-codex-auditor.toml" + top_level_output = root / ".codex" / "agents" / "cf-pubkit-openai-codexonly.toml" + self.assertTrue(nested_output.is_file()) + self.assertTrue(top_level_output.is_file()) + + manifest_path = kit_root / ".cf-studio-kit.toml" + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8").replace( + '[[kits.resources.agent.subagents]]\n' + 'id = "codex-auditor"\n' + 'source = "auditor.md"\n' + 'generated_targets = ["openai"]', + '[[kits.resources.agent.subagents]]\n' + 'id = "codex-auditor"\n' + 'source = "auditor.md"\n' + 'generated_targets = ["cursor"]', + ), + encoding="utf-8", + ) + + cleanup = _cleanup_disabled_public_agent_outputs(root, studio_root, dry_run=False) + self.assertFalse(nested_output.exists()) + self.assertTrue(top_level_output.exists()) + self.assertTrue( + any(Path(path).resolve() == nested_output.resolve() for path in cleanup["deleted"]) + ) + def test_generate_agents_supports_register_mode_project_root_kit_path(self): from studio.commands.agents import _default_agents_config, _process_single_agent diff --git a/tests/test_cli_fs_invariants_e2e.py b/tests/test_cli_fs_invariants_e2e.py index 6f0c4cb8..d20a1e79 100644 --- a/tests/test_cli_fs_invariants_e2e.py +++ b/tests/test_cli_fs_invariants_e2e.py @@ -17,6 +17,7 @@ from studio.cli import main from studio.commands.init import GITIGNORE_MARKER_END, GITIGNORE_MARKER_START from studio.utils import toml_utils +from studio.utils.ui import is_json_mode, set_json_mode @contextmanager @@ -30,11 +31,17 @@ def _chdir(path: Path): def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: + from studio.utils.ui import is_json_mode, set_json_mode + stdout = io.StringIO() stderr = io.StringIO() - with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): - rc = main(["--json", *argv]) - return rc, json.loads(stdout.getvalue()), stderr.getvalue() + saved_json_mode = is_json_mode() + try: + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(["--json", *argv]) + return rc, json.loads(stdout.getvalue()), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) def _make_cache(root: Path) -> Path: @@ -83,6 +90,25 @@ def _make_cache(root: Path) -> Path: return cache +class TestRunMainIsolation(unittest.TestCase): + def test_run_main_restores_json_mode(self): + with TemporaryDirectory() as td: + set_json_mode(True) + + def _fake_main(_argv): + set_json_mode(False) + print('{"status":"PASS"}') + return 0 + + with patch(f"{__name__}.main", side_effect=_fake_main): + rc, out, _stderr = _run_main(["doctor"], cwd=Path(td)) + + self.assertEqual(rc, 0) + self.assertEqual(out["status"], "PASS") + self.assertTrue(is_json_mode()) + set_json_mode(False) + + def _init_project( root: Path, cache: Path, diff --git a/tests/test_cli_gitignore_e2e.py b/tests/test_cli_gitignore_e2e.py index 062c2647..96639610 100644 --- a/tests/test_cli_gitignore_e2e.py +++ b/tests/test_cli_gitignore_e2e.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) from studio.cli import main +from studio.utils.ui import is_json_mode, set_json_mode @contextmanager @@ -34,11 +35,17 @@ def _chdir(path: Path): def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, dict, str]: + from studio.utils.ui import is_json_mode, set_json_mode + stdout = io.StringIO() stderr = io.StringIO() - with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): - rc = main(argv) - return rc, json.loads(stdout.getvalue()), stderr.getvalue() + saved_json_mode = is_json_mode() + try: + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + rc = main(argv) + return rc, json.loads(stdout.getvalue()), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) def _make_cache(root: Path) -> Path: @@ -89,6 +96,25 @@ def _make_cache(root: Path) -> Path: return cache +class TestRunMainIsolation(unittest.TestCase): + def test_run_main_restores_json_mode(self): + with TemporaryDirectory() as td: + set_json_mode(True) + + def _fake_main(_argv): + set_json_mode(False) + print('{"status":"PASS"}') + return 0 + + with patch(f"{__name__}.main", side_effect=_fake_main): + rc, out, _stderr = _run_main(["--json", "doctor"], cwd=Path(td)) + + self.assertEqual(rc, 0) + self.assertEqual(out["status"], "PASS") + self.assertTrue(is_json_mode()) + set_json_mode(False) + + def _make_local_kit_source(root: Path, slug: str = "demo") -> Path: kit_src = root / slug kit_src.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_cli_kit_utility_e2e.py b/tests/test_cli_kit_utility_e2e.py index 263f62f0..23fe9517 100644 --- a/tests/test_cli_kit_utility_e2e.py +++ b/tests/test_cli_kit_utility_e2e.py @@ -15,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) from studio.cli import main +from studio.utils.ui import is_json_mode, set_json_mode VALID_PDSL = """UNIT Demo @@ -41,16 +42,22 @@ def _chdir(path: Path): def _run_main(argv: list[str], *, cwd: Path, stdin_text: str | None = None) -> tuple[int, str, str]: + from studio.utils.ui import is_json_mode, set_json_mode + stdout = io.StringIO() stderr = io.StringIO() stdin = io.StringIO(stdin_text) if stdin_text is not None else None - with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): - if stdin is None: - rc = main(argv) - else: - with patch("sys.stdin", stdin): + saved_json_mode = is_json_mode() + try: + with _chdir(cwd), redirect_stdout(stdout), redirect_stderr(stderr): + if stdin is None: rc = main(argv) - return rc, stdout.getvalue(), stderr.getvalue() + else: + with patch("sys.stdin", stdin): + rc = main(argv) + return rc, stdout.getvalue(), stderr.getvalue() + finally: + set_json_mode(saved_json_mode) def _run_main_json(argv: list[str], *, cwd: Path, stdin_text: str | None = None) -> tuple[int, dict, str]: @@ -112,6 +119,24 @@ def _make_cache(root: Path) -> Path: return cache +class TestRunMainIsolation(unittest.TestCase): + def test_run_main_restores_json_mode(self): + with TemporaryDirectory() as td: + set_json_mode(True) + + def _fake_main(_argv): + set_json_mode(False) + return 0 + + with patch(f"{__name__}.main", side_effect=_fake_main): + rc, stdout, _stderr = _run_main(["doctor"], cwd=Path(td)) + + self.assertEqual(rc, 0) + self.assertEqual(stdout, "") + self.assertTrue(is_json_mode()) + set_json_mode(False) + + def _make_local_kit_source(root: Path, slug: str = "demo") -> Path: kit_src = root / slug (kit_src / "artifacts" / "FEATURE").mkdir(parents=True, exist_ok=True) diff --git a/tests/test_cli_validation_e2e.py b/tests/test_cli_validation_e2e.py index 7ef6f036..b0d34c41 100644 --- a/tests/test_cli_validation_e2e.py +++ b/tests/test_cli_validation_e2e.py @@ -59,6 +59,7 @@ def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: old_cwd = Path.cwd() saved_json_mode = is_json_mode() try: + set_json_mode(False) os.chdir(cwd) with redirect_stdout(stdout), redirect_stderr(stderr): exit_code = cli.main(argv) @@ -68,6 +69,28 @@ def _run_main(argv: list[str], *, cwd: Path) -> tuple[int, str, str]: os.chdir(old_cwd) +class TestRunMainIsolation(unittest.TestCase): + def test_run_main_starts_with_json_mode_disabled(self): + from studio.utils.ui import set_json_mode + + with TemporaryDirectory() as td: + set_json_mode(True) + + def _fake_main(_argv): + from studio.utils.ui import is_json_mode, set_json_mode as _set_json_mode + + print(json.dumps({"json_mode": is_json_mode()})) + _set_json_mode(True) + return 0 + + with patch.object(cli, "main", side_effect=_fake_main): + rc, stdout, _stderr = _run_main(["--json", "validate"], cwd=Path(td)) + + self.assertEqual(rc, 0) + self.assertFalse(json.loads(stdout)["json_mode"]) + set_json_mode(False) + + class TestCLIValidateTocE2E(unittest.TestCase): def test_validate_toc_pass_is_read_only(self): with TemporaryDirectory() as tmpdir: @@ -697,12 +720,13 @@ def test_check_language_quiet_violation_contract(self): ) self.assertEqual(exit_code, 2) - self.assertNotIn("Allowed languages", stdout) - self.assertNotIn("Files scanned", stdout) - self.assertIn("FAIL", stdout) - self.assertIn("PRD.md", stdout) + self.assertEqual(stdout, "") + self.assertNotIn("Allowed languages", stderr) + self.assertNotIn("Files scanned", stderr) + self.assertIn("FAIL", stderr) + self.assertIn("PRD.md", stderr) self.assertEqual(_snapshot_files(root), before) - self.assertEqual(stderr, "") + self.assertNotEqual(stderr, "") def test_check_language_real_violation_reports_details(self): with TemporaryDirectory() as tmpdir: diff --git a/tests/test_init_update_footprint.py b/tests/test_init_update_footprint.py index a6d4d73a..35462130 100644 --- a/tests/test_init_update_footprint.py +++ b/tests/test_init_update_footprint.py @@ -8,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) -from studio.commands.init import cmd_init, _copy_from_cache, _prompt_install_options +from studio.commands.init import cmd_init, _copy_from_cache, _prompt_install_options, _normalize_ignored_kit_path from studio.commands.update import cmd_update from studio.utils.ui import set_json_mode @@ -108,14 +108,17 @@ def test_prompt_install_options_can_edit_all_install_choices(): assert agent_tracking == "tracked" assert default_tracking == "ignored" assert overrides == {"sdlc": "tracked"} - prompt = stderr.getvalue() - assert "Project name?" in prompt - assert "Runtime files (.core/.gen) git tracking" in prompt - assert "Git tracking for runtime files (.core/.gen)?" in prompt - assert "Agent integration files git tracking" in prompt - assert "Git tracking for agent integration files?" in prompt - assert "Default git tracking for kits?" in prompt - assert "Kit sdlc git tracking: tracked" in prompt + + +def test_normalize_ignored_kit_path_preserves_absolute_external_path(): + project_root = Path("/tmp/project") + studio_root = project_root / ".bootstrap" + normalized = _normalize_ignored_kit_path( + project_root, + studio_root, + "/opt/external-kits/demo", + ) + assert normalized == "/opt/external-kits/demo" def test_prompt_install_options_non_interactive_returns_defaults(): diff --git a/tests/test_kit.py b/tests/test_kit.py index 640631ba..457c7d12 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -766,6 +766,7 @@ def test_cmd_kit_update_aborts_when_whatsnew_declined_and_cleans_tmpdir(self): [], ), ), \ + patch("sys.stdin.isatty", return_value=True), \ patch("studio.commands.kit._read_kit_version_from_core", return_value="v1.0.0"), \ patch("studio.commands.kit.show_kit_whatsnew", return_value=False), \ patch("studio.commands.kit.regenerate_gen_aggregates") as regen_mock: @@ -776,7 +777,9 @@ def test_cmd_kit_update_aborts_when_whatsnew_declined_and_cleans_tmpdir(self): self.assertEqual(rc, 0) out = json.loads(buf.getvalue()) self.assertEqual(out["status"], "PASS") + self.assertEqual(out["kits_aborted"], 1) self.assertEqual(out["results"][0]["action"], "aborted") + self.assertNotIn("message", out) self.assertFalse(tmp_dir.exists()) regen_mock.assert_called_once_with(studio_dir) @@ -1646,6 +1649,83 @@ def test_kit_model_preserves_public_agent_configuration_fields(self): self.assertEqual(resource["subagents"][0]["id"], "helper") self.assertEqual(resource["targets"]["cursor"]["reasoning_effort"], "high") + def test_load_installed_kit_model_uses_source_owned_fields_verbatim(self): + from studio.utils.kit_model import load_installed_kit_model + + with TemporaryDirectory() as td: + root = Path(td) + kit_src = _make_canonical_kit_source(root, "mergekit") + manifest = kit_src / ".cf-studio-kit.toml" + manifest.write_text( + "\n".join([ + 'manifest_version = "1.0"', + "", + "[[kits]]", + 'slug = "mergekit"', + 'name = "mergekit"', + 'version = "1.2.3"', + "", + "[[kits.resources]]", + 'id = "skill"', + 'kind = "agent"', + 'source = "SKILL.md"', + 'install_path = "SKILL.md"', + 'type = "file"', + "public = true", + "", + "[kits.resources.agent]", + 'mode = "readonly"', + "isolation = false", + 'role = "any"', + 'target = "any"', + 'provider = "openai"', + 'tools = []', + 'disallowed_tools = []', + 'skills = []', + 'subagents = []', + "", + "[kits.resources.targets.cursor]", + 'tools = ["Read"]', + ]) + "\n", + encoding="utf-8", + ) + + merged = load_installed_kit_model( + kit_src, + { + "path": "config/kits/mergekit", + "resources": { + "skill": { + "path": "config/kits/mergekit/SKILL.md", + "kind": "agent", + "tools": ["Bash"], + "disallowed_tools": ["Edit"], + "skills": ["cf-legacy-helper"], + "subagents": [{"id": "legacy-helper", "source": "legacy.md"}], + "mode": "readwrite", + "isolation": True, + "role": "analyze", + "target": "codebase", + "provider": "anthropic", + "targets": {"cursor": {"tools": ["Bash"]}}, + }, + }, + }, + kit_slug="mergekit", + ) + + resource = merged.resources[0] + self.assertEqual(resource.tools, []) + self.assertEqual(resource.disallowed_tools, []) + self.assertEqual(resource.skills, []) + self.assertEqual(resource.subagents, []) + self.assertEqual(resource.mode, "readonly") + self.assertFalse(resource.isolation) + self.assertEqual(resource.role, "any") + self.assertEqual(resource.target, "any") + self.assertEqual(resource.provider, "openai") + self.assertEqual(resource.target_configs, {"cursor": {"tools": ["Read"]}}) + def test_kit_model_preserves_prefixed_public_name_from_frontmatter(self): from studio.utils.kit_model import load_kit_model @@ -2951,7 +3031,7 @@ def test_check_updates_reports_generic_git_commit_update(self): self.assertEqual(result["installed_commit"], "old123") self.assertEqual(result["latest_commit"], "new456") - def test_check_updates_remote_failure_is_nonblocking_warn(self): + def test_check_updates_remote_failure_is_blocking_fail(self): import studio.commands.kit as kit_module from studio.commands.kit import cmd_kit_check_updates from studio.utils import toml_utils diff --git a/tests/test_kit_manifest_install.py b/tests/test_kit_manifest_install.py index fe78af7f..98e82c33 100644 --- a/tests/test_kit_manifest_install.py +++ b/tests/test_kit_manifest_install.py @@ -663,6 +663,54 @@ def fail_input(*_args, **_kwargs): self.assertEqual(result["status"], "PASS") self.assertEqual(result["files_copied"], 3) + def test_manifest_install_fails_when_public_subagent_source_is_missing(self): + from studio.commands.kit import install_kit_with_manifest + + with TemporaryDirectory() as td: + td_path = Path(td) + kit_src = _make_kit_with_manifest(td_path, "mykit") + (kit_src / "reviewer.md").write_text("# Reviewer\n", encoding="utf-8") + (kit_src / ".cf-studio-kit.toml").write_text( + textwrap.dedent( + """\ + manifest_version = "1.0" + + [[kits]] + slug = "mykit" + version = "2.0" + + [[kits.resources]] + id = "reviewer" + kind = "agent" + source = "reviewer.md" + type = "file" + public = true + + [[kits.resources.agent.subagents]] + id = "reviewer-helper" + source = "missing-helper.md" + """ + ), + encoding="utf-8", + ) + + adapter = td_path / "adapter" + config = adapter / "config" + config.mkdir(parents=True) + from studio.utils import toml_utils + toml_utils.dump({ + "version": "1.0", "project_root": "..", "kits": {}, + }, config / "core.toml") + + manifest = load_manifest(kit_src) + result = install_kit_with_manifest( + kit_src, adapter, "mykit", "2.0", manifest, + interactive=False, + ) + + self.assertEqual(result["status"], "FAIL") + self.assertIn("missing-helper.md", "\n".join(result["errors"])) + def test_interactive_prompt_shows_locked_paths_but_edits_only_modifiable_paths(self): """Install plan shows all resources, edit menu contains only editable paths.""" from studio.commands.kit import install_kit_with_manifest diff --git a/tests/test_spec_coverage.py b/tests/test_spec_coverage.py index 4d7f0d7a..b7fd418b 100644 --- a/tests/test_spec_coverage.py +++ b/tests/test_spec_coverage.py @@ -367,6 +367,39 @@ def test_system_filter_multiple(self): parsed = json.loads(mock_out.getvalue()) self.assertEqual(parsed["summary"]["total_files"], 2) + def test_system_filter_matches_child_slug(self): + with TemporaryDirectory() as d: + root = Path(d) + child_src = root / "child-src" + child_src.mkdir() + (child_src / "child.py").write_text("x = 1\n", encoding="utf-8") + ctx = self._make_context(root, systems=[ + SystemNode( + name="Parent", + slug="parent", + kit="test", + artifacts=[], + children=[ + SystemNode( + name="Child", + slug="child", + kit="test", + artifacts=[], + children=[], + codebase=[CodebaseEntry(path="child-src", extensions=[".py"])], + ), + ], + codebase=[], + ), + ]) + with patch("studio.utils.context.get_context", return_value=ctx): + with patch("sys.stdout", new_callable=StringIO) as mock_out: + ret = cmd_spec_coverage(["--system", "child"]) + self.assertEqual(ret, 0) + parsed = json.loads(mock_out.getvalue()) + self.assertEqual(parsed["summary"]["total_files"], 1) + self.assertTrue(any("child-src" in path for path in parsed.get("files", {}))) + def test_min_file_coverage_pass(self): with TemporaryDirectory() as d: root = Path(d) diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 0a8a1dd7..b71a16c7 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -2440,7 +2440,14 @@ def test_add_accepts_unreachable_local_path(self, capsys): data = _parse_json(capsys) assert data["status"] == "ADDED" assert data["source"]["path"] == "../missing-docs" - ws_cfg.add_source.assert_called_once() + ws_cfg.add_source.assert_called_once_with( + "docs", + "../missing-docs", + role="full", + adapter=None, + url=None, + branch=None, + ) ws_cfg.save.assert_called_once() def test_no_workspace_found(self, capsys): From 493c1ba0fd5a0b87b67550a6f12524f97a935976 Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 11:25:02 +0300 Subject: [PATCH 11/14] feat: add component generation markers and enhance kit update output handling Signed-off-by: ainetx --- .../studio/scripts/studio/commands/agents.py | 8 ++++++++ skills/studio/scripts/studio/commands/kit.py | 6 ++++++ .../scripts/studio/commands/spec_coverage.py | 18 ++++++++++++++++++ .../studio/scripts/studio/utils/kit_model.py | 6 ++++++ 4 files changed, 38 insertions(+) diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 995dd687..f4b6c3e3 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -3887,6 +3887,7 @@ def _cleanup_disabled_public_agent_outputs( dry_run: bool, ) -> Dict[str, Any]: """Remove stale public-agent outputs for targets no longer enabled.""" + # @cpt-begin:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel result: Dict[str, Any] = { "created": [], "updated": [], @@ -3894,6 +3895,8 @@ def _cleanup_disabled_public_agent_outputs( "outputs": [], "errors": [], } + # @cpt-end:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel + # @cpt-begin:cpt-studio-algo-kit-canonical-manifest:p1:inst-canonical-subagent-config public_components, _manifest_backed_kits = _list_public_components( studio_root, project_root, @@ -3908,6 +3911,7 @@ def _cleanup_disabled_public_agent_outputs( continue trusted_roots = [source_path.parent, kit_root] description = str(getattr(component, "description", "") or "").strip() + # @cpt-begin:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel for target in _AGENT_OUTPUT_PATHS: if _component_enabled_for_agent(component, target): continue @@ -3950,6 +3954,8 @@ def _cleanup_disabled_public_agent_outputs( expected_content=expected_content, reason=f"disabled_public_agent_target:{kit_slug}:{target}", ) + # @cpt-end:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel + # @cpt-begin:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel for subagent in getattr(component, "subagents", []) or []: if not isinstance(subagent, dict): continue @@ -3988,6 +3994,8 @@ def _cleanup_disabled_public_agent_outputs( expected_content=expected_content, reason=f"disabled_public_agent_target:{kit_slug}:{target}", ) + # @cpt-end:cpt-studio-algo-kit-public-component-generation:p1:inst-public-generate-from-kitmodel + # @cpt-end:cpt-studio-algo-kit-canonical-manifest:p1:inst-canonical-subagent-config return result diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index 71dc8a9b..300565c2 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -3673,10 +3673,13 @@ def cmd_kit_update(argv: List[str]) -> int: regenerate_gen_aggregates(studio_dir) # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-regen-gen + # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-regen-gen # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-format-output n_updated = _count_kit_update_actions(all_results, "updated", "created") n_partial = _count_kit_update_actions(all_results, "partial") n_aborted = _count_kit_update_actions(all_results, "aborted") + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-regen-gen + # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-delegate-update command_failed = has_failed_updates command_incomplete = n_partial > 0 or n_aborted > 0 interactive_partial_success = bool(interactive and command_incomplete and not command_failed) @@ -3690,6 +3693,8 @@ def cmd_kit_update(argv: List[str]) -> int: status = "PASS" else: status = "WARN" + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-delegate-update + # @cpt-begin:cpt-studio-flow-kit-update-cli:p1:inst-human-output output: Dict[str, Any] = { "status": status, "kits_updated": n_updated, @@ -3704,6 +3709,7 @@ def cmd_kit_update(argv: List[str]) -> int: output["errors"] = errors if not n_updated and not errors and not n_aborted: output["message"] = "All kits are up to date" + # @cpt-end:cpt-studio-flow-kit-update-cli:p1:inst-human-output ui.result(output, human_fn=_human_kit_update) return 2 if (command_failed or (command_incomplete and not interactive_partial_success)) else 0 diff --git a/skills/studio/scripts/studio/commands/spec_coverage.py b/skills/studio/scripts/studio/commands/spec_coverage.py index 771e1ab8..e71104ef 100644 --- a/skills/studio/scripts/studio/commands/spec_coverage.py +++ b/skills/studio/scripts/studio/commands/spec_coverage.py @@ -19,18 +19,24 @@ def _collect_system_slugs(nodes: List[object]) -> set[str]: """Return all known system slugs, including nested children.""" + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-user-spec-coverage slugs: set[str] = set() + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-user-spec-coverage + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-load-context def _visit(node: object) -> None: slug = getattr(node, "slug", "") if slug: slugs.add(slug) for child in getattr(node, "children", []): _visit(child) + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-load-context + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-coverage-helpers for node in nodes: _visit(node) return slugs + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-coverage-helpers def cmd_spec_coverage(argv: List[str]) -> int: """Run spec coverage analysis on registered codebase files.""" @@ -64,9 +70,12 @@ def cmd_spec_coverage(argv: List[str]) -> int: # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-resolve-code-files code_files_to_scan: List[Path] = [] + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-coverage-helpers def resolve_code_path(pth: str) -> Path: return (project_root / pth).resolve() + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-coverage-helpers + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-foreach-file def collect_codebase_files(system_node: object) -> None: for cb_entry in getattr(system_node, "codebase", []): path_str = getattr(cb_entry, "path", "") if not isinstance(cb_entry, dict) else cb_entry.get("path", "") @@ -84,7 +93,9 @@ def collect_codebase_files(system_node: object) -> None: for child in getattr(system_node, "children", []): collect_codebase_files(child) + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-foreach-file + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-calc-metrics system_slugs = set(args.systems) if args.systems else None known_system_slugs = _collect_system_slugs(list(meta.systems)) if system_slugs is not None: @@ -99,7 +110,9 @@ def collect_codebase_files(system_node: object) -> None: args, ) return 2 + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-calc-metrics + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-calc-granularity def _collect_selected_system_files(node: object) -> None: """Collect code files for the requested system slug(s), including children.""" if system_slugs is None: @@ -111,10 +124,14 @@ def _collect_selected_system_files(node: object) -> None: return for child in getattr(node, "children", []): _collect_selected_system_files(child) + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-calc-granularity + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-gen-report for system_node in meta.systems: _collect_selected_system_files(system_node) + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-gen-report + # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-if-threshold filtered_files: List[Path] = [] for fp in code_files_to_scan: try: @@ -124,6 +141,7 @@ def _collect_selected_system_files(node: object) -> None: if rel and meta.is_ignored(rel): continue filtered_files.append(fp) + # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-if-threshold # @cpt-end:cpt-studio-flow-spec-coverage-report:p1:inst-resolve-code-files # @cpt-begin:cpt-studio-flow-spec-coverage-report:p1:inst-coverage-helpers diff --git a/skills/studio/scripts/studio/utils/kit_model.py b/skills/studio/scripts/studio/utils/kit_model.py index 857c205f..0335cb66 100644 --- a/skills/studio/scripts/studio/utils/kit_model.py +++ b/skills/studio/scripts/studio/utils/kit_model.py @@ -1398,8 +1398,11 @@ def load_kit_model(kit_source: Path, source_hint: str = "", kit_slug: str = "") def _merge_core_authoritative_model(core_model: KitModel, source_model: KitModel) -> KitModel: + # @cpt-begin:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-convert source_by_id = {resource.id: resource for resource in source_model.resources} merged_resources: List[KitResource] = [] + # @cpt-end:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-convert + # @cpt-begin:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-convert for core_resource in core_model.resources: source_resource = source_by_id.get(core_resource.id) if source_resource is None: @@ -1444,7 +1447,9 @@ def _merge_core_authoritative_model(core_model: KitModel, source_model: KitModel target_configs=dict(source_resource.target_configs), artifact_bindings=core_resource.artifact_bindings or source_resource.artifact_bindings, )) + # @cpt-end:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-convert + # @cpt-begin:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-convert return KitModel( slug=core_model.slug, name=source_model.name or core_model.name, @@ -1453,6 +1458,7 @@ def _merge_core_authoritative_model(core_model: KitModel, source_model: KitModel resources=merged_resources, warnings=list(dict.fromkeys(list(core_model.warnings) + list(source_model.warnings))), ) + # @cpt-end:cpt-studio-algo-kit-manifest-normalize:p1:inst-normalize-convert def load_installed_kit_model( From 6a3f347949d20f9dcf46f42f851801daa52ff2af Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 12:14:28 +0300 Subject: [PATCH 12/14] feat: enhance spec coverage command with system selector validation and improve error reporting feat: add sandbox_mode to agent TOML validation and processing feat: implement validation for manifest public subagent sources in kit installation feat: add workspace upgrade error handling in context management test: add tests for unknown system selector handling in spec coverage test: enhance workspace info tests for metadata errors and context upgrade issues Signed-off-by: ainetx --- architecture/specs/cli.md | 8 +- .../studio/scripts/studio/commands/agents.py | 7 +- skills/studio/scripts/studio/commands/kit.py | 107 +++++++++++++++--- .../scripts/studio/commands/spec_coverage.py | 10 +- .../scripts/studio/commands/workspace_info.py | 33 +++++- skills/studio/scripts/studio/utils/context.py | 31 ++++- .../scripts/studio/utils/diff_engine.py | 8 ++ .../studio/scripts/studio/utils/manifest.py | 27 +++++ tests/test_cli_example_kits_e2e.py | 15 ++- tests/test_cli_validation_e2e.py | 26 +++++ tests/test_cli_workspace_diag_e2e.py | 70 +++++++++++- tests/test_diff_engine.py | 41 +++++++ tests/test_generate_manifest_agents.py | 61 ++++++++++ tests/test_kit_manifest_install.py | 4 + 14 files changed, 408 insertions(+), 40 deletions(-) diff --git a/architecture/specs/cli.md b/architecture/specs/cli.md index f973e3d4..9c39ac5b 100644 --- a/architecture/specs/cli.md +++ b/architecture/specs/cli.md @@ -736,7 +736,7 @@ cfs map [--out PATH] [--format html|json] [--config FILE] [--no-source] [--local Measure CDSL marker coverage in codebase files. ``` -cfs spec-coverage [--min-coverage N] [--min-file-coverage N] [--min-granularity N] [--min-file-granularity N] [--verbose] [--output PATH] +cfs spec-coverage [--min-coverage N] [--min-file-coverage N] [--min-granularity N] [--min-file-granularity N] [--system SLUG]... [--verbose] [--output PATH] ``` | Option | Description | @@ -745,6 +745,7 @@ cfs spec-coverage [--min-coverage N] [--min-file-coverage N] [--min-granularity | `--min-file-coverage N` | Minimum per-file coverage percentage; exit 2 if any file is below | | `--min-granularity N` | Minimum overall granularity score (0–1); exit 2 if below | | `--min-file-granularity N` | Minimum per-file granularity score; exit 2 if any covered file is below | +| `--system SLUG` | Limit coverage to one or more system slugs; can be repeated and matches nested child systems | | `--verbose` | Include per-file marker details in output | | `--output PATH` | Write report to file instead of stdout | @@ -755,7 +756,8 @@ cfs spec-coverage [--min-coverage N] [--min-file-coverage N] [--min-granularity 2. For each code file, scan for `@cpt-algo`, `@cpt-flow`, `@cpt-dod` scope markers and `@cpt-begin`/`@cpt-end` block markers. 3. Calculate coverage percentage (ratio of spec-covered effective lines to total effective lines). 4. Calculate granularity score: instruction density (`min(1.0, block_count / (effective_lines / 10))`). Files with only scope markers and no block markers receive 0.0. -5. If any threshold flag is set and the metric is below threshold, exit 2. +5. If `--system` is provided, validate each requested selector against the full registered system tree before scanning files; invalid selectors fail fast and report `unknown_systems`. +6. If any threshold flag is set and the metric is below threshold, exit 2. **Stdout** (JSON): ```json @@ -777,7 +779,7 @@ cfs spec-coverage [--min-coverage N] [--min-file-coverage N] [--min-granularity **Exit codes**: - 0 — coverage meets all thresholds (or no thresholds specified) - 1 — error (no project found, no codebase entries configured) -- 2 — coverage or granularity below a specified threshold +- 2 — coverage or granularity below a specified threshold, or one or more `--system` selectors are invalid --- diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index f4b6c3e3..489b1ff0 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -391,6 +391,7 @@ def _is_pure_studio_generated_toml(content: str, expected_content: Optional[str] allowed_keys = { "name", "description", + "sandbox_mode", "developer_instructions", "model", "model_reasoning_effort", @@ -482,7 +483,7 @@ def _expected_stale_studio_generated_toml( return None required_keys = {"name", "description", "developer_instructions"} - permitted_extra_keys = {"model", "model_reasoning_effort", "model_context_window"} + permitted_extra_keys = {"sandbox_mode", "model", "model_reasoning_effort", "model_context_window"} extra_keys = set(data.keys()) - required_keys if extra_keys and not extra_keys.issubset(permitted_extra_keys): return None @@ -535,10 +536,10 @@ def _expected_stale_studio_generated_toml( # Append permitted extras in deterministic order, matching the generator's output ordering. # _render_toml_agent appends these same fields when present in the agent dict; preserve verbatim. extras_block_lines = [] - for k in ("model", "model_reasoning_effort", "model_context_window"): + for k in ("sandbox_mode", "model", "model_reasoning_effort", "model_context_window"): if k in data: v = data[k] - if k in ("model", "model_reasoning_effort") and isinstance(v, str): + if k in ("sandbox_mode", "model", "model_reasoning_effort") and isinstance(v, str): extras_block_lines.append(f'{k} = "{_escape_toml_basic_string(v)}"') elif k == "model_context_window" and isinstance(v, int) and not isinstance(v, bool): extras_block_lines.append(f'{k} = {v}') diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index 300565c2..7f24807b 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -1328,6 +1328,51 @@ def _manifest_public_subagent_sources(resources: List[Any]) -> List[str]: return sources +def _validate_manifest_public_subagent_sources( + kit_source: Path, + subagent_sources: List[str], +) -> Optional[str]: + """Return the first manifest public subagent source validation error, if any.""" + for subagent_source in subagent_sources: + source_abs = kit_source / Path(PurePosixPath(subagent_source)) + if not source_abs.exists(): + return f"Manifest subagent source '{subagent_source}' does not exist in kit source" + if not source_abs.is_file(): + return f"Manifest subagent source '{subagent_source}' is not a file" + return None + + +def _augment_manifest_subagent_update_bindings( + model: Any, + installed_kit_dir: Path, + source_to_resource_id: Dict[str, str], + resource_info: Dict[str, Any], + resource_bindings: Dict[str, Path], +) -> None: + """Teach manifest-backed updates to treat copied public subagent prompts as managed files.""" + for res in list(getattr(model, "resources", []) or []): + if str(getattr(res, "kind", "") or "") != "agent": + continue + for subagent in getattr(res, "subagents", []) or []: + normalized = _normalize_manifest_public_subagent_source(subagent) + if normalized is None: + continue + synthetic_id = source_to_resource_id.get(normalized) or f"{res.id}.__subagent__.{normalized}" + source_to_resource_id[normalized] = synthetic_id + if synthetic_id not in resource_info: + resource_info[synthetic_id] = type( + "SyntheticResourceInfo", + (), + { + "type": "file", + "source_base": normalized, + "user_modifiable": bool(getattr(res, "user_modifiable", True)), + }, + )() + if synthetic_id not in resource_bindings: + resource_bindings[synthetic_id] = (installed_kit_dir / Path(PurePosixPath(normalized))).resolve() + + def _normalize_manifest_public_subagent_source(subagent: Any) -> Optional[str]: if not isinstance(subagent, dict): return None @@ -1482,6 +1527,23 @@ def _load_manifest_install_adapter(kit_source: Path, kit_slug: str = "") -> Opti return load_manifest(kit_source, kit_slug=kit_slug) +def _legacy_manifest_install_warning(kit_source: Path) -> Optional[str]: + """Return a migration warning when install does not use canonical kit metadata.""" + canonical_manifest = kit_source / ".cf-studio-kit.toml" + if canonical_manifest.is_file(): + return None + legacy_manifest = kit_source / "manifest.toml" + if legacy_manifest.is_file(): + return ( + "Kit uses legacy manifest 'manifest.toml'. " + "Please ask the kit authors to migrate to '.cf-studio-kit.toml'." + ) + return ( + "Kit uses a legacy layout without '.cf-studio-kit.toml'. " + "Please ask the kit authors to migrate to '.cf-studio-kit.toml'." + ) + + def _validate_register_manifest_containment( project_root: Optional[Path], studio_dir: Path, @@ -1756,7 +1818,7 @@ def install_kit( } if manifest is not None: # @cpt-begin:cpt-studio-flow-kit-install-cli:p1:inst-manifest-install - return install_kit_with_manifest( + install_result = install_kit_with_manifest( kit_source, studio_dir, kit_slug, kit_version, manifest, interactive=interactive, source=source, install_mode=install_mode, @@ -1770,6 +1832,12 @@ def install_kit( else "" ), ) + legacy_manifest_warning = _legacy_manifest_install_warning(kit_source) + if legacy_manifest_warning: + install_result.setdefault("errors", []) + install_result["errors"] = list(install_result.get("errors", [])) + install_result["errors"].append(legacy_manifest_warning) + return install_result # @cpt-end:cpt-studio-flow-kit-install-cli:p1:inst-manifest-install # @cpt-end:cpt-studio-algo-kit-install:p1:inst-manifest-install @@ -1840,9 +1908,12 @@ def install_kit( # @cpt-begin:cpt-studio-algo-kit-install:p1:inst-return-result files_copied = sum(1 for v in copy_actions.values() if v == "copied") + legacy_manifest_warning = _legacy_manifest_install_warning(kit_source) + if legacy_manifest_warning: + errors.append(legacy_manifest_warning) return { - "status": "PASS" if not errors else "WARN", + "status": "PASS", "action": "installed", "kit": kit_slug, "version": kit_version, @@ -2075,6 +2146,17 @@ def install_kit_with_manifest( "install_mode": install_mode, "errors": overwrite_errors, } + subagent_source_error = _validate_manifest_public_subagent_sources( + kit_source, + extra_subagent_sources, + ) + if subagent_source_error: + return { + "status": "FAIL", + "kit": kit_slug, + "install_mode": install_mode, + "errors": [subagent_source_error], + } # @cpt-end:cpt-studio-algo-kit-local-path-install-mode:p1:inst-local-copy-no-silent-overwrite kit_root.mkdir(parents=True, exist_ok=True) @@ -2091,20 +2173,6 @@ def install_kit_with_manifest( files_copied += 1 for subagent_source in extra_subagent_sources: source_abs = kit_source / Path(PurePosixPath(subagent_source)) - if not source_abs.exists(): - return { - "status": "FAIL", - "kit": kit_slug, - "install_mode": install_mode, - "errors": [f"Manifest subagent source '{subagent_source}' does not exist in kit source"], - } - if not source_abs.is_file(): - return { - "status": "FAIL", - "kit": kit_slug, - "install_mode": install_mode, - "errors": [f"Manifest subagent source '{subagent_source}' is not a file"], - } target_abs = (kit_root / Path(PurePosixPath(subagent_source))).resolve() target_abs.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source_abs, target_abs) @@ -4930,6 +4998,13 @@ def update_kit( kit_slug=kit_slug, ) _resource_bindings = resolve_resource_bindings(config_dir, kit_slug, studio_dir) + _augment_manifest_subagent_update_bindings( + _risk_model if _risk_model is not None else _manifest, + installed_kit_dir, + _source_to_resource_id, + _resource_info, + _resource_bindings, + ) except ValueError as exc: result["version"] = {"status": "failed"} result["gen"] = {"files_written": 0} diff --git a/skills/studio/scripts/studio/commands/spec_coverage.py b/skills/studio/scripts/studio/commands/spec_coverage.py index e71104ef..abbd74d6 100644 --- a/skills/studio/scripts/studio/commands/spec_coverage.py +++ b/skills/studio/scripts/studio/commands/spec_coverage.py @@ -255,9 +255,17 @@ def _format_ranges(ranges: list) -> str: return ", ".join(parts) def _human_spec_coverage(data: dict) -> None: - summary = data.get("summary", {}) status = data.get("status", "") + unknown_systems = data.get("unknown_systems", []) ui.header("Spec Coverage") + if unknown_systems: + ui.error(data.get("message", "Unknown system selector(s)")) + for slug in unknown_systems: + ui.substep(f" unknown system: {slug}") + ui.blank() + return + + summary = data.get("summary", {}) ui.detail("Files", f"{summary.get('covered_files', 0)}/{summary.get('total_files', 0)} covered") ui.detail("Coverage", f"{summary.get('coverage_pct', 0):.1f}%") ui.detail("Granularity", f"{summary.get('granularity_score', 0):.4f}") diff --git a/skills/studio/scripts/studio/commands/workspace_info.py b/skills/studio/scripts/studio/commands/workspace_info.py index da5ca143..e52e16bb 100644 --- a/skills/studio/scripts/studio/commands/workspace_info.py +++ b/skills/studio/scripts/studio/commands/workspace_info.py @@ -7,9 +7,9 @@ from pathlib import Path from typing import List, Optional -from ..utils.git_utils import _redact_url +from ..utils.git_utils import _redact_url, peek_git_source_path from ..utils.ui import ui -from ..utils.workspace import WorkspaceConfig +from ..utils.workspace import ResolveConfig, WorkspaceConfig def _source_warning_for_result(info: dict) -> Optional[str]: @@ -24,6 +24,11 @@ def _collect_workspace_warnings(sources_info: List[dict], config_warnings: List[ """Collect top-level workspace warnings from source and config degradation.""" warnings = [_source_warning_for_result(source) for source in sources_info] warnings.extend(f"config: {warning}" for warning in config_warnings) + warnings.extend( + f"{source.get('name', '?')}: metadata: {source['metadata_error']}" + for source in sources_info + if source.get("metadata_error") + ) return [warning for warning in warnings if warning] @@ -45,7 +50,20 @@ def _probe_source_adapter(resolved: Path, explicit_adapter: Optional[Path]) -> O def _build_source_info(ws_cfg: WorkspaceConfig, name: str) -> dict: """Build status dict for a single workspace source.""" src = ws_cfg.sources[name] - resolved = ws_cfg.resolve_source_path(name) + if src.url: + if ws_cfg.resolution_base is not None: + base = ws_cfg.resolution_base + elif ws_cfg.workspace_file is not None: + base = ws_cfg.workspace_file.parent + else: + base = None + resolved = ( + peek_git_source_path(src, ws_cfg.resolve or ResolveConfig(), base) + if base is not None + else None + ) + else: + resolved = ws_cfg.resolve_source_path(name) reachable = resolved is not None and resolved.is_dir() info: dict = { @@ -110,7 +128,7 @@ def cmd_workspace_info(argv: List[str]) -> int: p.parse_args(argv) # @cpt-end:cpt-studio-flow-workspace-info:p1:inst-user-workspace-info - from ..utils.context import get_context, WorkspaceContext + from ..utils.context import WorkspaceContext, get_context, get_workspace_upgrade_error from ..utils.workspace import find_workspace_config, require_project_root # @cpt-begin:cpt-studio-flow-workspace-info:p1:inst-info-find-root @@ -178,6 +196,13 @@ def cmd_workspace_info(argv: List[str]) -> int: # @cpt-begin:cpt-studio-flow-workspace-info:p1:inst-info-load-context ctx = get_context() + workspace_upgrade_error = get_workspace_upgrade_error() + if workspace_upgrade_error: + warnings.append(f"context: {workspace_upgrade_error}") + result["workspace_context_error"] = workspace_upgrade_error + result["degraded"] = True + result["warning_count"] = len(warnings) + result["warnings"] = warnings if isinstance(ctx, WorkspaceContext): reachable_count = sum(1 for sc in ctx.sources.values() if sc.reachable) result["context_loaded"] = True diff --git a/skills/studio/scripts/studio/utils/context.py b/skills/studio/scripts/studio/utils/context.py index f9c68356..10328c1e 100644 --- a/skills/studio/scripts/studio/utils/context.py +++ b/skills/studio/scripts/studio/utils/context.py @@ -987,6 +987,7 @@ def _collect_remote_artifacts( # Global context instance (set by CLI on startup) _global_context: Optional[Union[StudioContext, WorkspaceContext]] = None # pylint: disable=invalid-name _workspace_upgrade_attempted: bool = False # pylint: disable=invalid-name +_workspace_upgrade_error: Optional[str] = None # pylint: disable=invalid-name def get_context() -> Optional[Union[StudioContext, WorkspaceContext]]: @@ -997,47 +998,59 @@ def get_context() -> Optional[Union[StudioContext, WorkspaceContext]]: operations for git URL sources) is deferred until a command actually needs the context. """ - global _global_context, _workspace_upgrade_attempted # pylint: disable=global-statement # module-level singleton pattern for CLI context + global _global_context, _workspace_upgrade_attempted, _workspace_upgrade_error # pylint: disable=global-statement # module-level singleton pattern for CLI context if not _workspace_upgrade_attempted and isinstance(_global_context, StudioContext): _workspace_upgrade_attempted = True try: ws_ctx = WorkspaceContext.load(_global_context) - except (OSError, ValueError, KeyError, AttributeError): + except (OSError, ValueError, KeyError, AttributeError) as exc: + _workspace_upgrade_error = str(exc) ws_ctx = None if ws_ctx is not None: + _workspace_upgrade_error = None _global_context = ws_ctx return _global_context def set_context(ctx: Optional[Union[StudioContext, WorkspaceContext]]) -> None: """Set the global Studio context.""" - global _global_context, _workspace_upgrade_attempted # pylint: disable=global-statement # module-level singleton pattern for CLI context + global _global_context, _workspace_upgrade_attempted, _workspace_upgrade_error # pylint: disable=global-statement # module-level singleton pattern for CLI context _global_context = ctx + _workspace_upgrade_error = None # If caller already provides a WorkspaceContext, skip lazy upgrade _workspace_upgrade_attempted = isinstance(ctx, WorkspaceContext) or ctx is None def ensure_context(start_path: Optional[Path] = None) -> Optional[Union[StudioContext, WorkspaceContext]]: """Ensure context is loaded, loading if necessary.""" - global _global_context, _workspace_upgrade_attempted # pylint: disable=global-statement # module-level singleton pattern for CLI context + global _global_context, _workspace_upgrade_attempted, _workspace_upgrade_error # pylint: disable=global-statement # module-level singleton pattern for CLI context if _global_context is None: base_ctx = StudioContext.load(start_path) if base_ctx is not None: # @cpt-begin:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-workspace-upgrade try: ws_ctx = WorkspaceContext.load(base_ctx) - except (OSError, ValueError, KeyError, AttributeError): + except (OSError, ValueError, KeyError, AttributeError) as exc: + _workspace_upgrade_error = str(exc) ws_ctx = None _workspace_upgrade_attempted = True # @cpt-end:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-workspace-upgrade # @cpt-begin:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return-workspace + if ws_ctx is not None: + _workspace_upgrade_error = None _global_context = ws_ctx if ws_ctx is not None else base_ctx # @cpt-end:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return-workspace else: project_root = _find_project_root_for_workspace(start_path or Path.cwd()) if project_root is not None: _workspace_upgrade_attempted = True - _global_context = WorkspaceContext.load_from_workspace_root(project_root) + try: + _global_context = WorkspaceContext.load_from_workspace_root(project_root) + except (OSError, ValueError, KeyError, AttributeError) as exc: + _workspace_upgrade_error = str(exc) + _global_context = None + else: + _workspace_upgrade_error = None else: # @cpt-begin:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-return _global_context = None @@ -1045,6 +1058,11 @@ def ensure_context(start_path: Optional[Path] = None) -> Optional[Union[StudioCo return _global_context +def get_workspace_upgrade_error() -> Optional[str]: + """Return the last workspace-upgrade error, if any.""" + return _workspace_upgrade_error + + def is_workspace() -> bool: """Check if the global context is a WorkspaceContext.""" ctx = get_context() @@ -1212,6 +1230,7 @@ def _find_project_root_for_workspace(start_path: Path) -> Optional[Path]: "resolve_target_and_artifacts", "set_context", "ensure_context", + "get_workspace_upgrade_error", "is_workspace", ] # @cpt-end:cpt-studio-algo-core-infra-context-loading:p1:inst-ctx-globals diff --git a/skills/studio/scripts/studio/utils/diff_engine.py b/skills/studio/scripts/studio/utils/diff_engine.py index 7936c5e7..0d15f090 100644 --- a/skills/studio/scripts/studio/utils/diff_engine.py +++ b/skills/studio/scripts/studio/utils/diff_engine.py @@ -939,6 +939,14 @@ def file_level_kit_update( rel_path, dest.as_posix(), } + # Manifest copy-mode updates commonly surface binding paths relative + # to the studio root (for example `config/kits//...`). + # Accept those aliases in addition to the absolute resolved path. + if len(user_dir.parents) >= 3: + try: + approval_tokens.add(dest.relative_to(user_dir.parents[2]).as_posix()) + except ValueError: + pass has_overwrite_approval = bool(overwrite_approvals.intersection(approval_tokens)) if force or auto_approve: diff --git a/skills/studio/scripts/studio/utils/manifest.py b/skills/studio/scripts/studio/utils/manifest.py index 7fd1ab3d..45c62f57 100644 --- a/skills/studio/scripts/studio/utils/manifest.py +++ b/skills/studio/scripts/studio/utils/manifest.py @@ -1481,6 +1481,33 @@ def build_source_to_resource_mapping( if fpath.is_file(): rel_path = fpath.relative_to(kit_source).as_posix() source_to_resource_id[rel_path] = res.id + for subagent in getattr(res, "subagents", []) or []: + if not isinstance(subagent, dict): + continue + subagent_source = str(subagent.get("source", "") or "").strip().replace("\\", "/") + if not subagent_source: + continue + source_rel_subagent = Path(subagent_source) + if source_rel_subagent.is_absolute(): + raise ValueError( + f"Resource '{res.id}': subagent source path '{subagent_source}' must be relative" + ) + resolved_subagent = (kit_source / source_rel_subagent).resolve() + try: + resolved_subagent.relative_to(kit_source.resolve()) + except ValueError as exc: + raise ValueError( + f"Resource '{res.id}': subagent source path '{subagent_source}' escapes the kit root" + ) from exc + if not resolved_subagent.is_file(): + continue + synthetic_id = f"{res.id}.__subagent__.{subagent_source}" + resource_info[synthetic_id] = ResourceInfo( + type="file", + source_base=subagent_source, + user_modifiable=res.user_modifiable, + ) + source_to_resource_id[subagent_source] = synthetic_id # @cpt-end:cpt-studio-algo-kit-manifest-source-mapping:p1:inst-expand-directories # @cpt-end:cpt-studio-algo-kit-manifest-source-mapping:p1:inst-map-file-resources # @cpt-end:cpt-studio-algo-kit-manifest-source-mapping:p1:inst-record-resource-info diff --git a/tests/test_cli_example_kits_e2e.py b/tests/test_cli_example_kits_e2e.py index 104a4ccb..de304379 100644 --- a/tests/test_cli_example_kits_e2e.py +++ b/tests/test_cli_example_kits_e2e.py @@ -1608,6 +1608,9 @@ def test_example_legacy_local_copy_install_runs_info_update_validate_without_pro self.assertEqual(rc, 0, stderr) self.assertEqual(out["status"], "PASS") self.assertEqual(out["files_written"], 3) + self.assertIn("errors", out) + self.assertIn("legacy manifest 'manifest.toml'", "\n".join(out["errors"])) + self.assertIn(".cf-studio-kit.toml", "\n".join(out["errors"])) rc, out, stderr = _run_main_json(["info", "--root", str(project_root)], cwd=project_root) self.assertEqual(rc, 0, stderr) @@ -2633,8 +2636,18 @@ def test_example_v2_generic_git_subdir_install_and_update_preserves_subdirectory self.assertEqual(out["kits_updated"], 0) self.assertEqual(out["kits_partially_updated"], 1) self.assertEqual(result["action"], "partial") - self.assertEqual(result["accepted"], ["agents/planner-helper.md", "agents/reviewer-helper.md"]) + self.assertEqual(result["accepted"], []) self.assertEqual(result["declined"], ["artifacts/FEATURE/example.md"]) + self.assertTrue( + ( + project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "agents" / "planner-helper.md" + ).is_file() + ) + self.assertTrue( + ( + project_root / ".bootstrap" / "config" / "kits" / "example-v2" / "agents" / "reviewer-helper.md" + ).is_file() + ) self.assertEqual(result["authority"]["commit_sha"], updated_commit) self.assertIn( "cpt-example-v2-feature-flow", diff --git a/tests/test_cli_validation_e2e.py b/tests/test_cli_validation_e2e.py index b0d34c41..09845ca0 100644 --- a/tests/test_cli_validation_e2e.py +++ b/tests/test_cli_validation_e2e.py @@ -346,6 +346,32 @@ def test_spec_coverage_unknown_system_fails_without_mutating_sources(self): self.assertFalse((root / ".gitignore").exists()) self.assertEqual(stderr, "") + def test_spec_coverage_unknown_system_human_output_names_invalid_selector(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _bootstrap_project( + root, + systems=[ + { + "name": "Web", + "slug": "web", + "kit": "test", + "codebase": [{"path": "src/web", "extensions": [".py"]}], + }, + ], + ) + + exit_code, stdout, stderr = _run_main( + ["spec-coverage", "--system", "missing"], + cwd=root, + ) + + self.assertEqual(exit_code, 2) + self.assertEqual(stdout, "") + self.assertIn("Unknown system selector(s)", stderr) + self.assertIn("unknown system: missing", stderr) + self.assertNotIn("Threshold check failed", stderr) + def test_spec_coverage_output_writes_report_only(self): with TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/tests/test_cli_workspace_diag_e2e.py b/tests/test_cli_workspace_diag_e2e.py index d40950f0..462570cb 100644 --- a/tests/test_cli_workspace_diag_e2e.py +++ b/tests/test_cli_workspace_diag_e2e.py @@ -76,6 +76,8 @@ def _make_adapter_repo(root: Path, *, adapter_rel: str = ".bootstrap", role_dir: adapter = root / adapter_rel / "config" adapter.mkdir(parents=True, exist_ok=True) (adapter / "AGENTS.md").write_text("# Test adapter\n", encoding="utf-8") + toml_utils.dump({"version": "1.0", "project_root": "..", "kits": {}}, adapter / "core.toml") + toml_utils.dump({"version": "1.0", "project_root": "..", "kits": {}, "systems": []}, adapter / "artifacts.toml") (root / role_dir).mkdir(parents=True, exist_ok=True) @@ -330,7 +332,7 @@ def test_workspace_init_add_info_round_trip_with_bounded_writes(self): self.assertNotIn("warnings", info_payload) self.assertEqual(info_payload["sources_count"], 2) self.assertFalse(info_payload["is_inline"]) - self.assertFalse(info_payload["context_loaded"]) + self.assertTrue(info_payload["context_loaded"]) sources = {source["name"]: source for source in info_payload["sources"]} self.assertTrue(sources["docs-repo"]["reachable"]) @@ -565,11 +567,7 @@ def test_workspace_info_git_source_not_cloned_reports_warning_without_network(se ) before = _snapshot_tree(root) - with patch( - "studio.utils.git_utils.resolve_git_source", - return_value=root / ".workspace-sources" / "acme" / "docs", - ): - exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) after = _snapshot_tree(root) self.assertEqual(exit_code, 0) @@ -587,6 +585,66 @@ def test_workspace_info_git_source_not_cloned_reports_warning_without_network(se self.assertFalse(source["reachable"]) self.assertIn("Source not cloned", source["warning"]) + def test_workspace_info_metadata_error_marks_workspace_degraded(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + source_root = root / "docs-repo" + _make_adapter_repo(source_root, role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": { + "docs-repo": { + "path": "docs-repo", + "role": "artifacts", + "adapter": ".bootstrap", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + with patch("studio.utils.artifacts_meta.load_artifacts_meta", return_value=(None, "registry parse failed")): + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertTrue(payload["degraded"]) + self.assertEqual(payload["warning_count"], 1) + self.assertEqual(payload["warnings"], ["docs-repo: metadata: registry parse failed"]) + self.assertEqual(payload["sources"][0]["metadata_error"], "registry parse failed") + + def test_workspace_info_surfaces_workspace_context_upgrade_error(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root, role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": { + "docs-repo": { + "path": ".", + "role": "artifacts", + "adapter": ".bootstrap", + }, + }, + }, + root / ".cf-workspace.toml", + ) + + with patch("studio.utils.context.get_workspace_upgrade_error", return_value="workspace load boom"): + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertTrue(payload["degraded"]) + self.assertEqual(payload["workspace_context_error"], "workspace load boom") + self.assertIn("context: workspace load boom", payload["warnings"]) + def test_workspace_info_invalid_adapter_reports_adapter_found_false(self): with TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "workspace-root" diff --git a/tests/test_diff_engine.py b/tests/test_diff_engine.py index 20f9a45d..0f6eb7d9 100644 --- a/tests/test_diff_engine.py +++ b/tests/test_diff_engine.py @@ -755,6 +755,47 @@ def test_removed_bound_resource_file_requires_prune(self): "resource removed upstream; explicit prune mode required", ) + def test_overwrite_approval_accepts_studio_relative_binding_path(self): + """Overwrite approvals accept config/kits-relative aliases for bound files.""" + from studio.utils.diff_engine import file_level_kit_update + from studio.utils.manifest import ResourceInfo + + with TemporaryDirectory() as td: + src = Path(td) / "src" + studio = Path(td) / "studio" + usr = studio / "config" / "kits" / "demo" + src.mkdir() + usr.mkdir(parents=True) + (src / "agents").mkdir() + (src / "agents" / "reviewer-helper.md").write_text("new helper\n", encoding="utf-8") + (usr / "agents").mkdir() + (usr / "agents" / "reviewer-helper.md").write_text("old helper\n", encoding="utf-8") + + result = file_level_kit_update( + src, + usr, + auto_approve=True, + content_dirs=("agents",), + resource_bindings={ + "reviewer.__subagent__.agents/reviewer-helper.md": usr / "agents" / "reviewer-helper.md", + }, + source_to_resource_id={ + "agents/reviewer-helper.md": "reviewer.__subagent__.agents/reviewer-helper.md", + }, + resource_info={ + "reviewer.__subagent__.agents/reviewer-helper.md": ResourceInfo( + type="file", + source_base="agents/reviewer-helper.md", + user_modifiable=True, + ), + }, + approved_overwrites=["config/kits/demo/agents/reviewer-helper.md"], + ) + + self.assertEqual(result["accepted"], ["agents/reviewer-helper.md"]) + self.assertEqual(result["declined"], []) + self.assertEqual((usr / "agents" / "reviewer-helper.md").read_text(encoding="utf-8"), "new helper\n") + def test_prune_bound_resource_file_requires_fingerprint(self): """Non-interactive prune mode still requires the per-path fingerprint.""" from studio.utils.diff_engine import file_level_kit_update diff --git a/tests/test_generate_manifest_agents.py b/tests/test_generate_manifest_agents.py index 1412764e..0e2df145 100644 --- a/tests/test_generate_manifest_agents.py +++ b/tests/test_generate_manifest_agents.py @@ -12,6 +12,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "studio" / "scripts")) @@ -909,6 +910,66 @@ def test_openai_does_not_delete_unrelated_legacy_file(self): self.assertIn("My custom hand-written agent config", legacy_out.read_text(encoding="utf-8")) self.assertEqual(result.get("deleted", []), []) + def test_openai_stale_cleanup_accepts_current_toml_with_sandbox_mode(self): + from studio.commands.agents import _process_subagents, _render_toml_agent + + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) + output_dir = project_root / ".codex" / "agents" + output_dir.mkdir(parents=True) + + prompt = project_root / "agents" / "worker.md" + prompt.parent.mkdir(parents=True) + prompt.write_text( + "---\n" + 'description: "Worker"\n' + "---\n" + "# worker\n", + encoding="utf-8", + ) + + stale_prompt = project_root / "agents" / "cf-constructor-stale.md" + stale_prompt.write_text( + "---\n" + 'description: "Stale"\n' + "---\n" + "# stale\n", + encoding="utf-8", + ) + + base_toml = _render_toml_agent( + {"name": "cf-constructor-stale", "description": "Stale"}, + "{cf-studio-path}/agents/cf-constructor-stale.md", + ) + stale = output_dir / "cf-constructor-stale.toml" + stale.write_text( + base_toml.rstrip("\n") + "\n" + 'sandbox_mode = "workspace-write"\n', + encoding="utf-8", + ) + + with patch( + "studio.commands.agents._discover_kit_agents", + return_value=[ + { + "name": "worker", + "description": "Worker", + "prompt_file_abs": prompt, + } + ], + ): + result = _process_subagents( + "openai", + project_root, + project_root, + {}, + None, + dry_run=False, + ) + + self.assertFalse(stale.exists()) + deleted_paths = {Path(path).resolve().as_posix() for path in result["deleted"]} + self.assertIn(stale.resolve().as_posix(), deleted_paths) + def test_openai_preserves_marker_owned_legacy_agents_path_when_content_diverged(self): """OpenAI legacy cleanup preserves diverged marker-bearing legacy files.""" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/test_kit_manifest_install.py b/tests/test_kit_manifest_install.py index 98e82c33..f2e62787 100644 --- a/tests/test_kit_manifest_install.py +++ b/tests/test_kit_manifest_install.py @@ -710,6 +710,7 @@ def test_manifest_install_fails_when_public_subagent_source_is_missing(self): self.assertEqual(result["status"], "FAIL") self.assertIn("missing-helper.md", "\n".join(result["errors"])) + self.assertFalse((adapter / "config" / "kits" / "mykit").exists()) def test_interactive_prompt_shows_locked_paths_but_edits_only_modifiable_paths(self): """Install plan shows all resources, edit menu contains only editable paths.""" @@ -1130,6 +1131,9 @@ def test_install_legacy_kit_via_cli(self): self.assertEqual(rc, 0) out = json.loads(buf.getvalue()) self.assertEqual(out["status"], "PASS") + self.assertIn("errors", out) + self.assertIn("legacy layout", "\n".join(out["errors"])) + self.assertIn(".cf-studio-kit.toml", "\n".join(out["errors"])) finally: os.chdir(cwd) From a47c1c95c6e3b747044a5adef081cf13954fdbce Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 12:20:16 +0300 Subject: [PATCH 13/14] feat: add context markers for kit installation and workspace info commands Signed-off-by: ainetx --- skills/studio/scripts/studio/commands/kit.py | 6 ++++++ skills/studio/scripts/studio/commands/workspace_info.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index 7f24807b..ffbf33dd 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -1529,6 +1529,7 @@ def _load_manifest_install_adapter(kit_source: Path, kit_slug: str = "") -> Opti def _legacy_manifest_install_warning(kit_source: Path) -> Optional[str]: """Return a migration warning when install does not use canonical kit metadata.""" + # @cpt-begin:cpt-studio-algo-kit-install:p1:inst-validate-source canonical_manifest = kit_source / ".cf-studio-kit.toml" if canonical_manifest.is_file(): return None @@ -1542,6 +1543,7 @@ def _legacy_manifest_install_warning(kit_source: Path) -> Optional[str]: "Kit uses a legacy layout without '.cf-studio-kit.toml'. " "Please ask the kit authors to migrate to '.cf-studio-kit.toml'." ) + # @cpt-end:cpt-studio-algo-kit-install:p1:inst-validate-source def _validate_register_manifest_containment( @@ -1832,11 +1834,13 @@ def install_kit( else "" ), ) + # @cpt-begin:cpt-studio-algo-kit-install:p1:inst-collect-meta legacy_manifest_warning = _legacy_manifest_install_warning(kit_source) if legacy_manifest_warning: install_result.setdefault("errors", []) install_result["errors"] = list(install_result.get("errors", [])) install_result["errors"].append(legacy_manifest_warning) + # @cpt-end:cpt-studio-algo-kit-install:p1:inst-collect-meta return install_result # @cpt-end:cpt-studio-flow-kit-install-cli:p1:inst-manifest-install # @cpt-end:cpt-studio-algo-kit-install:p1:inst-manifest-install @@ -1908,9 +1912,11 @@ def install_kit( # @cpt-begin:cpt-studio-algo-kit-install:p1:inst-return-result files_copied = sum(1 for v in copy_actions.values() if v == "copied") + # @cpt-begin:cpt-studio-algo-kit-install:p1:inst-seed-configs legacy_manifest_warning = _legacy_manifest_install_warning(kit_source) if legacy_manifest_warning: errors.append(legacy_manifest_warning) + # @cpt-end:cpt-studio-algo-kit-install:p1:inst-seed-configs return { "status": "PASS", diff --git a/skills/studio/scripts/studio/commands/workspace_info.py b/skills/studio/scripts/studio/commands/workspace_info.py index e52e16bb..598f4182 100644 --- a/skills/studio/scripts/studio/commands/workspace_info.py +++ b/skills/studio/scripts/studio/commands/workspace_info.py @@ -186,11 +186,13 @@ def cmd_workspace_info(argv: List[str]) -> int: if config_warnings: result["config_warnings"] = config_warnings + # @cpt-begin:cpt-studio-flow-workspace-info:p1:inst-info-load-context warnings = _collect_workspace_warnings(sources_info, config_warnings) result["degraded"] = bool(warnings) result["warning_count"] = len(warnings) if warnings: result["warnings"] = warnings + # @cpt-end:cpt-studio-flow-workspace-info:p1:inst-info-load-context # @cpt-end:cpt-studio-flow-workspace-info:p1:inst-info-build-result From f9ffbbc959e1538682202df28051f9f1bff0478c Mon Sep 17 00:00:00 2001 From: ainetx Date: Sat, 20 Jun 2026 12:32:24 +0300 Subject: [PATCH 14/14] feat: add validation for missing public subagent sources in kit installation Signed-off-by: ainetx --- skills/studio/scripts/studio/commands/kit.py | 23 +++++---- tests/test_kit_manifest_install.py | 54 ++++++++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index ffbf33dd..3721b048 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -2033,6 +2033,18 @@ def install_kit_with_manifest( } # @cpt-end:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-resolve-install-mode + subagent_source_error = _validate_manifest_public_subagent_sources( + kit_source, + extra_subagent_sources, + ) + if subagent_source_error: + return { + "status": "FAIL", + "kit": kit_slug, + "install_mode": install_mode, + "errors": [subagent_source_error], + } + if install_mode == "register": # @cpt-begin:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-register-resource-in-place # @cpt-begin:cpt-studio-algo-kit-local-path-install-mode:p1:inst-local-register-core-only @@ -2152,17 +2164,6 @@ def install_kit_with_manifest( "install_mode": install_mode, "errors": overwrite_errors, } - subagent_source_error = _validate_manifest_public_subagent_sources( - kit_source, - extra_subagent_sources, - ) - if subagent_source_error: - return { - "status": "FAIL", - "kit": kit_slug, - "install_mode": install_mode, - "errors": [subagent_source_error], - } # @cpt-end:cpt-studio-algo-kit-local-path-install-mode:p1:inst-local-copy-no-silent-overwrite kit_root.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_kit_manifest_install.py b/tests/test_kit_manifest_install.py index f2e62787..1eb060e2 100644 --- a/tests/test_kit_manifest_install.py +++ b/tests/test_kit_manifest_install.py @@ -712,6 +712,60 @@ def test_manifest_install_fails_when_public_subagent_source_is_missing(self): self.assertIn("missing-helper.md", "\n".join(result["errors"])) self.assertFalse((adapter / "config" / "kits" / "mykit").exists()) + def test_manifest_register_install_fails_when_public_subagent_source_is_missing(self): + from studio.commands.kit import install_kit_with_manifest + import tomllib + + with TemporaryDirectory() as td: + td_path = Path(td) + project_root = td_path / "project" + adapter = _bootstrap_project(project_root) + kit_src = project_root / "local-kits" / "mykit" + kit_src.mkdir(parents=True) + (kit_src / "reviewer.md").write_text("# Reviewer\n", encoding="utf-8") + (kit_src / ".cf-studio-kit.toml").write_text( + textwrap.dedent( + """\ + manifest_version = "1.0" + + [[kits]] + slug = "mykit" + version = "2.0" + + [[kits.resources]] + id = "reviewer" + kind = "agent" + source = "reviewer.md" + type = "file" + public = true + + [[kits.resources.agent.subagents]] + id = "reviewer-helper" + source = "missing-helper.md" + """ + ), + encoding="utf-8", + ) + + manifest = load_manifest(kit_src) + result = install_kit_with_manifest( + kit_src, + adapter, + "mykit", + "2.0", + manifest, + interactive=False, + install_mode="register", + project_root=project_root, + ) + + self.assertEqual(result["status"], "FAIL") + self.assertEqual(result["install_mode"], "register") + self.assertIn("missing-helper.md", "\n".join(result["errors"])) + with open(adapter / "config" / "core.toml", "rb") as fh: + core = tomllib.load(fh) + self.assertEqual(core["kits"], {}) + def test_interactive_prompt_shows_locked_paths_but_edits_only_modifiable_paths(self): """Install plan shows all resources, edit menu contains only editable paths.""" from studio.commands.kit import install_kit_with_manifest