Skip to content

Commit e8e4119

Browse files
minbang930codex
andcommitted
fix: recover active skills registration for extensions
Extension command registration now resolves the active skills directory before writing command artifacts. This lets initialized skills-backed agents recover a missing active skills directory while preserving the existing preset registration behavior. Add regression coverage for missing active skills directories, shared skills directories, and symlinked parent guards. Fixes #2769. Co-authored-by: OpenAI Codex <codex@openai.com>
1 parent ed10b32 commit e8e4119

8 files changed

Lines changed: 537 additions & 53 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@
102102
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
103103
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
104104
)
105+
from ._init_options import (
106+
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
107+
is_ai_skills_enabled as _is_ai_skills_enabled,
108+
load_init_options as load_init_options,
109+
save_init_options as save_init_options,
110+
)
105111

106112
app = typer.Typer(
107113
name="specify",
@@ -275,35 +281,6 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
275281
for f in failures:
276282
console.print(f" - {f}")
277283

278-
INIT_OPTIONS_FILE = ".specify/init-options.json"
279-
280-
281-
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
282-
"""Persist the CLI options used during ``specify init``.
283-
284-
Writes a small JSON file to ``.specify/init-options.json`` so that
285-
later operations (e.g. preset install) can adapt their behaviour
286-
without scanning the filesystem.
287-
"""
288-
dest = project_path / INIT_OPTIONS_FILE
289-
dest.parent.mkdir(parents=True, exist_ok=True)
290-
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
291-
292-
293-
def load_init_options(project_path: Path) -> dict[str, Any]:
294-
"""Load the init options previously saved by ``specify init``.
295-
296-
Returns an empty dict if the file does not exist or cannot be parsed.
297-
"""
298-
path = project_path / INIT_OPTIONS_FILE
299-
if not path.exists():
300-
return {}
301-
try:
302-
return json.loads(path.read_text())
303-
except (json.JSONDecodeError, OSError):
304-
return {}
305-
306-
307284
# ---------------------------------------------------------------------------
308285
# Agent-context extension config helpers
309286
# ---------------------------------------------------------------------------
@@ -387,10 +364,10 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
387364
"""Return the active skills directory, creating it on demand when enabled.
388365
389366
Reads ``.specify/init-options.json`` to determine whether skills are
390-
enabled and which agent was selected. When ``ai_skills`` is true the
391-
directory is created safely (symlink/containment checks); when false
392-
only Kimi's native-skills fallback is honoured (directory must already
393-
exist).
367+
enabled and which agent was selected. Only ``ai_skills`` set to boolean
368+
``True`` creates the directory safely (symlink/containment checks); when
369+
``ai_skills`` is not boolean ``True``, only Kimi's native-skills fallback
370+
is honoured, and the native skills directory must already exist.
394371
395372
Returns:
396373
The skills directory ``Path``, or ``None`` if skills are not active.
@@ -411,14 +388,15 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
411388
if not isinstance(agent, str) or not agent:
412389
return None
413390

414-
ai_skills_enabled = bool(opts.get("ai_skills"))
391+
ai_skills_enabled = _is_ai_skills_enabled(opts)
415392
if not ai_skills_enabled and agent != "kimi":
416393
return None
417394

418395
skills_dir = _get_skills_dir(project_root, agent)
419396

420397
if not ai_skills_enabled:
421-
# Kimi native-skills fallback: use the directory only if it exists.
398+
# Kimi native-skills fallback when ai_skills is not boolean True:
399+
# use the native skills directory only if it already exists.
422400
if not skills_dir.is_dir():
423401
return None
424402
_ensure_safe_shared_directory(
@@ -427,7 +405,7 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
427405
)
428406
return skills_dir
429407

430-
# ai_skills is explicitly enabled — create the directory safely.
408+
# ai_skills is boolean True: create the directory safely.
431409
_ensure_safe_shared_directory(
432410
project_root, skills_dir, context="agent skills directory",
433411
)

src/specify_cli/_init_options.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Helpers for interpreting persisted init options."""
2+
3+
import json
4+
from collections.abc import Mapping
5+
from pathlib import Path
6+
from typing import Any
7+
8+
9+
INIT_OPTIONS_FILE = ".specify/init-options.json"
10+
11+
12+
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
13+
"""Persist the CLI options used during ``specify init``."""
14+
dest = project_path / INIT_OPTIONS_FILE
15+
dest.parent.mkdir(parents=True, exist_ok=True)
16+
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
17+
18+
19+
def load_init_options(project_path: Path) -> dict[str, Any]:
20+
"""Load persisted init options, returning an empty dict when unavailable."""
21+
path = project_path / INIT_OPTIONS_FILE
22+
if not path.exists():
23+
return {}
24+
try:
25+
return json.loads(path.read_text())
26+
except (json.JSONDecodeError, OSError):
27+
return {}
28+
29+
30+
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
31+
"""Return True only when init options explicitly enable AI skills."""
32+
return isinstance(opts, Mapping) and opts.get("ai_skills") is True

src/specify_cli/agents.py

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
import yaml
1717

18+
from ._init_options import is_ai_skills_enabled, load_init_options
19+
1820

1921
def _build_agent_configs() -> dict[str, Any]:
2022
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
@@ -359,11 +361,6 @@ def resolve_skill_placeholders(
359361
agent_name: str, frontmatter: dict, body: str, project_root: Path
360362
) -> str:
361363
"""Resolve script placeholders for skills-backed agents."""
362-
try:
363-
from . import load_init_options
364-
except ImportError:
365-
return body
366-
367364
if not isinstance(frontmatter, dict):
368365
frontmatter = {}
369366

@@ -474,6 +471,29 @@ def _is_safe_command_name(name: str) -> bool:
474471
return False
475472
return os.path.normpath(name) == name
476473

474+
@staticmethod
475+
def _same_lexical_path(left: Path, right: Path) -> bool:
476+
"""Compare paths after lexical normalization without resolving symlinks."""
477+
return os.path.normcase(os.path.normpath(os.fspath(left))) == os.path.normcase(
478+
os.path.normpath(os.fspath(right))
479+
)
480+
481+
@staticmethod
482+
def _active_skills_agent(project_root: Path) -> Optional[str]:
483+
"""Return the initialized skills-backed agent, if skills mode is active."""
484+
opts = load_init_options(project_root)
485+
if not isinstance(opts, dict):
486+
return None
487+
488+
agent = opts.get("ai")
489+
if not isinstance(agent, str) or not agent:
490+
return None
491+
# Kimi is a native skills integration; when ai_skills is not boolean
492+
# True, Kimi still uses its existing SKILL.md layout.
493+
if not is_ai_skills_enabled(opts) and agent != "kimi":
494+
return None
495+
return agent
496+
477497
def register_commands(
478498
self,
479499
agent_name: str,
@@ -806,6 +826,7 @@ def register_commands_for_all_agents(
806826
project_root: Path,
807827
context_note: str = None,
808828
link_outputs: bool = False,
829+
create_missing_active_skills_dir: bool = False,
809830
) -> Dict[str, List[str]]:
810831
"""Register commands for all detected agents in the project.
811832
@@ -817,13 +838,23 @@ def register_commands_for_all_agents(
817838
context_note: Custom context comment for markdown output
818839
link_outputs: If True, create dev-mode symlinks for rendered
819840
command files when supported by the OS.
841+
create_missing_active_skills_dir: If True, attempt missing-dir
842+
recovery only for the active initialized skills-backed agent.
843+
Recovery requires active skills mode (or Kimi's existing native
844+
skills directory) and is skipped when safe resolution or
845+
creation fails.
820846
821847
Returns:
822848
Dictionary mapping agent names to list of registered commands
823849
"""
824850
results = {}
825851

826852
self._ensure_configs()
853+
active_skills_agent = (
854+
self._active_skills_agent(project_root)
855+
if create_missing_active_skills_dir else None
856+
)
857+
active_created_skills_dir: Optional[Path] = None
827858
for agent_name, agent_config in self.AGENT_CONFIGS.items():
828859
# Check detect_dir first (project-local marker) if configured,
829860
# falling back to the resolved dir for output. This prevents
@@ -838,7 +869,36 @@ def register_commands_for_all_agents(
838869
agent_name, agent_config, project_root,
839870
)
840871

841-
if agent_dir.exists():
872+
agent_dir_existed = agent_dir.is_dir()
873+
register_missing_active_skills_agent = (
874+
not agent_dir_existed
875+
and agent_name == active_skills_agent
876+
and agent_config.get("extension") == "/SKILL.md"
877+
)
878+
if register_missing_active_skills_agent:
879+
try:
880+
from . import resolve_active_skills_dir
881+
882+
resolved_active_dir = resolve_active_skills_dir(project_root)
883+
except (ValueError, OSError):
884+
continue
885+
if resolved_active_dir is None:
886+
continue
887+
agent_dir = resolved_active_dir
888+
active_created_skills_dir = agent_dir
889+
# Shared skill dirs such as .agents/skills should not make
890+
# later integrations look detected when the active agent just
891+
# recreated the directory during this registration pass.
892+
created_by_active_agent = (
893+
active_created_skills_dir is not None
894+
and self._same_lexical_path(agent_dir, active_created_skills_dir)
895+
and agent_name != active_skills_agent
896+
)
897+
should_register = (
898+
agent_dir_existed and not created_by_active_agent
899+
) or register_missing_active_skills_agent
900+
901+
if should_register:
842902
try:
843903
registered = self.register_commands(
844904
agent_name,
@@ -852,8 +912,14 @@ def register_commands_for_all_agents(
852912
)
853913
if registered:
854914
results[agent_name] = registered
915+
if register_missing_active_skills_agent:
916+
active_created_skills_dir = agent_dir
855917
except ValueError:
856918
continue
919+
except OSError:
920+
if register_missing_active_skills_agent:
921+
continue
922+
raise
857923

858924
return results
859925

@@ -897,7 +963,7 @@ def register_commands_for_non_skill_agents(
897963
agent_dir = self._resolve_agent_dir(
898964
agent_name, agent_config, project_root,
899965
)
900-
if agent_dir.exists():
966+
if agent_dir.is_dir():
901967
try:
902968
registered = self.register_commands(
903969
agent_name,

src/specify_cli/extensions.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from packaging.specifiers import SpecifierSet, InvalidSpecifier
2727

2828
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
29+
from ._init_options import is_ai_skills_enabled
2930

3031
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
3132
"analyze",
@@ -1205,7 +1206,11 @@ def install_from_directory(
12051206
registrar = CommandRegistrar()
12061207
# Register for all detected agents
12071208
registered_commands = registrar.register_commands_for_all_agents(
1208-
manifest, dest_dir, self.project_root, link_outputs=link_commands
1209+
manifest,
1210+
dest_dir,
1211+
self.project_root,
1212+
link_outputs=link_commands,
1213+
create_missing_active_skills_dir=True,
12091214
)
12101215

12111216
# Auto-register extension commands as agent skills when --ai-skills
@@ -1471,9 +1476,10 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
14711476
init_options = {}
14721477

14731478
active_agent = init_options.get("ai")
1479+
ai_skills_enabled = is_ai_skills_enabled(init_options)
14741480
skills_mode_active = (
14751481
active_agent == agent_name
1476-
and bool(init_options.get("ai_skills"))
1482+
and ai_skills_enabled
14771483
and bool(agent_config)
14781484
and agent_config.get("extension") != "/SKILL.md"
14791485
)
@@ -1667,13 +1673,15 @@ def register_commands_for_all_agents(
16671673
extension_dir: Path,
16681674
project_root: Path,
16691675
link_outputs: bool = False,
1676+
create_missing_active_skills_dir: bool = False,
16701677
) -> Dict[str, List[str]]:
16711678
"""Register extension commands for all detected agents."""
16721679
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
16731680
return self._registrar.register_commands_for_all_agents(
16741681
manifest.commands, manifest.id, extension_dir, project_root,
16751682
context_note=context_note,
16761683
link_outputs=link_outputs,
1684+
create_missing_active_skills_dir=create_missing_active_skills_dir,
16771685
)
16781686

16791687
def unregister_commands(
@@ -2409,10 +2417,11 @@ def _render_hook_invocation(self, command: Any) -> str:
24092417

24102418
init_options = self._load_init_options()
24112419
selected_ai = init_options.get("ai")
2412-
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
2413-
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
2420+
ai_skills_enabled = is_ai_skills_enabled(init_options)
2421+
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
2422+
claude_skill_mode = selected_ai == "claude" and ai_skills_enabled
24142423
kimi_skill_mode = selected_ai == "kimi"
2415-
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
2424+
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
24162425
cline_mode = selected_ai == "cline"
24172426

24182427
skill_name = self._skill_name_from_command(command_id)

src/specify_cli/presets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
3131
from .integrations.base import IntegrationBase
32+
from ._init_options import is_ai_skills_enabled
3233

3334

3435
def _substitute_core_template(
@@ -1262,7 +1263,7 @@ def _register_skills(
12621263
selected_ai = init_opts.get("ai")
12631264
if not isinstance(selected_ai, str):
12641265
return []
1265-
ai_skills_enabled = bool(init_opts.get("ai_skills"))
1266+
ai_skills_enabled = is_ai_skills_enabled(init_opts)
12661267
registrar = CommandRegistrar()
12671268
integration = get_integration(selected_ai)
12681269
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})

0 commit comments

Comments
 (0)