mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
* docs: add Spec Kit spec for agent-context full opt-in Use Spec Kit's own specify workflow to author the spec that makes the agent-context extension a full opt-in, removing all agent-context configuration/support from the Python codebase and removing the deprecation message. Force-added despite specs/ being gitignored; the generated artifact will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit plan artifacts for agent-context full opt-in Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md, quickstart.md, and contracts/cli-behavior.md. Constitution Check is a documented no-op (repo has no ratified constitution). Force-added despite specs/ being gitignored; generated artifacts will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Constitution Check against ratified v1.0.0 Earlier draft wrongly treated the gate as a no-op; the fork's main is 16 commits behind upstream/main, which carries .specify/memory/constitution.md. Re-evaluate the feature against Principles I-V (all PASS) and note that Principle I mandates keeping context_file as a declared class attribute, validating the R1 metadata decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: refresh plan artifacts against synced upstream/main After syncing fork main to upstream and rebasing, re-scan the current agent-context surface. Upstream generalized the single context_file into a plural context_files concept with new resolver helpers (_resolve_context_files, _resolve_context_file_values, _format_context_file_values) and upsert/remove now loop over multiple files. Update research.md, data-model.md, contracts, quickstart grep guards, and the plan summary to cover the expanded removal scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit tasks for agent-context full opt-in Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the three user stories, with mandatory test tasks (Constitution Principle II) and a foundational phase decoupling __CONTEXT_FILE__ resolution from the extension config. Includes the extension self-seeding task (T015) and a static guard test (T002) enforcing zero agent-context references in the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove agent-context lifecycle from the Specify CLI Make the agent-context extension a full opt-in. The CLI no longer installs the extension during init, writes agent-context-config.yml, or creates/updates/removes the managed Spec Kit section in agent context files. Context-section upsert/remove, marker resolution, extension-enabled gating, the config helpers, and the obsolete inline deprecation warning are all removed. Integration context_file stays as inert metadata; __CONTEXT_FILE__ now resolves from registry metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): self-seed context file from the active integration When agent-context-config.yml has no context_file/context_files, the bundled bash and PowerShell update scripts now resolve the context file from the active integration in .specify/init-options.json via the integration registry, so the extension no longer depends on the CLI writing its config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: update suite and docs for agent-context opt-in Update integration/extension tests to expect no agent-context install, config, or context-section writes during init. Add a static guard test (test_agent_context_cli_free.py) asserting the CLI source is free of agent-context lifecycle symbols, plus backward-compatibility tests for legacy projects. Refresh AGENTS.md, the extension README, and add a CHANGELOG entry describing the opt-in behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent-context): warn on self-seed failure, correct docs, speed up guard test Address PR review feedback: - Self-seed scripts (bash + PowerShell) now emit an actionable warning when an active integration is configured but specify_cli cannot be imported by the chosen Python (e.g. pipx installs), or when the integration declares no context file, instead of silently falling through to 'nothing to do'. - Correct the extension README disable note: command rendering never reads the extension config; __CONTEXT_FILE__ is always substituted from integration metadata, so a stale context_files value cannot affect rendering. - Cache CLI source reads in the static guard test via a module-scoped fixture so the directory walk happens once instead of once per forbidden symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): ship self-owned per-agent context-file defaults The extension now bundles agent-context-defaults.json (key→context_file map) and self-seeds from it, dropping any dependency on the Specify CLI registry. Both the bash and PowerShell update scripts read the bundled JSON map keyed by the active integration from init-options.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove all agent-context state from the Specify CLI Strip every context_file reference from the CLI: the field on all 35 integration classes, the IntegrationBase plumbing (process_template param/step, _context_file_display, docstrings), the __CONTEXT_FILE__ resolution in agents.py, the legacy context_file/context_markers popping in _helpers.py, and the context_file template in integration_scaffold.py. Also drop the Agent context update step and __CONTEXT_FILE__ placeholder from templates/commands/plan.md. The agent-context extension now solely owns all context-file knowledge, including the per-agent default mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop context_file coverage and guard against CLI reintroduction Remove CONTEXT_FILE attrs and context_file assertions across the base mixins, all 35 per-integration test files, shared integration tests, and conftest stubs. Rewrite the base-mixin context tests to assert no managed section is written and no __CONTEXT_FILE__ placeholder survives. Extend the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and _context_file_display in src/specify_cli, and have the extension tests copy the bundled defaults JSON so self-seed runs without the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: reflect full removal of agent-context state from the CLI Update AGENTS.md (integration examples, required-fields table, context behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts (FR-007, SC-002, data-model) to state that the CLI carries no context_file and the extension fully owns the per-agent default mapping via agent-context-defaults.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: align SDD artifacts with full context_file removal Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md (C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source map), and checklists/requirements.md so the spec artifacts reflect the implemented decision: the CLI carries no context_file attribute or __CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the extension. Resolves PR review #4548130110. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub stale context-file mentions from CLI docstrings Update the multi_install_safe docstring (drop the removed "context file" invariant), the RovoDev setup docstring (no longer upserts a context section), the Copilot module docstring (drop the context-file line), and tighten the _update_init_options_for_integration note. Pure docstring changes — no behavioral impact. Resolves PR review #4548237085. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: harden agent-context test helper and fix stale docs - base.py: document multi_install_safe as an optional subclass attribute in the IntegrationBase docstring. - test_cli.py: clarify the init-options assertion is guarding against leftover legacy agent-context keys, not relocation. - test_extension_agent_context.py: _install_agent_context_config now asserts the bundled agent-context-defaults.json exists and always copies it, so self-seeding tests fail loudly instead of silently skipping when the map is missing. - test_integration_cursor_agent.py: drop Path/IntegrationManifest imports left unused after removing the context-section frontmatter tests. Resolves PR review #4548293116. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove gitignored SDD artifacts from specs/ The specs/001-agent-context-full-optin/ artifacts were force-added for dogfooding visibility, but specs/ is gitignored and these were always intended to be purged before merge. Remove them so merging does not add an intentionally-untracked directory to repo history. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: keep CHANGELOG.md identical to upstream CHANGELOG.md is auto-generated at release time, so the branch should not carry a manual entry. Restore it to match upstream/main exactly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve Cursor .mdc frontmatter in agent-context updater scripts The bundled agent-context updater scripts wrote the managed section as plain text. For Cursor-style `.mdc` targets this dropped the required `---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic into both the bash and PowerShell updaters: prepend frontmatter when missing, repair `alwaysApply` when set to the wrong value, and leave non-`.mdc` targets untouched. Add regression tests covering both shells. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: scope CLI-free guard to agent-context-specific symbols Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the guard no longer fails on unrelated future CLI fields named context_file. The list still covers agent-context-specific identifiers (__CONTEXT_FILE__, _context_file_display, _resolve_context_files, _resolve_context_file_values). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden agent-context bash self-seed against malformed init JSON Two robustness fixes in the embedded Python self-seed logic: - Coerce the integration value from init-options.json to a string only when it is actually a string; otherwise treat it as unset so a corrupted dict/list value degrades to the existing nothing-to-do behavior instead of breaking the agents-map lookup. - Normalize agent-context-defaults.json: only use 'agents' when both the JSON root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON falls back to the warning path instead of raising on .get. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct PowerShell hyphenated key lookup and regex replace count - Self-seed now reads the defaults mapping via $defaults.agents.PSObject.Properties[$integrationKey].Value instead of member access ($defaults.agents.$integrationKey), which parsed hyphenated keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve. - Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was interpreted as RegexOptions.IgnoreCase rather than a replacement count, with an instance Regex whose Replace(input, replacement, 1) limits to the first match as intended. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make bash .mdc frontmatter guard case-insensitive The bash updater only injected Cursor .mdc frontmatter when ctx_path ended in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC) was skipped and Cursor would not auto-load the rule file. Compare against the casefolded path. The PowerShell variant already uses -match, which is case-insensitive by default, so no change is needed there. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document separator-agnostic agent-context update invocation The README hard-coded the dot-notation slash command (/speckit.agent-context.update), which hyphen-separator agents like Forge and Cline do not recognize. Document the canonical command ID plus both slash invocations so users copy the form their agent accepts. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""Kimi Code integration — skills-based agent (Moonshot AI).
|
|
|
|
Kimi uses the ``.kimi-code/skills/speckit-<name>/SKILL.md`` layout with
|
|
``/skill:speckit-<name>`` invocation syntax.
|
|
|
|
Legacy migration covers projects created before Kimi Code CLI moved to
|
|
this layout and handles two distinct changes: the directory move from
|
|
``.kimi/`` to ``.kimi-code/``, and the dotted-to-hyphenated skill naming
|
|
(``speckit.xxx`` → ``speckit-xxx``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ..base import IntegrationOption, SkillsIntegration
|
|
from ..manifest import IntegrationManifest
|
|
|
|
|
|
class KimiIntegration(SkillsIntegration):
|
|
"""Integration for Kimi Code CLI (Moonshot AI)."""
|
|
|
|
key = "kimi"
|
|
config = {
|
|
"name": "Kimi Code",
|
|
"folder": ".kimi-code/",
|
|
"commands_subdir": "skills",
|
|
"install_url": "https://code.kimi.com/",
|
|
"requires_cli": True,
|
|
}
|
|
registrar_config = {
|
|
"dir": ".kimi-code/skills",
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": "/SKILL.md",
|
|
}
|
|
multi_install_safe = False
|
|
|
|
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
|
"""Build Kimi's native skill invocation: ``/skill:speckit-<stem>``.
|
|
|
|
Kimi Code CLI invokes installed skills with a ``/skill:<name>``
|
|
slash command (e.g. ``/skill:speckit-plan``), not the bare
|
|
``/speckit-<name>`` form produced by the generic skills base
|
|
class. Overriding here keeps ``dispatch_command()`` and workflow
|
|
command steps aligned with the ``/skill:`` guidance shown at init
|
|
time and in rendered hook invocations.
|
|
"""
|
|
stem = command_name
|
|
if stem.startswith("speckit."):
|
|
stem = stem[len("speckit.") :]
|
|
|
|
invocation = "/skill:speckit-" + stem.replace(".", "-")
|
|
if args:
|
|
invocation = f"{invocation} {args}"
|
|
return invocation
|
|
|
|
def post_process_skill_content(self, content: str) -> str:
|
|
"""Ensure in-skill cross-command references use Kimi's `/skill:` syntax."""
|
|
content = super().post_process_skill_content(content)
|
|
return content.replace("/speckit-", "/skill:speckit-")
|
|
|
|
@classmethod
|
|
def options(cls) -> list[IntegrationOption]:
|
|
return [
|
|
IntegrationOption(
|
|
"--skills",
|
|
is_flag=True,
|
|
default=True,
|
|
help="Install as agent skills (default for Kimi)",
|
|
),
|
|
IntegrationOption(
|
|
"--migrate-legacy",
|
|
is_flag=True,
|
|
default=False,
|
|
help=(
|
|
"Migrate legacy Kimi installations: "
|
|
".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx"
|
|
),
|
|
),
|
|
]
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install skills with optional legacy migration."""
|
|
parsed_options = parsed_options or {}
|
|
|
|
# Refuse a symlinked destination before any writes occur. base
|
|
# setup() only rejects a destination that *escapes* project_root
|
|
# after resolve(), so an in-tree symlinked ``.kimi-code`` /
|
|
# ``.kimi-code/skills`` (e.g. ``-> .``) would still pass that check
|
|
# and misdirect the SKILL.md writes into an unintended in-tree
|
|
# location (e.g. ``./skills/``). Reject any symlinked destination
|
|
# component up front so this never happens.
|
|
new_skills_dir = self.skills_dest(project_root)
|
|
if _has_symlinked_component(new_skills_dir, project_root):
|
|
raise ValueError(
|
|
f"Skills destination {new_skills_dir} contains a symlinked "
|
|
f"path component; refusing to install into it."
|
|
)
|
|
|
|
# Run base setup first so new-path targets (speckit-*) exist,
|
|
# then migrate/clean legacy dirs without risking user content loss.
|
|
created = super().setup(
|
|
project_root, manifest, parsed_options=parsed_options, **opts
|
|
)
|
|
|
|
if parsed_options.get("migrate_legacy", False):
|
|
old_skills_dir = project_root / ".kimi" / "skills"
|
|
# Validate both endpoints. base setup() already rejects a
|
|
# destination that *escapes* the project root, but an in-tree
|
|
# symlinked ``.kimi-code``/``.kimi-code/skills`` (e.g. ``-> .``)
|
|
# would still misdirect the move; ``_is_safe_legacy_dir`` rejects
|
|
# any symlinked component, giving the destination the same
|
|
# protection as the source.
|
|
if _is_safe_legacy_dir(old_skills_dir, project_root) and (
|
|
_is_safe_legacy_dir(new_skills_dir, project_root)
|
|
):
|
|
_migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir)
|
|
|
|
return created
|
|
|
|
def teardown(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
*,
|
|
force: bool = False,
|
|
) -> tuple[list[Path], list[Path]]:
|
|
"""Uninstall Kimi skills and remove leftover legacy directories."""
|
|
removed, skipped = super().teardown(project_root, manifest, force=force)
|
|
|
|
old_skills_dir = project_root / ".kimi" / "skills"
|
|
if _is_safe_legacy_dir(old_skills_dir, project_root):
|
|
legacy_dirs = sorted(
|
|
[*old_skills_dir.glob("speckit-*"), *old_skills_dir.glob("speckit.*")]
|
|
)
|
|
for legacy_dir in legacy_dirs:
|
|
if legacy_dir.is_symlink() or not legacy_dir.is_dir():
|
|
continue
|
|
if _is_speckit_generated_skill(legacy_dir):
|
|
try:
|
|
shutil.rmtree(legacy_dir)
|
|
removed.append(legacy_dir)
|
|
except OSError:
|
|
skipped.append(legacy_dir)
|
|
|
|
try:
|
|
old_skills_dir.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
return removed, skipped
|
|
|
|
|
|
def _has_symlinked_component(path: Path, project_root: Path) -> bool:
|
|
"""Return ``True`` when *path* escapes *project_root* or any component is a symlink.
|
|
|
|
Walks the components strictly between *project_root* and *path*
|
|
(including the final one) and reports whether any of them is a symlink.
|
|
Components that do not exist yet are not symlinks, so this safely handles
|
|
a not-yet-created destination. *project_root* itself is trusted and never
|
|
checked. A *path* outside *project_root* is treated as unsafe.
|
|
"""
|
|
try:
|
|
relative = path.relative_to(project_root)
|
|
except ValueError:
|
|
return True
|
|
current = project_root
|
|
for part in relative.parts:
|
|
current = current / part
|
|
if current.is_symlink():
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool:
|
|
"""Return ``True`` when *path* is a real directory safely inside *project_root*.
|
|
|
|
Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()``
|
|
directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached
|
|
through a symlinked parent) must never be followed: doing so could
|
|
relocate or delete content living outside the project tree — or operate
|
|
on an unrelated in-tree directory (e.g. ``.kimi -> .`` makes
|
|
``.kimi/skills`` resolve to ``./skills``).
|
|
|
|
Checking only the fully-resolved path is insufficient, because a symlink
|
|
pointing elsewhere *inside* the project still resolves to a location under
|
|
*project_root*. We therefore reject the path when it is not a directory,
|
|
when any component between *project_root* and *path* is a symlink
|
|
(including the final component), or when the resolved path escapes the
|
|
resolved *project_root*.
|
|
"""
|
|
if not path.is_dir():
|
|
return False
|
|
|
|
# Reject if any path component below project_root is a symlink (or the
|
|
# path escapes project_root). We trust project_root itself, so only
|
|
# components strictly under it are checked.
|
|
if _has_symlinked_component(path, project_root):
|
|
return False
|
|
|
|
try:
|
|
resolved = path.resolve()
|
|
root = project_root.resolve()
|
|
except OSError:
|
|
return False
|
|
return resolved == root or root in resolved.parents
|
|
|
|
|
|
def _migrate_legacy_kimi_skills_dir(
|
|
old_skills_dir: Path, new_skills_dir: Path
|
|
) -> tuple[int, int]:
|
|
"""Migrate skills from the legacy ``.kimi/skills/`` directory to ``.kimi-code/skills/``.
|
|
|
|
Handles both hyphenated (``speckit-xxx``) and dotted (``speckit.xxx``)
|
|
legacy directory names. If a target already exists, the legacy dir is
|
|
only removed when its ``SKILL.md`` is byte-identical and no extra user
|
|
files are present.
|
|
|
|
Returns ``(migrated_count, removed_count)``.
|
|
"""
|
|
if not old_skills_dir.is_dir():
|
|
return (0, 0)
|
|
|
|
migrated_count = 0
|
|
removed_count = 0
|
|
|
|
# Process hyphenated dirs first, then dotted dirs.
|
|
legacy_dirs = sorted(old_skills_dir.glob("speckit-*")) + sorted(
|
|
old_skills_dir.glob("speckit.*")
|
|
)
|
|
|
|
for legacy_dir in legacy_dirs:
|
|
if legacy_dir.is_symlink() or not legacy_dir.is_dir():
|
|
continue
|
|
legacy_skill = legacy_dir / "SKILL.md"
|
|
# Treat a symlinked SKILL.md as invalid: later read_bytes() would
|
|
# otherwise follow it and read content from outside the project.
|
|
if legacy_skill.is_symlink() or not legacy_skill.is_file():
|
|
continue
|
|
|
|
target_name = _legacy_to_target_name(legacy_dir.name)
|
|
if not target_name:
|
|
continue
|
|
|
|
target_dir = new_skills_dir / target_name
|
|
|
|
# Skip if the legacy dir is already the target dir (same-directory call).
|
|
if legacy_dir.resolve() == target_dir.resolve():
|
|
continue
|
|
|
|
if not target_dir.exists():
|
|
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(legacy_dir), str(target_dir))
|
|
migrated_count += 1
|
|
continue
|
|
|
|
# Target exists — only remove legacy if SKILL.md is identical.
|
|
# Skip when the target dir or its SKILL.md is a symlink (or the dir is
|
|
# not a real directory) so the byte comparison never follows a link
|
|
# outside the project. (legacy_skill is already guaranteed to be a real
|
|
# file by the guard above.)
|
|
if target_dir.is_symlink() or not target_dir.is_dir():
|
|
continue
|
|
target_skill = target_dir / "SKILL.md"
|
|
if target_skill.is_symlink() or not target_skill.is_file():
|
|
continue
|
|
try:
|
|
if target_skill.read_bytes() == legacy_skill.read_bytes():
|
|
has_extra = any(
|
|
child.name != "SKILL.md" for child in legacy_dir.iterdir()
|
|
)
|
|
if not has_extra:
|
|
shutil.rmtree(legacy_dir)
|
|
removed_count += 1
|
|
except OSError:
|
|
pass
|
|
|
|
# Remove the legacy skills directory if it is now empty.
|
|
try:
|
|
old_skills_dir.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
return (migrated_count, removed_count)
|
|
|
|
|
|
def _legacy_to_target_name(legacy_name: str) -> str:
|
|
"""Convert a legacy skill directory name to the modern hyphenated form."""
|
|
if legacy_name.startswith("speckit-"):
|
|
return legacy_name
|
|
if legacy_name.startswith("speckit."):
|
|
suffix = legacy_name[len("speckit.") :]
|
|
if suffix:
|
|
return f"speckit-{suffix.replace('.', '-')}"
|
|
return ""
|
|
|
|
|
|
def _is_speckit_generated_skill(skill_dir: Path) -> bool:
|
|
"""Return True when *skill_dir* contains a Speckit-generated SKILL.md.
|
|
|
|
Uses the ``metadata.author`` and ``metadata.source`` fields written by
|
|
``SkillsIntegration.setup()`` to avoid deleting user-authored skills.
|
|
"""
|
|
skill_file = skill_dir / "SKILL.md"
|
|
# A symlinked SKILL.md is never treated as Speckit-generated, so teardown
|
|
# cleanup never follows it to read frontmatter from outside the project.
|
|
if skill_file.is_symlink() or not skill_file.is_file():
|
|
return False
|
|
|
|
try:
|
|
content = skill_file.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return False
|
|
|
|
if not content.startswith("---"):
|
|
return False
|
|
|
|
parts = content.split("---", 2)
|
|
if len(parts) < 3:
|
|
return False
|
|
|
|
try:
|
|
import yaml
|
|
|
|
frontmatter = yaml.safe_load(parts[1])
|
|
except Exception:
|
|
return False
|
|
|
|
if not isinstance(frontmatter, dict):
|
|
return False
|
|
|
|
metadata = frontmatter.get("metadata", {})
|
|
if not isinstance(metadata, dict):
|
|
return False
|
|
|
|
author = metadata.get("author", "")
|
|
source = metadata.get("source", "")
|
|
return (
|
|
author == "github-spec-kit"
|
|
and isinstance(source, str)
|
|
and source.startswith("templates/commands/")
|
|
)
|
|
|
|
|
|
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
|
|
"""Compatibility shim — migrate legacy dotted skill dirs in place.
|
|
|
|
.. deprecated::
|
|
Kept for direct callers/tests. New code should call
|
|
``_migrate_legacy_kimi_skills_dir`` directly.
|
|
|
|
Delegates to ``_migrate_legacy_kimi_skills_dir`` with *skills_dir* as both
|
|
source and destination, so it processes every ``speckit-*`` and
|
|
``speckit.*`` entry under *skills_dir*. Because the two paths are
|
|
identical, the same-path short-circuit there skips any directory whose
|
|
target resolves to itself; in practice this renames dotted
|
|
``speckit.xxx`` dirs to hyphenated ``speckit-xxx`` in place and never
|
|
moves content outside *skills_dir*.
|
|
|
|
Returns ``(migrated_count, removed_count)``.
|
|
"""
|
|
return _migrate_legacy_kimi_skills_dir(skills_dir, skills_dir)
|