From 8a067c57617251ad6f36ae3241ae95c363dfa98b Mon Sep 17 00:00:00 2001 From: ainetx Date: Fri, 19 Jun 2026 14:53:16 +0300 Subject: [PATCH] test: add coverage for gitignore updates and agent generation scenarios Signed-off-by: ainetx --- .../studio/scripts/studio/commands/agents.py | 146 +++++- tests/test_agents_coverage.py | 421 ++++++++++++++++++ 2 files changed, 556 insertions(+), 11 deletions(-) diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 0a4010f7..4aaa8e5a 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -4705,14 +4705,18 @@ def cmd_generate_agents(argv: List[str]) -> int: layers = [layer for layer in discovered_layers if layer.scope == "kit"] # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step2-discover-layers + # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step3-v2-pipeline if _layers_have_v2_manifests(layers): # ── NEW PATH: Multi-layer v2.0 manifest pipeline ───────────────── # Step 3: Resolve includes for each layer + # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step3-resolve-includes resolved_layers, has_v2_errors = _resolve_includes_for_layers(layers, project_root) if has_v2_errors: return 1 + # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step3-resolve-includes # Step 4: Handle --discover flag + # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step4-discover if getattr(args, "discover", False): _run_discover_flag(args, project_root, studio_root) discovered_layers = _discover_layers(project_root, studio_root) @@ -4720,6 +4724,7 @@ def cmd_generate_agents(argv: List[str]) -> int: resolved_layers, has_v2_errors = _resolve_includes_for_layers(layers, project_root) if has_v2_errors: return 1 + # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step4-discover # Step 5: Merge components from all layers # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step6-merge @@ -4729,12 +4734,16 @@ def cmd_generate_agents(argv: List[str]) -> int: # Collect trusted roots from discovered layer directories so that # master-layer source paths (rewritten to absolute) pass the # containment check in _read_source_content. + # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step6-trusted-roots _trusted_roots = [layer.path.parent for layer in resolved_layers if layer.state == _ManifestLayerState.LOADED] + # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step6-trusted-roots # Step 6: Handle --show-layers flag + # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step6-show-layers rc = _handle_show_layers_v2(args, merged, project_root) if rc is not None: return rc + # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step6-show-layers # Step 7: Extend variables with layer path variables # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step9-layer-vars @@ -4757,6 +4766,7 @@ 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_agents: Dict[str, Dict[str, Any]] = {} preview_skills: Dict[str, Dict[str, Any]] = {} legacy_preview: Dict[str, Any] = {} @@ -4788,8 +4798,13 @@ def cmd_generate_agents(argv: List[str]) -> int: preview_v2_create += len(sec.get("created", [])) preview_v2_update += len(sec.get("updated", [])) + len(sec.get("renamed", [])) preview_v2_delete += len(sec.get("deleted", [])) + if preview_gitignore_action == "created": + preview_v2_create += 1 + elif preview_gitignore_action == "updated": + preview_v2_update += 1 # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step8-preview + # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step8-dry-run-report if args.dry_run: # @cpt-begin:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step8-dry-run dry_results: Dict[str, Any] = {} @@ -4806,12 +4821,23 @@ def cmd_generate_agents(argv: List[str]) -> int: "subagents": lp.get("subagents", {}), "legacy_skills": lp.get("skills", {}), } - dr = _build_result(dry_results, agents_to_process, project_root, studio_root, cfg_path, copy_report, dry_run=True) + dr = _build_result( + dry_results, + agents_to_process, + project_root, + studio_root, + cfg_path, + copy_report, + dry_run=True, + gitignore_action=preview_gitignore_action, + ) dr["manifest_v2"] = True ui.result(dr, human_fn=lambda d: _human_generate_agents_ok(d, agents_to_process, dry_results, dry_run=True)) return 0 # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step8-dry-run + # @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): return 0 @@ -4828,11 +4854,23 @@ def cmd_generate_agents(argv: List[str]) -> int: trusted_roots=_trusted_roots, remove_cypilot=remove_cypilot, ) - agents_result = _build_result(results, agents_to_process, project_root, studio_root, cfg_path, copy_report, dry_run=args.dry_run) + gitignore_action = _refresh_managed_gitignore(project_root, studio_root, dry_run=False) + agents_result = _build_result( + results, + agents_to_process, + project_root, + studio_root, + cfg_path, + copy_report, + dry_run=args.dry_run, + gitignore_action=gitignore_action, + ) agents_result["manifest_v2"] = True agents_result["layers"] = len(resolved_layers) ui.result(agents_result, human_fn=lambda d: _human_generate_agents_ok(d, agents_to_process, results, dry_run=args.dry_run)) return 0 if not has_errors else 1 + # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step8-confirm-execute + # @cpt-end:cpt-studio-flow-project-extensibility-generate-with-multi-layer:p1:inst-step3-v2-pipeline # ── EXISTING PATH: Legacy agents.toml flow ──────────────────────────── # @cpt-begin:cpt-studio-dod-project-extensibility-backward-compat:p1:inst-legacy-path @@ -4875,6 +4913,7 @@ 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) for r in preview_results.values(): wf = r.get("workflows", {}) sk = r.get("skills", {}) @@ -4895,10 +4934,23 @@ def cmd_generate_agents(argv: List[str]) -> int: + len(sk.get("deleted", [])) + len(sub.get("deleted", [])) ) + if preview_gitignore_action == "created": + total_create += 1 + elif preview_gitignore_action == "updated": + total_update += 1 if args.dry_run: # Just show the preview and exit - agents_result = _build_result(preview_results, agents_to_process, project_root, studio_root, cfg_path, copy_report, dry_run=True) + agents_result = _build_result( + preview_results, + agents_to_process, + project_root, + studio_root, + cfg_path, + copy_report, + dry_run=True, + gitignore_action=preview_gitignore_action, + ) ui.result(agents_result, human_fn=lambda d: _human_generate_agents_ok(d, agents_to_process, preview_results, dry_run=True)) _failing = {"PARTIAL", "CONFIG_ERROR"} if any( @@ -4923,6 +4975,7 @@ def cmd_generate_agents(argv: List[str]) -> int: cfg_path, copy_report, dry_run=False, + gitignore_action=preview_gitignore_action, ) ui.result( agents_result, @@ -4943,6 +4996,7 @@ def cmd_generate_agents(argv: List[str]) -> int: cfg_path, copy_report, dry_run=False, + gitignore_action=preview_gitignore_action, ) ui.result( agents_result, @@ -4958,7 +5012,12 @@ def cmd_generate_agents(argv: List[str]) -> int: if not is_json_mode(): auto_approve = getattr(args, "yes", False) if not auto_approve: - _human_generate_agents_preview(agents_to_process, preview_results, project_root) + _human_generate_agents_preview( + agents_to_process, + preview_results, + project_root, + gitignore_action=preview_gitignore_action, + ) if not auto_approve and sys.stdin.isatty(): try: answer = input( @@ -4996,7 +5055,17 @@ 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 - agents_result = _build_result(results, agents_to_process, project_root, studio_root, cfg_path, copy_report, dry_run=False) + gitignore_action = _refresh_managed_gitignore(project_root, studio_root, dry_run=False) + agents_result = _build_result( + results, + agents_to_process, + project_root, + studio_root, + cfg_path, + copy_report, + dry_run=False, + gitignore_action=gitignore_action, + ) ui.result(agents_result, human_fn=lambda d: _human_generate_agents_ok(d, agents_to_process, results, dry_run=False)) # @cpt-end:cpt-studio-flow-agent-integration-generate:p1:inst-return-report @@ -5050,9 +5119,10 @@ def _build_result( cfg_path: Optional[Path], copy_report: dict, dry_run: bool, + gitignore_action: Optional[str] = None, ) -> Dict[str, Any]: has_errors = any(r.get("status") != "PASS" for r in results.values()) - return { + result = { "status": "PASS" if not has_errors else "PARTIAL", "agents": list(agents_to_process), "project_root": project_root.as_posix(), @@ -5062,11 +5132,36 @@ def _build_result( "studio_copy": copy_report, "results": results, } + if gitignore_action: + result["gitignore"] = gitignore_action + return result +# @cpt-end:cpt-studio-algo-agent-integration-generate-shims:p1:inst-format-output + + +def _refresh_managed_gitignore( + project_root: Path, + studio_root: Path, + dry_run: bool, +) -> 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(): + return None + from .init import _read_kit_tracking, _write_gitignore_block + + 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, + ) # --------------------------------------------------------------------------- # Human-friendly formatters # --------------------------------------------------------------------------- +# @cpt-begin:cpt-studio-algo-agent-integration-generate-shims:p1:inst-format-output def _human_agents_list( _data: Dict[str, Any], _agents_to_process: List[str], @@ -5112,6 +5207,7 @@ def _human_generate_agents_preview( agents_to_process: List[str], results: Dict[str, Any], _project_root: Path, + gitignore_action: Optional[str] = None, ) -> None: agent_label = ", ".join(agents_to_process) ui.header(f"Generate Agent Integration — {agent_label}") @@ -5164,12 +5260,15 @@ def _human_generate_agents_preview( ui.file_action(path, "updated") if skipped_sub and skipped_sub_reason: ui.substep(f"subagents skipped: {skipped_sub_reason}") + if gitignore_action in {"created", "updated"}: + ui.file_action(".gitignore", gitignore_action) ui.blank() def _render_agent_file_actions( wf: Dict[str, Any], sk: Dict[str, Any], sub: Dict[str, Any], + extra_sk: Optional[Dict[str, Any]] = None, ) -> None: """Emit ui.file_action calls for one agent's workflows, skills, and subagents.""" for path in wf.get("created", []): @@ -5188,6 +5287,15 @@ def _render_agent_file_actions( ui.file_action(path, "deleted") for item in sk.get("skipped", []): ui.warn(f" skipped: {item}") + extra_sk = extra_sk or {} + for path in extra_sk.get("created", []): + ui.file_action(path, "created") + for path in extra_sk.get("updated", []): + ui.file_action(path, "updated") + for path in extra_sk.get("deleted", []): + ui.file_action(path, "deleted") + for item in extra_sk.get("skipped", []): + ui.warn(f" skipped: {item}") for path in sub.get("created", []): ui.file_action(path, "created") for path in sub.get("updated", []): @@ -5202,15 +5310,22 @@ def _build_agent_summary_parts( wf_counts: Dict[str, Any], sk_counts: Dict[str, Any], sub_counts: Dict[str, Any], + extra_sk_counts: Optional[Dict[str, Any]], dry_run: bool, ) -> List[str]: """Build summary parts list for one agent's result counts.""" total_wf = wf_counts.get("created", 0) + wf_counts.get("updated", 0) + wf_counts.get("renamed", 0) total_wf_deleted = wf_counts.get("deleted", 0) - total_sk = sk_counts.get("created", 0) + sk_counts.get("updated", 0) + extra_sk_counts = extra_sk_counts or {} + total_sk = ( + sk_counts.get("created", 0) + + sk_counts.get("updated", 0) + + extra_sk_counts.get("created", 0) + + extra_sk_counts.get("updated", 0) + ) total_sub = sub_counts.get("created", 0) + sub_counts.get("updated", 0) - total_deleted = sk_counts.get("deleted", 0) + sub_counts.get("deleted", 0) - total_skipped = sk_counts.get("skipped", 0) + total_deleted = sk_counts.get("deleted", 0) + extra_sk_counts.get("deleted", 0) + sub_counts.get("deleted", 0) + total_skipped = sk_counts.get("skipped", 0) + extra_sk_counts.get("skipped", 0) parts: List[str] = [] if total_wf: parts.append(f"{total_wf} workflow(s)") @@ -5240,9 +5355,11 @@ def _human_generate_agents_ok( agent_status = r.get("status", "?") wf = r.get("workflows", {}) sk = r.get("skills", {}) + legacy_sk = r.get("legacy_skills", {}) sub = r.get("subagents", {}) wf_counts = wf.get("counts", {}) sk_counts = sk.get("counts", {}) + legacy_sk_counts = legacy_sk.get("counts", {}) sub_counts = sub.get("counts", {}) if agent_status == "PASS": @@ -5250,7 +5367,7 @@ def _human_generate_agents_ok( else: ui.warn(f"{agent_name} ({agent_status})") - _render_agent_file_actions(wf, sk, sub) + _render_agent_file_actions(wf, sk, sub, legacy_sk) # V2 manifest agents v2_ag = r.get("v2_agents", {}) @@ -5294,7 +5411,7 @@ def _human_generate_agents_ok( else: total_v2_ag = len(created_v2_ag) + len(updated_v2_ag) + len(deleted_v2_ag) parts = _build_agent_summary_parts( - wf_counts, sk_counts, sub_counts, dry_run, + wf_counts, sk_counts, sub_counts, legacy_sk_counts, dry_run, ) if total_v2_ag: parts.append(f"{total_v2_ag} agent file action(s)") @@ -5306,6 +5423,9 @@ def _human_generate_agents_ok( for e in errs: ui.warn(f" {e}") + if data.get("gitignore") in {"created", "updated"}: + ui.file_action(".gitignore", str(data.get("gitignore"))) + if dry_run: ui.success("Dry run complete — no files were written.") elif data.get("status") == "PASS": @@ -5553,6 +5673,7 @@ def _translate_windsurf_schema(_agent: "_AgentEntry") -> Dict[str, Any]: # @cpt-end:cpt-studio-algo-project-extensibility-translate-agent-schema:p1:inst-per-tool-translators +# @cpt-begin:cpt-studio-algo-project-extensibility-translate-agent-schema:p1:inst-dispatch-table # Dispatch table: maps target tool name to per-tool translator function. _SCHEMA_TRANSLATOR_MAP: Dict[str, Any] = { "claude": _translate_claude_schema, @@ -5561,6 +5682,7 @@ def _translate_windsurf_schema(_agent: "_AgentEntry") -> Dict[str, Any]: "openai": _translate_codex_schema, "windsurf": _translate_windsurf_schema, } +# @cpt-end:cpt-studio-algo-project-extensibility-translate-agent-schema:p1:inst-dispatch-table # @cpt-begin:cpt-studio-algo-project-extensibility-translate-agent-schema:p1:inst-translate-agent-schema @@ -5600,6 +5722,7 @@ def translate_agent_schema(agent: "_AgentEntry", target: str) -> Dict[str, Any]: # @cpt-end:cpt-studio-algo-project-extensibility-translate-agent-schema:p1:inst-translate-agent-schema +# @cpt-begin:cpt-studio-algo-project-extensibility-generate-skills:p1:inst-skill-output-paths # Skill output paths per agent tool # All skills go to shared .agents/skills/ directory (readable by all agents) # Agent targeting is enforced via frontmatter metadata in the generated file @@ -5611,6 +5734,7 @@ def translate_agent_schema(agent: "_AgentEntry", target: str) -> Dict[str, Any]: "openai": ".agents/skills/{id}/SKILL.md", "windsurf": ".agents/skills/{id}/SKILL.md", } +# @cpt-end:cpt-studio-algo-project-extensibility-generate-skills:p1:inst-skill-output-paths # @cpt-begin:cpt-studio-algo-project-extensibility-generate-skills:p1:inst-read-source-content diff --git a/tests/test_agents_coverage.py b/tests/test_agents_coverage.py index 35e1b5d0..4075bd94 100644 --- a/tests/test_agents_coverage.py +++ b/tests/test_agents_coverage.py @@ -262,6 +262,209 @@ def fake_process(*_args, **kwargs): self.assertEqual(rc, 0) self.assertEqual(process.call_count, 1) + def test_no_change_preview_with_gitignore_update_still_returns_without_apply(self): + from studio.commands.agents import cmd_generate_agents + + with TemporaryDirectory() as td: + root = Path(td) / "project" + studio_root = root / ".cf-studio" + studio_root.mkdir(parents=True) + args = SimpleNamespace( + dry_run=False, + remove_cypilot="no", + discover=False, + show_layers=False, + yes=True, + ) + cfg = {"agents": {"cursor": {"workflows": {}, "skills": {}}}} + empty_result = { + "status": "PASS", + "agent": "cursor", + "workflows": {"created": [], "updated": [], "unchanged": [], "renamed": [], "deleted": [], "errors": []}, + "skills": {"created": [], "updated": [], "deleted": [], "skipped": [], "outputs": []}, + "subagents": {"created": [], "updated": [], "deleted": [], "skipped": False, "skip_reason": "", "outputs": []}, + "rules": {"created": [], "updated": [], "deleted": [], "outputs": []}, + "errors": None, + } + + with ( + patch( + "studio.commands.agents._resolve_agents_context", + return_value=(args, ["cursor"], root, studio_root, {}, None, cfg), + ), + 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", return_value=empty_result) as process, + patch("studio.commands.agents._refresh_managed_gitignore", return_value="updated"), + ): + rc = cmd_generate_agents([]) + + self.assertEqual(rc, 0) + self.assertEqual(process.call_count, 2) + + def test_dry_run_fatal_preview_returns_one(self): + from studio.commands.agents import cmd_generate_agents + + with TemporaryDirectory() as td: + root = Path(td) / "project" + studio_root = root / ".cf-studio" + studio_root.mkdir(parents=True) + args = SimpleNamespace( + dry_run=True, + remove_cypilot="no", + discover=False, + show_layers=False, + yes=True, + ) + cfg = {"agents": {"cursor": {"workflows": {}, "skills": {}}}} + fatal_result = { + "status": "PARTIAL", + "agent": "cursor", + "workflows": {"created": [], "updated": [], "unchanged": [], "renamed": [], "deleted": [], "errors": []}, + "skills": {"created": [], "updated": [], "deleted": [], "skipped": [], "outputs": []}, + "subagents": {"created": [], "updated": [], "deleted": [], "skipped": False, "skip_reason": "", "outputs": []}, + "rules": {"created": [], "updated": [], "deleted": [], "outputs": []}, + "errors": ["fatal"], + } + + with ( + patch( + "studio.commands.agents._resolve_agents_context", + return_value=(args, ["cursor"], root, studio_root, {}, None, cfg), + ), + 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", return_value=fatal_result), + patch("studio.commands.agents._refresh_managed_gitignore", return_value="updated"), + ): + rc = cmd_generate_agents([]) + + self.assertEqual(rc, 1) + + def test_interactive_abort_returns_one(self): + from studio.commands.agents import cmd_generate_agents + + with TemporaryDirectory() as td: + root = Path(td) / "project" + studio_root = root / ".cf-studio" + studio_root.mkdir(parents=True) + args = SimpleNamespace( + dry_run=False, + remove_cypilot="no", + discover=False, + show_layers=False, + yes=False, + ) + cfg = {"agents": {"cursor": {"workflows": {}, "skills": {}}}} + preview_result = { + "status": "PASS", + "agent": "cursor", + "workflows": {"created": ["/p/.cursor/commands/cf.md"], "updated": [], "unchanged": [], "renamed": [], "deleted": [], "errors": []}, + "skills": {"created": [], "updated": [], "deleted": [], "skipped": [], "outputs": []}, + "subagents": {"created": [], "updated": [], "deleted": [], "skipped": False, "skip_reason": "", "outputs": []}, + "rules": {"created": [], "updated": [], "deleted": [], "outputs": []}, + "errors": None, + } + + with ( + patch( + "studio.commands.agents._resolve_agents_context", + return_value=(args, ["cursor"], root, studio_root, {}, None, cfg), + ), + 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", return_value=preview_result) as process, + patch("studio.commands.agents._refresh_managed_gitignore", return_value=None), + patch("studio.commands.agents.sys.stdin.isatty", return_value=True), + patch("studio.utils.ui.is_json_mode", return_value=False), + patch("builtins.input", return_value="n"), + ): + rc = cmd_generate_agents([]) + + self.assertEqual(rc, 1) + self.assertEqual(process.call_count, 1) + + def test_v2_confirm_declined_returns_zero(self): + from studio.commands.agents import cmd_generate_agents + from studio.utils.manifest import ManifestLayerState + + with TemporaryDirectory() as td: + root = Path(td) / "project" + studio_root = root / ".cf-studio" + studio_root.mkdir(parents=True) + args = SimpleNamespace( + dry_run=False, + remove_cypilot="no", + discover=False, + show_layers=False, + yes=False, + ) + cfg = {"agents": {"cursor": {"workflows": {}, "skills": {}}}} + merged = SimpleNamespace(agents={}, skills={}) + layer = SimpleNamespace(path=studio_root / "config" / "manifest.toml", state=ManifestLayerState.LOADED, scope="kit") + + with ( + patch( + "studio.commands.agents._resolve_agents_context", + return_value=(args, ["cursor"], root, studio_root, {}, None, cfg), + ), + patch("studio.commands.agents._discover_layers", return_value=[layer]), + patch("studio.commands.agents._layers_have_v2_manifests", return_value=True), + patch("studio.commands.agents._resolve_includes_for_layers", return_value=([layer], False)), + patch("studio.commands.agents._merge_components", return_value=merged), + patch("studio.commands.agents.generate_manifest_agents", return_value={"created": [], "updated": [], "deleted": [], "outputs": []}), + patch("studio.commands.agents.generate_manifest_skills", return_value={"created": [], "updated": [], "deleted": [], "outputs": []}), + patch("studio.commands.agents._process_single_agent", return_value={"workflows": {}, "skills": {}, "subagents": {}, "status": "PASS", "errors": None}), + patch("studio.commands.agents._handle_show_layers_v2", return_value=None), + patch("studio.commands.agents._confirm_v2_generation", return_value=False), + patch("studio.commands.agents._refresh_managed_gitignore", return_value=None), + ): + rc = cmd_generate_agents([]) + + self.assertEqual(rc, 0) + + def test_legacy_discover_writes_manifest_before_preview(self): + from studio.commands.agents import cmd_generate_agents + + with TemporaryDirectory() as td: + root = Path(td) / "project" + studio_root = root / ".cf-studio" + studio_root.mkdir(parents=True) + args = SimpleNamespace( + dry_run=False, + remove_cypilot="no", + discover=True, + show_layers=False, + yes=True, + ) + cfg = {"agents": {"cursor": {"workflows": {}, "skills": {}}}} + empty_result = { + "status": "PASS", + "agent": "cursor", + "workflows": {"created": [], "updated": [], "unchanged": [], "renamed": [], "deleted": [], "errors": []}, + "skills": {"created": [], "updated": [], "deleted": [], "skipped": [], "outputs": []}, + "subagents": {"created": [], "updated": [], "deleted": [], "skipped": False, "skip_reason": "", "outputs": []}, + "rules": {"created": [], "updated": [], "deleted": [], "outputs": []}, + "errors": None, + } + + with ( + patch( + "studio.commands.agents._resolve_agents_context", + return_value=(args, ["cursor"], root, studio_root, {}, None, cfg), + ), + patch("studio.commands.agents._discover_layers", return_value=[]), + patch("studio.commands.agents._layers_have_v2_manifests", return_value=False), + patch("studio.commands.agents.discover_components", return_value={"skills": []}), + patch("studio.commands.agents.write_discovered_manifest") as write_manifest, + patch("studio.commands.agents._process_single_agent", return_value=empty_result), + patch("studio.commands.agents._refresh_managed_gitignore", return_value=None), + ): + rc = cmd_generate_agents([]) + + self.assertEqual(rc, 0) + write_manifest.assert_called_once() + class TestCanonicalKitPublicComponentGeneration(unittest.TestCase): """Canonical kit public components drive generated skills and agents.""" @@ -1358,6 +1561,29 @@ def test_no_agents_installed_hint(self): output = err.getvalue() self.assertIn("cfs generate-agents", output) + @_with_human_mode + def test_unchanged_outputs_count_as_existing_files(self): + from studio.commands.agents import _human_agents_list + import io + from contextlib import redirect_stderr + + results = { + "cursor": { + "workflows": {"updated": [], "unchanged": []}, + "skills": { + "updated": [], + "created": [], + "outputs": [{"path": "/p/.agents/skills/cf-extra/SKILL.md", "action": "unchanged"}], + }, + }, + } + err = io.StringIO() + with redirect_stderr(err): + _human_agents_list({}, ["cursor"], results, Path("/p")) + output = err.getvalue() + self.assertIn("installed", output) + self.assertIn("cf-extra/SKILL.md", output) + class TestHumanGenerateAgentsPreview(unittest.TestCase): """Cover lines 1166-1191 (_human_generate_agents_preview formatter).""" @@ -1482,6 +1708,29 @@ def test_preview_includes_workflow_renames_and_subagents(self): self.assertIn("workflow renamed", output) self.assertIn("cypilot-codegen.md", output) + @_with_human_mode + def test_preview_renders_deleted_workflow_updates_and_gitignore(self): + from studio.commands.agents import _human_generate_agents_preview + import io + from contextlib import redirect_stderr + + results = { + "claude": { + "workflows": {"created": [], "updated": [], "renamed": [], "deleted": ["/p/.claude/commands/cf-old.md"]}, + "skills": {"created": [], "updated": ["/p/.agents/skills/cf-extra/SKILL.md"], "deleted": []}, + "subagents": {"created": [], "updated": ["/p/.claude/agents/cf-sub.md"], "skipped": True, "skip_reason": "unsupported"}, + }, + } + err = io.StringIO() + with redirect_stderr(err): + _human_generate_agents_preview(["claude"], results, Path("/p"), gitignore_action="updated") + output = err.getvalue() + self.assertIn("cf-old.md", output) + self.assertIn("cf-extra/SKILL.md", output) + self.assertIn("cf-sub.md", output) + self.assertIn("subagents skipped: unsupported", output) + self.assertIn(".gitignore", output) + class TestHumanGenerateAgentsOk(unittest.TestCase): """Cover lines 1194-1252 (_human_generate_agents_ok formatter).""" @@ -1533,6 +1782,35 @@ def test_ok_dry_run(self): output = err.getvalue() self.assertIn("Dry run", output) + @_with_human_mode + def test_ok_renders_legacy_skills_and_gitignore(self): + from studio.commands.agents import _human_generate_agents_ok + import io + from contextlib import redirect_stderr + + results = { + "cursor": { + "status": "PASS", + "workflows": {"created": [], "updated": [], "deleted": [], "counts": {}}, + "skills": {"created": [], "updated": [], "deleted": [], "counts": {"created": 0, "updated": 0, "deleted": 0, "skipped": 0}}, + "legacy_skills": { + "created": ["/p/.agents/skills/cf-extra/SKILL.md"], + "updated": [], + "deleted": [], + "skipped": [], + "counts": {"created": 1, "updated": 0, "deleted": 0, "skipped": 0}, + }, + "subagents": {"created": [], "updated": [], "deleted": [], "counts": {"created": 0, "updated": 0, "deleted": 0}}, + "errors": [], + }, + } + err = io.StringIO() + with redirect_stderr(err): + _human_generate_agents_ok({"status": "PASS", "gitignore": "updated"}, ["cursor"], results, dry_run=True) + output = err.getvalue() + self.assertIn("cf-extra/SKILL.md", output) + self.assertIn(".gitignore", output) + @_with_human_mode def test_ok_dry_run_uses_future_tense_and_reports_subagents(self): from studio.commands.agents import _human_generate_agents_ok @@ -1575,6 +1853,101 @@ def test_ok_dry_run_uses_future_tense_and_reports_subagents(self): self.assertIn("would be preserved", output) self.assertIn("subagent file(s)", output) + @_with_human_mode + def test_ok_renders_extra_skill_and_subagent_branches_and_v2_fallback(self): + from studio.commands.agents import _human_generate_agents_ok + import io + from contextlib import redirect_stderr + + results = { + "claude": { + "status": "PASS", + "workflows": { + "created": [], + "updated": [], + "renamed": [], + "deleted": ["/p/.claude/commands/cf-old.md"], + "counts": {"created": 0, "updated": 0, "renamed": 0, "deleted": 1}, + }, + "skills": { + "created": [], + "updated": [], + "deleted": [], + "skipped": [], + "counts": {"created": 0, "updated": 0, "deleted": 0, "skipped": 0}, + }, + "legacy_skills": { + "created": [], + "updated": ["/p/.agents/skills/cf-extra/SKILL.md"], + "deleted": ["/p/.agents/skills/cf-legacy/SKILL.md"], + "skipped": ["preserve me"], + "counts": {"created": 0, "updated": 1, "deleted": 1, "skipped": 1}, + }, + "subagents": { + "created": [], + "updated": ["/p/.claude/agents/cf-sub.md"], + "deleted": ["/p/.claude/agents/cf-old-sub.md"], + "skipped": True, + "skip_reason": "unsupported", + "counts": {"created": 0, "updated": 1, "deleted": 1}, + }, + "v2_agents": { + "created": ["/p/.claude/agents/cf-v2-new.md"], + "updated": ["/p/.claude/agents/cf-v2-updated.md"], + "deleted": ["/p/.claude/agents/cf-v2-old.md"], + "warnings": ["plain warning"], + }, + "errors": [], + }, + } + err = io.StringIO() + with redirect_stderr(err): + _human_generate_agents_ok({"status": "PASS"}, ["claude"], results, dry_run=False) + output = err.getvalue() + self.assertIn("cf-extra/SKILL.md", output) + self.assertIn("cf-legacy/SKILL.md", output) + self.assertIn("cf-old-sub.md", output) + self.assertIn("subagents skipped: unsupported", output) + self.assertIn("cf-v2-new.md", output) + self.assertIn("cf-v2-updated.md", output) + self.assertIn("cf-v2-old.md", output) + self.assertIn("plain warning", output) + + @_with_human_mode + def test_ok_skips_non_dict_v2_output_and_renders_warning_variants(self): + from studio.commands.agents import _human_generate_agents_ok + import io + from contextlib import redirect_stderr + + results = { + "cursor": { + "status": "PASS", + "workflows": {"created": [], "updated": [], "renamed": [], "deleted": [], "counts": {}}, + "skills": {"created": [], "updated": [], "deleted": [], "skipped": [], "counts": {"created": 0, "updated": 0, "deleted": 0, "skipped": 0}}, + "subagents": {"created": [], "updated": [], "deleted": [], "counts": {"created": 0, "updated": 0, "deleted": 0}}, + "v2_agents": { + "outputs": [ + None, + {"path": "/p/.cursor/agents/cf-preserved.md", "action": "preserved", "reason": "keep"}, + ], + "warnings": [ + {"path": "/p/.cursor/agents/cf-other.md", "reason": "warned"}, + "string warning", + ], + }, + "errors": [], + }, + } + err = io.StringIO() + with redirect_stderr(err): + _human_generate_agents_ok({"status": "PASS"}, ["cursor"], results, dry_run=True) + output = err.getvalue() + self.assertIn("cf-preserved.md", output) + self.assertIn("keep", output) + self.assertIn("cf-other.md", output) + self.assertIn("warned", output) + self.assertIn("string warning", output) + @_with_human_mode def test_ok_with_errors(self): from studio.commands.agents import _human_generate_agents_ok @@ -4582,5 +4955,53 @@ def test_relative_target_outside_roots_is_skipped(self): self.assertIn("errors", result) +class TestManagedGitignoreRefresh(unittest.TestCase): + """Managed .gitignore refresh should pick up newly managed agent outputs.""" + + def test_refresh_managed_gitignore_updates_block(self): + from studio.commands.agents import _refresh_managed_gitignore + from studio.commands.init import _write_gitignore_block + + with TemporaryDirectory() as td: + root = Path(td) / "proj" + root.mkdir() + studio_root = root / ".cf-studio" + (studio_root / "config").mkdir(parents=True) + core_toml = studio_root / "config" / "core.toml" + core_toml.write_text( + 'version = "1.0"\nproject_root = ".."\n\n[install]\nkit_tracking = "tracked"\nruntime_tracking = "ignored"\nagent_tracking = "ignored"\n', + encoding="utf-8", + ) + + with patch("studio.commands.init.list_managed_agent_output_paths", return_value=[".agents/skills/cf-core/SKILL.md"]): + _write_gitignore_block(root, ".cf-studio", core_toml, "tracked", dry_run=False) + + with patch( + "studio.commands.init.list_managed_agent_output_paths", + return_value=[".agents/skills/cf-core/SKILL.md", ".agents/skills/cf-extra/SKILL.md"], + ): + action = _refresh_managed_gitignore(root, studio_root, dry_run=False) + + self.assertEqual(action, "updated") + gitignore = (root / ".gitignore").read_text(encoding="utf-8") + self.assertIn(".agents/skills/cf-extra/SKILL.md", gitignore) + + +class TestResultHasFatalErrors(unittest.TestCase): + """Covers nested-section fallback in _result_has_fatal_errors.""" + + def test_fallback_scans_nested_sections_when_top_level_errors_missing(self): + from studio.commands.agents import _result_has_fatal_errors + + result = { + "status": "PARTIAL", + "workflows": {"errors": []}, + "skills": {"errors": ["fatal"]}, + "subagents": {"errors": []}, + } + + self.assertTrue(_result_has_fatal_errors(result)) + + if __name__ == "__main__": unittest.main()