diff --git a/.bootstrap/config/core.toml b/.bootstrap/config/core.toml index b3ef373f..e0e8b0f7 100644 --- a/.bootstrap/config/core.toml +++ b/.bootstrap/config/core.toml @@ -174,10 +174,6 @@ examples = "feature_example" template = "prd_template" examples = "prd_example" -[kits.sdlc.resources.skill] -path = "config/kits/sdlc/SKILL.md" -kind = "skill" - [kits.sdlc.resources.agents] path = "config/kits/sdlc/AGENTS.md" kind = "rule" diff --git a/skills/studio/scripts/studio/commands/agents.py b/skills/studio/scripts/studio/commands/agents.py index 94c4be3f..55fe644f 100644 --- a/skills/studio/scripts/studio/commands/agents.py +++ b/skills/studio/scripts/studio/commands/agents.py @@ -1707,15 +1707,6 @@ def _default_agents_config() -> dict: "skills": { "custom_content": "", "outputs": shared_skills + [ - { - "path": ".github/copilot-instructions.md", - "template": [ - "# Constructor Studio", - _GENERATED_MARKER, - "", - "{custom_content}", - ], - }, { "path": ".github/prompts/cf.prompt.md", "template": [ @@ -2471,15 +2462,6 @@ def _has_non_openai_install_signal(project_root: Path) -> bool: if _file_has_studio_follow_link(project_root / ".cursor" / "rules" / "studio.mdc"): return True - legacy_ci = project_root / ".github" / "copilot-instructions.md" - if legacy_ci.is_file(): - try: - ci_text = legacy_ci.read_text(encoding="utf-8") - if ci_text.startswith("# Constructor Studio") or ci_text.startswith("# Studio"): - return True - except (OSError, UnicodeDecodeError): - pass - return ( (project_root / ".github" / "prompts" / "cf.prompt.md").is_file() or (project_root / ".github" / "prompts" / "studio.prompt.md").is_file() @@ -2597,18 +2579,8 @@ def _is_agent_installed(agent: str, project_root: Path) -> bool: return True # ── Legacy Copilot fallback ─────────────────────────────────────────── - # A Constructor Studio-managed copilot-instructions.md (starts with - # "# Constructor Studio" or legacy "# Studio") is a valid signal from - # pre-marker installs. Also detect via prompts file. + # Detect via prompts files and legacy install markers only. if agent == "copilot": - legacy_ci = project_root / ".github" / "copilot-instructions.md" - if legacy_ci.is_file(): - try: - ci_text = legacy_ci.read_text(encoding="utf-8") - if ci_text.startswith("# Constructor Studio") or ci_text.startswith("# Studio"): - return True - except (OSError, UnicodeDecodeError): - pass for prompt_name in ("cf.prompt.md", "studio.prompt.md", "cypilot.prompt.md"): if (project_root / ".github" / "prompts" / prompt_name).is_file(): return True @@ -3381,21 +3353,6 @@ def _process_skills( continue out_path, rel_path, content = rendered - # Guard: skip overwriting user-authored copilot-instructions.md. - # The file is only Constructor Studio-managed when it starts with "# Constructor Studio" or legacy "# Studio". - if rel_path == ".github/copilot-instructions.md" and out_path.is_file(): - try: - existing = out_path.read_text(encoding="utf-8") - except OSError: - existing = "" - if not existing.startswith("# Constructor Studio") and not existing.startswith("# Studio"): - rel = _safe_relpath(out_path.resolve(), project_root) - skills_result["skipped"].append( - f"{rel} (user-authored, not overwriting)" - ) - skills_result["_copilot_user_authored"] = True - continue - _write_or_skip(out_path, content, skills_result, project_root, dry_run) return skills_result @@ -3798,6 +3755,85 @@ def _process_kit_public_agents_and_rules( return {"agents": public_agents, "rules": public_rules} +def _collect_managed_result_paths( + project_root: Path, + section: Dict[str, Any], +) -> Set[str]: + """Collect normalized project-relative output paths from a generator result.""" + paths: Set[str] = set() + for key in ("created", "updated", "unchanged", "deleted"): + values = section.get(key, []) + if not isinstance(values, list): + continue + for value in values: + if isinstance(value, tuple): + candidates = [value[-1]] + else: + candidates = [value] + for candidate in candidates: + if not isinstance(candidate, str) or not candidate.strip(): + continue + candidate_path = Path(candidate) + if candidate_path.is_absolute(): + paths.add(_safe_relpath(candidate_path, project_root)) + else: + paths.add(candidate.replace("\\", "/").strip("/")) + for output in section.get("outputs", []): + if not isinstance(output, dict): + continue + raw_path = output.get("path") + if not isinstance(raw_path, str) or not raw_path.strip(): + continue + candidate_path = Path(raw_path) + if candidate_path.is_absolute(): + paths.add(_safe_relpath(candidate_path, project_root)) + else: + paths.add(raw_path.replace("\\", "/").strip("/")) + return {path for path in paths if 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.""" + cfg = _default_agents_config() + managed_paths: Set[str] = set() + + for marker_path, _marker_content in _INSTALL_MARKERS.values(): + managed_paths.add(marker_path) + + 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 + if isinstance(outputs, list): + for output in outputs: + 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)) + 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)) + + return sorted(path for path in managed_paths if path) + + def _process_legacy_cleanup( agent: str, project_root: Path, @@ -3924,9 +3960,8 @@ def _process_legacy_cleanup( # @cpt-begin:cpt-studio-algo-agent-integration-generate-shims:p1:inst-write-install-marker # Tools that share generic directories need a unique Constructor Studio-specific # marker so detection/regeneration can distinguish Constructor Studio installs from - # unrelated user files. The marker is always created when the agent is - # processed — even if copilot-instructions.md was preserved as user- - # authored, the other generated outputs (prompts, shared skills, agents) + # unrelated user files. The marker is always created when the agent is + # processed because the other generated outputs (prompts, shared skills, agents) # still need to be managed by future `cfs update` runs. marker_info = _INSTALL_MARKERS.get(agent) if marker_info and not dry_run: diff --git a/skills/studio/scripts/studio/commands/init.py b/skills/studio/scripts/studio/commands/init.py index 33be0e4a..a60f6eba 100644 --- a/skills/studio/scripts/studio/commands/init.py +++ b/skills/studio/scripts/studio/commands/init.py @@ -14,6 +14,7 @@ from ..utils.artifacts_meta import create_backup, generate_default_registry, generate_slug from ..utils import toml_utils from ..utils.ui import ui +from .agents import list_managed_agent_output_paths # Cache-managed install content: core directories go into .core/, root files go into the install root. # Full directories (copied entirely) @@ -360,6 +361,7 @@ def _ignored_kit_paths(core_toml_path: Path, default: str = "tracked") -> List[s def _gitignore_patterns( + project_root: Path, install_dir: str, ignored_kit_paths: List[str], runtime_tracking: str = "ignored", @@ -373,56 +375,9 @@ def _gitignore_patterns( f"{install_rel}/{GEN_SUBDIR}/", ]) if agent_tracking == "ignored": - patterns.extend([ - ".agents/skills/cf/", - ".agents/skills/cf-*/", - ".agents/skills/studio-*/", - ".agents/skills/cypilot-*/", - ".agents/skills/cf-constructor-*/", - ".codex/agents/cf*.toml", - ".codex/agents/studio-*.toml", - ".codex/agents/cypilot-*.toml", - ".codex/agents/cf-constructor-*.toml", - ".codex/agents/storytelling-*.toml", - ".codex/.cf-installed", - ".codex/.constructor-studio-installed", - ".claude/skills/cf/", - ".claude/skills/cf-*/", - ".claude/commands/cf*.md", - ".claude/commands/studio-*.md", - ".claude/commands/cypilot-*.md", - ".claude/commands/cf-constructor-*.md", - ".claude/agents/cf*.md", - ".claude/agents/studio-*.md", - ".claude/agents/cypilot-*.md", - ".claude/agents/cf-constructor-*.md", - ".claude/agents/storytelling-*.md", - ".cursor/commands/cf*.md", - ".cursor/commands/studio-*.md", - ".cursor/commands/cypilot-*.md", - ".cursor/commands/cf-constructor-*.md", - ".cursor/agents/cf*.md", - ".cursor/agents/studio-*.md", - ".cursor/agents/cypilot-*.md", - ".cursor/agents/cf-constructor-*.md", - ".cursor/agents/storytelling-*.md", - ".github/prompts/cf*.prompt.md", - ".github/prompts/studio-*.prompt.md", - ".github/prompts/cypilot-*.prompt.md", - ".github/prompts/cf-constructor-*.prompt.md", - ".github/agents/cf*.md", - ".github/agents/studio-*.md", - ".github/agents/cypilot-*.md", - ".github/agents/cf-constructor-*.md", - ".github/agents/storytelling-*.md", - ".github/.cf-installed", - ".github/.constructor-studio-installed", - ".github/copilot-instructions.md", - ".windsurf/workflows/cf*.md", - ".windsurf/workflows/studio-*.md", - ".windsurf/workflows/cypilot-*.md", - ".windsurf/workflows/cf-constructor-*.md", - ]) + patterns.extend( + list_managed_agent_output_paths(project_root, project_root / install_rel) + ) for kit_path in ignored_kit_paths: kit_rel = kit_path.strip().replace("\\", "/").strip("/") if kit_rel: @@ -431,6 +386,7 @@ def _gitignore_patterns( def _compute_gitignore_block( + project_root: Path, install_dir: str, ignored_kit_paths: List[str], runtime_tracking: str = "ignored", @@ -442,6 +398,7 @@ def _compute_gitignore_block( "# Generated Constructor Studio runtime and agent integration files.", "# Files matched here are owned by Constructor Studio and may be overwritten.", *_gitignore_patterns( + project_root, install_dir, ignored_kit_paths, runtime_tracking=runtime_tracking, @@ -462,6 +419,7 @@ def _write_gitignore_block( ) -> str: gitignore_path = project_root / ".gitignore" expected_block = _compute_gitignore_block( + project_root, install_dir, _ignored_kit_paths(core_toml_path, default=default_kit_tracking), runtime_tracking=_read_install_tracking(core_toml_path, "runtime_tracking", default="ignored"), diff --git a/skills/studio/scripts/studio/commands/kit.py b/skills/studio/scripts/studio/commands/kit.py index 1834b48b..7f4c4276 100644 --- a/skills/studio/scripts/studio/commands/kit.py +++ b/skills/studio/scripts/studio/commands/kit.py @@ -687,6 +687,7 @@ def _is_registered_kit_path_absolute(registered_kit_path: str) -> bool: _is_posix_absolute_path(registered_kit_path) or _is_windows_absolute_path(registered_kit_path) ) +# @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata-fn # @cpt-begin:cpt-studio-algo-kit-manifest-install:p1:inst-manifest-persist-relative-only @@ -916,32 +917,6 @@ def _registered_slug_for_local_kit_path( return matches[0] if len(matches) == 1 else "" -def _collect_kit_metadata( - config_kit_dir: Optional[Path], - kit_slug: str, - registered_kit_path: Optional[str] = None, -) -> Dict[str, str]: - """Read installed kit files and return metadata for .gen/ aggregation. - - Returns dict with: - agents_content — raw content of kit's AGENTS.md for ``.gen/AGENTS.md`` - """ - # @cpt-begin:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata - del kit_slug, registered_kit_path - result: Dict[str, str] = {"skill_nav": "", "agents_content": ""} - - agents_path = config_kit_dir / _KIT_AGENTS_FILE if config_kit_dir is not None else None - if agents_path is not None and agents_path.is_file(): - try: - result["agents_content"] = agents_path.read_text(encoding="utf-8") - except OSError: - pass - - return result - # @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata -# @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata-fn - - def _binding_is_public_metadata_resource(binding: Dict[str, Any], kind: str) -> bool: public_value = binding.get("public") if isinstance(public_value, bool): @@ -951,6 +926,7 @@ def _binding_is_public_metadata_resource(binding: Dict[str, Any], kind: str) -> return kind in {"skill", "rule"} +# @cpt-begin:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata def _collect_registered_kit_metadata( studio_dir: Path, kit_slug: str, @@ -996,12 +972,7 @@ def _collect_registered_kit_metadata( except (OSError, ValueError): resources = {} if not isinstance(resources, dict) or not resources: - kit_dir, kit_rel_path = _resolve_registered_kit_metadata_target( - studio_dir, - kit_slug, - kit_entry, - ) - return _collect_kit_metadata(kit_dir, kit_slug, kit_rel_path) + return {"skill_nav": "", "agents_content": ""} result: Dict[str, str] = {"skill_nav": "", "agents_content": ""} agents_parts: List[str] = [] @@ -1034,6 +1005,7 @@ def _collect_registered_kit_metadata( pass result["agents_content"] = "\n\n".join(part for part in agents_parts if part) return result +# @cpt-end:cpt-studio-algo-kit-content-mgmt:p1:inst-collect-metadata # --------------------------------------------------------------------------- @@ -4240,19 +4212,23 @@ def _sync_manifest_resource_bindings( config_dir: Path, kit_slug: str, ) -> Optional[Dict[str, Dict[str, Any]]]: - """Merge existing resource bindings with any new manifest resources. + """Sync resource bindings to the current manifest declaration set. - Returns merged bindings dict, or None if there is no manifest. + Returns synced bindings dict, or None if there is no manifest. """ if manifest is None: return None existing_raw = _read_kits_from_core_toml(config_dir).get(kit_slug, {}).get("resources", {}) - merged: Dict[str, Dict[str, Any]] = {} + existing: Dict[str, Dict[str, Any]] = {} for res_id, binding in existing_raw.items(): if isinstance(binding, dict): - merged[res_id] = binding + existing[res_id] = dict(binding) elif isinstance(binding, str): - merged[res_id] = {"path": binding} + existing[res_id] = {"path": binding} + merged: Dict[str, Dict[str, Any]] = {} + for res in getattr(manifest, "resources", []): + if getattr(res, "id", None) in existing: + merged[str(res.id)] = dict(existing[str(res.id)]) kit_root_rel = _resolve_manifest_kit_root_rel(manifest, merged, kit_slug) for res in getattr(manifest, "resources", []): if res.id not in merged: diff --git a/tests/test_agents_coverage.py b/tests/test_agents_coverage.py index 8e23d1f0..dbcbc176 100644 --- a/tests/test_agents_coverage.py +++ b/tests/test_agents_coverage.py @@ -1152,7 +1152,7 @@ def test_detects_cursor_follow_link(self): self.assertTrue(_has_non_openai_install_signal(root)) - def test_detects_legacy_copilot_instructions(self): + def test_ignores_legacy_copilot_instructions(self): from studio.commands.agents import _has_non_openai_install_signal with TemporaryDirectory() as tmpdir: @@ -1161,7 +1161,7 @@ def test_detects_legacy_copilot_instructions(self): legacy_ci.parent.mkdir(parents=True) legacy_ci.write_text("# Constructor Studio\nLegacy instructions\n", encoding="utf-8") - self.assertTrue(_has_non_openai_install_signal(root)) + self.assertFalse(_has_non_openai_install_signal(root)) def test_ignores_legacy_copilot_read_errors(self): from studio.commands.agents import _has_non_openai_install_signal @@ -1263,8 +1263,8 @@ def test_agents_not_configured(self): results = { "copilot": { - "workflows": {"updated": [], "created": ["/p/.github/prompts/cypilot-generate.prompt.md"]}, - "skills": {"updated": [], "created": ["/p/.github/copilot-instructions.md"]}, + "workflows": {"updated": [], "created": ["/p/.github/prompts/cf-generate.prompt.md"]}, + "skills": {"updated": [], "created": ["/p/.github/prompts/cf.prompt.md"]}, }, } err = io.StringIO() @@ -2185,8 +2185,8 @@ def test_copilot_detected_via_marker_file(self): from studio.commands.agents import _AGENT_MARKERS self.assertIn(".github/.cf-installed", _AGENT_MARKERS.get("copilot", [])) - def test_copilot_generates_repo_wide_instructions(self): - """Copilot generation must produce .github/copilot-instructions.md (always-on).""" + def test_copilot_generates_prompt_entrypoint(self): + """Copilot generation must produce the shared prompt entrypoint and marker.""" from studio.commands.agents import _process_single_agent, _default_agents_config with TemporaryDirectory() as td: @@ -2201,44 +2201,14 @@ def test_copilot_generates_repo_wide_instructions(self): (cpt / ".core" / "workflows").mkdir(parents=True, exist_ok=True) cfg = _default_agents_config() _process_single_agent("copilot", root, cpt, cfg, None, dry_run=False) - instructions = root / ".github" / "copilot-instructions.md" - self.assertTrue(instructions.exists(), ".github/copilot-instructions.md must be generated") - content = instructions.read_text(encoding="utf-8") - self.assertNotIn("ALWAYS open and follow", content) + prompt = root / ".github" / "prompts" / "cf.prompt.md" + self.assertTrue(prompt.exists(), ".github/prompts/cf.prompt.md must be generated") # Marker file must also be created marker = root / ".github" / ".cf-installed" self.assertTrue(marker.exists(), ".github/.cf-installed marker must be created") - def test_copilot_cleanup_removes_legacy_skill_follow_line(self): - """Regeneration must clean the old generated follow-line from copilot-instructions.md.""" - from studio.commands.agents import _process_single_agent, _default_agents_config - - with TemporaryDirectory() as td: - root = (Path(td) / "proj").resolve() - root.mkdir() - (root / ".git").mkdir() - cpt = root / "cypilot" - cpt.mkdir() - core_skill = cpt / ".core" / "skills" / "studio" / "SKILL.md" - core_skill.parent.mkdir(parents=True) - core_skill.write_text("---\nname: studio\ndescription: Test\n---\nContent\n") - (cpt / ".core" / "workflows").mkdir(parents=True, exist_ok=True) - - instructions = root / ".github" / "copilot-instructions.md" - instructions.parent.mkdir(parents=True, exist_ok=True) - instructions.write_text( - "# Constructor Studio\n\nALWAYS open and follow `{cf-studio-path}/.core/skills/studio/SKILL.md`\n", - encoding="utf-8", - ) - - cfg = _default_agents_config() - _process_single_agent("copilot", root, cpt, cfg, None, dry_run=False) - - content = instructions.read_text(encoding="utf-8") - self.assertNotIn("ALWAYS open and follow", content) - def test_user_authored_copilot_instructions_preserved(self): - """User-authored copilot-instructions.md must NOT be overwritten by generation.""" + """User-authored copilot-instructions.md must be left untouched by generation.""" from studio.commands.agents import _process_single_agent, _default_agents_config with TemporaryDirectory() as td: @@ -2263,12 +2233,8 @@ def test_user_authored_copilot_instructions_preserved(self): # User content must be preserved self.assertEqual(instructions.read_text(encoding="utf-8"), user_content) - # Should appear in skipped skipped = result.get("skills", {}).get("skipped", []) - self.assertTrue( - any("user-authored" in s for s in skipped), - f"Expected user-authored skip notice, got: {skipped}", - ) + self.assertFalse(skipped, f"Did not expect copilot-instructions handling, got: {skipped}") # Marker IS created even when copilot-instructions.md is user-authored, # because other Copilot outputs (prompts, shared skills) are still managed. marker = root / ".github" / ".cf-installed" @@ -2324,12 +2290,12 @@ def test_legacy_windsurf_non_cypilot_not_detected(self): self.assertFalse(_is_agent_installed("windsurf", root)) def test_legacy_copilot_fallback_detection(self): - """Shared _is_agent_installed has legacy Copilot fallback via Constructor Studio-managed copilot-instructions.md.""" + """Shared _is_agent_installed must no longer rely on copilot-instructions.md.""" import importlib src = importlib.util.find_spec("studio.commands.agents").origin source = Path(src).read_text(encoding="utf-8") - self.assertIn("legacy_ci", source) - self.assertIn('startswith("# Constructor Studio")', source) + self.assertNotIn("legacy_ci", source) + self.assertNotIn('startswith("# Constructor Studio")', source) def test_openai_legacy_requires_codex_agents_content(self): """Shared _is_agent_installed requires .codex/agents/ with content, not bare .codex/ directory.""" diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index ec932765..c49a1138 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -1471,7 +1471,6 @@ def test_all_agents_use_at_path(self): ], "copilot": [ ".agents/skills/cf/SKILL.md", - ".github/copilot-instructions.md", ".github/prompts/cf.prompt.md", ], "cursor": [ diff --git a/tests/test_init_update_footprint.py b/tests/test_init_update_footprint.py index 31a76164..a6d4d73a 100644 --- a/tests/test_init_update_footprint.py +++ b/tests/test_init_update_footprint.py @@ -186,7 +186,8 @@ def fake_install_default_kit(studio_dir, _interactive, _actions, _errors): assert ".bootstrap/config/kits/sdlc/" in gitignore assert ".bootstrap/config/kits/\n" not in gitignore assert ".github/\n" not in gitignore - assert ".github/prompts/cf*.prompt.md" in gitignore + assert ".github/prompts/cf.prompt.md" in gitignore + assert ".github/copilot-instructions.md" not in gitignore core = (root / ".bootstrap" / "config" / "core.toml").read_text(encoding="utf-8") assert 'tracking = "ignored"' in core assert (root / ".bootstrap" / "whatsnew.toml").is_file() @@ -226,8 +227,8 @@ def test_init_tracked_runtime_and_agents_are_not_gitignored(): gitignore = (root / ".gitignore").read_text(encoding="utf-8") assert ".bootstrap/.core/" not in gitignore assert ".bootstrap/.gen/" not in gitignore - assert ".codex/agents/cf*.toml" not in gitignore - assert ".github/prompts/cf*.prompt.md" not in gitignore + assert ".codex/.cf-installed" not in gitignore + assert ".github/prompts/cf.prompt.md" not in gitignore core = (root / ".bootstrap" / "config" / "core.toml").read_text(encoding="utf-8") assert 'runtime_tracking = "tracked"' in core assert 'agent_tracking = "tracked"' in core diff --git a/tests/test_kit.py b/tests/test_kit.py index f4b09a39..11ab372d 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -5092,57 +5092,7 @@ def test_existing_dir_overwritten(self): self.assertFalse((dst / "artifacts" / "PRD" / "old.md").exists()) -# --------------------------------------------------------------------------- -# _collect_kit_metadata OSError -# --------------------------------------------------------------------------- - -class TestCollectKitMetadataOsError(unittest.TestCase): - def test_agents_read_oserror(self): - from studio.commands.kit import _collect_kit_metadata - with TemporaryDirectory() as td: - kit_dir = Path(td) / "sdlc" - kit_dir.mkdir() - agents = kit_dir / "AGENTS.md" - agents.mkdir() # directory, not file — read will fail - meta = _collect_kit_metadata(kit_dir, "sdlc") - self.assertEqual(meta["agents_content"], "") - - def test_skill_nav_ignores_registered_custom_path(self): - from studio.commands.kit import _collect_kit_metadata - with TemporaryDirectory() as td: - kit_dir = Path(td) / "custom-kits" / "sdlc" - kit_dir.mkdir(parents=True) - (kit_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8") - meta = _collect_kit_metadata(kit_dir, "sdlc", "custom-kits/sdlc") - self.assertEqual(meta["skill_nav"], "") - - def test_skill_nav_ignores_absolute_registered_custom_path(self): - from studio.commands.kit import _collect_kit_metadata - with TemporaryDirectory() as td: - kit_dir = Path(td) / "custom-kits" / "sdlc" - kit_dir.mkdir(parents=True) - (kit_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8") - meta = _collect_kit_metadata(kit_dir, "sdlc", kit_dir.as_posix()) - self.assertEqual(meta["skill_nav"], "") - - def test_skill_nav_ignores_windows_drive_registered_custom_path(self): - from studio.commands.kit import _collect_kit_metadata - with TemporaryDirectory() as td: - kit_dir = Path(td) / "custom-kits" / "sdlc" - kit_dir.mkdir(parents=True) - (kit_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8") - meta = _collect_kit_metadata(kit_dir, "sdlc", "C:/external-kits/sdlc") - self.assertEqual(meta["skill_nav"], "") - - def test_skill_nav_ignores_windows_backslash_registered_custom_path(self): - from studio.commands.kit import _collect_kit_metadata - with TemporaryDirectory() as td: - kit_dir = Path(td) / "custom-kits" / "sdlc" - kit_dir.mkdir(parents=True) - (kit_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8") - meta = _collect_kit_metadata(kit_dir, "sdlc", r"C:\external-kits\sdlc") - self.assertEqual(meta["skill_nav"], "") - +class TestCollectRegisteredKitMetadata(unittest.TestCase): def test_registered_resource_metadata_uses_public_bindings_only(self): from studio.commands.kit import _collect_registered_kit_metadata @@ -5824,7 +5774,7 @@ def test_ignores_unregistered_config_kit_dirs(self): self.assertFalse((adapter / ".gen" / "SKILL.md").exists()) self.assertNotIn("# Loose Agents", gen_agents) - def test_uses_default_installed_kit_path_when_path_not_explicitly_registered(self): + def test_skips_default_installed_kit_path_when_resources_not_registered(self): from studio.commands.kit import regenerate_gen_aggregates from studio.utils import toml_utils with TemporaryDirectory() as td: @@ -5852,7 +5802,7 @@ def test_uses_default_installed_kit_path_when_path_not_explicitly_registered(sel gen_agents = (adapter / ".gen" / "AGENTS.md").read_text(encoding="utf-8") self.assertFalse((adapter / ".gen" / "SKILL.md").exists()) - self.assertIn("# Default Agents", gen_agents) + self.assertNotIn("# Default Agents", gen_agents) def test_deletes_legacy_generated_skill_aggregate(self): from studio.commands.kit import regenerate_gen_aggregates @@ -5876,7 +5826,7 @@ def test_deletes_legacy_generated_skill_aggregate(self): self.assertEqual(result["gen_skill"], "deleted") self.assertFalse(legacy_skill.exists()) - def test_uses_registered_custom_kit_path(self): + def test_skips_registered_custom_kit_path_when_resources_not_registered(self): from studio.commands.kit import regenerate_gen_aggregates from studio.utils import toml_utils with TemporaryDirectory() as td: @@ -5905,9 +5855,9 @@ def test_uses_registered_custom_kit_path(self): gen_agents = (adapter / ".gen" / "AGENTS.md").read_text(encoding="utf-8") self.assertFalse((adapter / ".gen" / "SKILL.md").exists()) - self.assertIn("# Custom Agents", gen_agents) + self.assertNotIn("# Custom Agents", gen_agents) - def test_uses_registered_absolute_custom_kit_path(self): + def test_skips_registered_absolute_custom_kit_path_when_resources_not_registered(self): from studio.commands.kit import regenerate_gen_aggregates from studio.utils import toml_utils with TemporaryDirectory() as td: @@ -5936,7 +5886,7 @@ def test_uses_registered_absolute_custom_kit_path(self): gen_agents = (adapter / ".gen" / "AGENTS.md").read_text(encoding="utf-8") self.assertFalse((adapter / ".gen" / "SKILL.md").exists()) - self.assertIn("# Custom Agents", gen_agents) + self.assertNotIn("# Custom Agents", gen_agents) def test_uses_registered_windows_drive_custom_kit_path(self): from studio.commands.kit import regenerate_gen_aggregates diff --git a/tests/test_kit_manifest_install.py b/tests/test_kit_manifest_install.py index 2bbe40a7..fe78af7f 100644 --- a/tests/test_kit_manifest_install.py +++ b/tests/test_kit_manifest_install.py @@ -281,6 +281,46 @@ def test_sync_manifest_resource_bindings_preserves_artifact_bindings(self): ) self.assertNotIn("artifacts", merged["feature-template"]) + def test_sync_manifest_resource_bindings_drops_removed_resources(self): + from studio.commands.kit import _sync_manifest_resource_bindings + from studio.utils import toml_utils + + with TemporaryDirectory() as td: + config = Path(td) / "config" + config.mkdir() + toml_utils.dump({ + "version": "1.0", + "project_root": "..", + "kits": { + "mykit": { + "format": "CFS", + "path": "config/kits/mykit", + "resources": { + "skill": {"path": "config/kits/mykit/SKILL.md"}, + "stale": {"path": "config/kits/mykit/stale.md"}, + }, + }, + }, + }, config / "core.toml") + manifest = SimpleNamespace( + root="{cf-studio-path}/config/kits/{slug}", + resources=[ + SimpleNamespace( + id="skill", + kind="skill", + install_path="SKILL.md", + default_path="SKILL.md", + public=False, + artifact_bindings={}, + ), + ], + ) + + merged = _sync_manifest_resource_bindings(manifest, config, "mykit") + + assert merged is not None + self.assertEqual(set(merged.keys()), {"skill"}) + def test_manifest_resource_bindings_writer_persists_artifact_bindings(self): from studio.commands.kit import _manifest_resource_bindings diff --git a/tests/test_update.py b/tests/test_update.py index d7d68aa7..205a8c47 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1966,8 +1966,8 @@ def test_copilot_detected_via_cypilot_installed_marker(self): ) self.assertIn("copilot", result) - def test_legacy_copilot_detected_via_managed_instructions(self): - """Legacy Copilot install detected via Constructor Studio-managed copilot-instructions.md.""" + def test_legacy_copilot_instructions_do_not_trigger_detection(self): + """Legacy copilot-instructions.md alone must not trigger Copilot detection.""" from studio.commands.update import _maybe_regenerate_agents with TemporaryDirectory() as td: root = Path(td) / "proj" @@ -1984,7 +1984,7 @@ def test_legacy_copilot_detected_via_managed_instructions(self): result = _maybe_regenerate_agents( {"skills": "updated"}, {}, root, cypilot_dir, ) - self.assertIn("copilot", result) + self.assertNotIn("copilot", result) def test_user_copilot_instructions_does_not_trigger_detection(self): """User-authored .github/copilot-instructions.md must NOT trigger Copilot detection."""