Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING: Windsurf skills now deploy to `.agents/skills/<name>/SKILL.md`
instead of `.windsurf/skills/<name>/SKILL.md` (skill routing convergence).**
Cascade
[natively discovers `.agents/skills/`](https://docs.windsurf.com/windsurf/cascade/skills#skill-scopes)
at both workspace and user scope, so Windsurf joins the existing convergence
with Copilot, Cursor, Codex, Gemini, and OpenCode (6 clients total) and APM
no longer writes a separate `.windsurf/skills/` copy. Claude remains on its
native `.claude/skills/` routing. **Migration:** the first `apm install`
after upgrading auto-migrates any `.windsurf/skills/<pkg>/` recorded in
`apm.lock.yaml` to `.agents/skills/` and deletes the stale copy (idempotent;
foreign/hand-authored skills are untouched; aborts with a clear error on a
content collision). If you never re-run `apm install`, delete the stale
directories by hand: `rm -rf .windsurf/skills/<pkg>/`. Pass
`--legacy-skill-paths` (or set `APM_LEGACY_SKILL_PATHS=1`) to keep the
pre-convergence `.windsurf/skills/` layout. (closes #1520)

### Removed

- `apm marketplace publish` command and consumer-repo fan-out workflow; consumers should run `apm install --update` instead. (#1134)
Expand Down
8 changes: 5 additions & 3 deletions docs/src/content/docs/getting-started/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ skill collection layout reference.
## Skill routing convergence

:::caution[Behavior change]
Skills for **Copilot, Cursor, OpenCode, Codex, and Gemini** now deploy to `.agents/skills/` by default instead of per-client directories (`.github/skills/`, `.cursor/skills/`, `.gemini/skills/`, etc.). This matches the `.agents/` discovery path documented by all five clients and eliminates redundant copies when targeting multiple clients.
Skills for **Copilot, Cursor, OpenCode, Codex, Gemini, and Windsurf** now deploy to `.agents/skills/` by default instead of per-client directories (`.github/skills/`, `.cursor/skills/`, `.gemini/skills/`, `.windsurf/skills/`, etc.). This matches the `.agents/` discovery path documented by all six clients and eliminates redundant copies when targeting multiple clients.

**Windsurf joined the convergence in [#1520](https://github.com/microsoft/apm/issues/1520).** Cascade [natively discovers `.agents/skills/`](https://docs.windsurf.com/windsurf/cascade/skills#skill-scopes) at both workspace and user scope, so APM no longer writes a separate `.windsurf/skills/` copy. The next `apm install` after upgrading auto-migrates any `.windsurf/skills/<pkg>/` recorded in `apm.lock.yaml` to `.agents/skills/` and deletes the stale copy (see below). If you never re-run `apm install`, remove the stale directories by hand with `rm -rf .windsurf/skills/<pkg>/`.

**Claude is unchanged** - its skills continue to deploy to `.claude/skills/`.

Expand All @@ -113,7 +115,7 @@ To restore the previous per-client layout, pass `--legacy-skill-paths` to any co

### Auto-migration of legacy lockfile state

When you upgrade APM and run `apm install`, the tool automatically detects legacy per-client skill paths (`.github/skills/`, `.cursor/skills/`, `.opencode/skills/`, `.gemini/skills/`) recorded in your `apm.lock.yaml` and migrates them to `.agents/skills/`.
When you upgrade APM and run `apm install`, the tool automatically detects legacy per-client skill paths (`.github/skills/`, `.cursor/skills/`, `.opencode/skills/`, `.gemini/skills/`, `.windsurf/skills/`) recorded in your `apm.lock.yaml` and migrates them to `.agents/skills/`.

**What happens:**
- Old per-client skill files are deleted after the new `.agents/skills/` files are written
Expand All @@ -135,7 +137,7 @@ per-client skill paths to `.agents/skills/` and update `apm.lock.yaml`. In
CI pipelines, this means the working tree will show:

- Deletions under `.github/skills/`, `.cursor/skills/`, `.opencode/skills/`,
and/or `.gemini/skills/`
`.gemini/skills/`, and/or `.windsurf/skills/`
- Additions under `.agents/skills/`
- An updated `apm.lock.yaml`

Expand Down
15 changes: 8 additions & 7 deletions docs/src/content/docs/producer/author-primitives/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,22 +99,23 @@ in `references/`; keep `SKILL.md` to the always-relevant flow.
| Target | Deploy directory |
|-------------------|----------------------------------------------|
| `claude` | `.claude/skills/<name>/SKILL.md` |
| `windsurf` | `.windsurf/skills/<name>/SKILL.md` |
| `kiro` | `.kiro/skills/<name>/SKILL.md` |
| `copilot` | `.agents/skills/<name>/SKILL.md` |
| `cursor` | `.agents/skills/<name>/SKILL.md` |
| `codex` | `.agents/skills/<name>/SKILL.md` |
| `gemini` | `.agents/skills/<name>/SKILL.md` |
| `opencode` | `.agents/skills/<name>/SKILL.md` |
| `windsurf` | `.agents/skills/<name>/SKILL.md` |
| `agent-skills` | `.agents/skills/<name>/SKILL.md` (explicit) |

Five harnesses converge on the cross-tool `.agents/skills/`
Six harnesses converge on the cross-tool `.agents/skills/`
directory. Claude keeps its harness-native path because Claude Code's
default scan is `.claude/skills/`; Windsurf and Kiro currently use
`.windsurf/skills/` and `.kiro/skills/` for the same reason. Windsurf's Cascade also
[discovers `.agents/skills/`](https://docs.windsurf.com/windsurf/cascade/skills#skill-scopes)
natively for cross-agent compatibility (convergence tracked in
[#1520](https://github.com/microsoft/apm/issues/1520)). The whole
default scan is `.claude/skills/`; Kiro likewise keeps `.kiro/skills/`.
Windsurf joined the convergence in
[#1520](https://github.com/microsoft/apm/issues/1520): Cascade
[natively discovers `.agents/skills/`](https://docs.windsurf.com/windsurf/cascade/skills#skill-scopes)
at both workspace and user scope, so APM no longer ships a separate
`.windsurf/skills/` copy. The whole
skill folder is copied (`shutil.copytree`), so `scripts/`,
`references/`, `assets/`, and `examples/` ride along. Symlinks and
the `.apm-pin` cache marker are filtered out
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ A plural alias `targets:` (YAML list only) is also accepted and takes precedence
| `opencode` | Emits to `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/`. |
| `codex` | Emits `AGENTS.md` and deploys skills to `.agents/skills/`, agents to `.codex/agents/`. |
| `gemini` | Emits `GEMINI.md` and deploys to `.gemini/commands/`, `.gemini/skills/`, `.gemini/settings.json`. |
| `windsurf` | Emits `AGENTS.md` and deploys to `.windsurf/rules/`, `.windsurf/skills/`, `.windsurf/workflows/`, `.windsurf/hooks.json`. |
| `windsurf` | Emits `AGENTS.md` and deploys skills to `.agents/skills/`, plus `.windsurf/rules/`, `.windsurf/workflows/`, `.windsurf/hooks.json`. |
| `kiro` | Emits `AGENTS.md` and deploys to `.kiro/steering/`, `.kiro/skills/`, `.kiro/hooks/`, `.kiro/settings/mcp.json`. |
| `all` | All targets. Cannot be combined with other values in a list. |
| `minimal` | `AGENTS.md` only at project root. **Auto-detected only**: this value MUST NOT be set explicitly in manifests; it is an internal fallback when no target folder is detected. |
Expand Down
10 changes: 5 additions & 5 deletions docs/src/content/docs/reference/targets-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ see [Primitive types](./primitive-types/).
| codex | `.codex/` + `.agents/` | [ ] | [ ] | [x] | [x] | [ ] | [x] | [x] |
| gemini | `.gemini/` | [ ] | [ ] | [ ] | [x] | [x] | [x] | [x] |
| opencode | `.opencode/` | [ ] | [ ] | [x] | [x] | [x] | [ ] | [x] |
| windsurf | `.windsurf/` | [x] | [ ] | [ ] | [x] | [x] | [x] | [x] |
| windsurf | `.windsurf/` + `.agents/` | [x] | [ ] | [ ] | [x] | [x] | [x] | [x] |
| kiro | `.kiro/` | [x] | [ ] | [ ] | [x] | [ ] | [x] | [x] |
| agent-skills | `.agents/` | [ ] | [ ] | [ ] | [x] | [ ] | [ ] | [ ] |

Skills deploy to `.agents/skills/` for Copilot, Cursor, OpenCode,
Gemini, and Codex by default (see [Skills convergence](#skills-convergence)
below). Claude, Windsurf, and Kiro keep target-native skill directories.
Gemini, Codex, and Windsurf by default (see [Skills convergence](#skills-convergence)
below). Claude and Kiro keep target-native skill directories.

`copilot-cowork` (Microsoft 365 Copilot), `copilot-app` (GitHub
Copilot desktop App), and `openclaw` (OpenClaw agent runtime) are
Expand Down Expand Up @@ -164,11 +164,11 @@ OpenCode.
Windsurf / Cascade.

- **Detection.** `.windsurf/` directory.
- **Deploy directory.** `.windsurf/` at project scope; `~/.codeium/windsurf/` at user scope.
- **Deploy directory.** `.windsurf/` at project scope (`~/.codeium/windsurf/` at user scope), plus `.agents/` for skills.
- **Supported primitives.** instructions, skills, commands, hooks, mcp.
- **File conventions.**
- instructions: `.windsurf/rules/<name>.md`
- skills: `.windsurf/skills/<name>/SKILL.md`
- skills: `.agents/skills/<name>/SKILL.md` (Cascade natively discovers `.agents/skills/`; [#1520](https://github.com/microsoft/apm/issues/1520))
- commands: `.windsurf/workflows/<name>.md`
- hooks: `.windsurf/hooks.json`
- **Agents.** Not deployed. Cascade auto-invokes any `SKILL.md` by its `description:` frontmatter, so a separate agents primitive would collide with skills on the same path. Ship personas as skills under `.apm/skills/<name>/SKILL.md` instead.
Expand Down
11 changes: 6 additions & 5 deletions src/apm_cli/bundle/lockfile_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
# for Copilot. Cursor/opencode sources are niche; if someone publishes
# skills exclusively under .cursor/, they must pack with --target cursor.
#
# Windsurf converts agents -> skills (lossy: AGENTS.md format is collapsed
# into the windsurf skill envelope), so .github/agents/ maps to
# .windsurf/skills/.
# Windsurf converges skills onto the cross-tool .agents/skills/ directory
# (apm#1520), so .github/skills/ maps there. It also converts agents ->
# skills (lossy: AGENTS.md format is collapsed into the windsurf skill
# envelope), so .github/agents/ maps to .agents/skills/ as well.
_CROSS_TARGET_MAPS: dict[str, dict[str, str]] = {
"claude": {
".github/skills/": ".claude/skills/",
Expand All @@ -45,8 +46,8 @@
".github/agents/": ".codex/agents/",
},
"windsurf": {
".github/skills/": ".windsurf/skills/",
".github/agents/": ".windsurf/skills/",
".github/skills/": ".agents/skills/",
".github/agents/": ".agents/skills/",
},
"agent-skills": {
".github/skills/": ".agents/skills/",
Expand Down
6 changes: 5 additions & 1 deletion src/apm_cli/install/skill_path_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@
# Legacy per-client skill prefixes that have been converged into .agents/skills/.
# .claude/skills/ is excluded (Claude is not in the convergence set).
# .codex/skills/ was never a legacy path (Codex always used .agents/).
_LEGACY_SKILL_PATTERN = re.compile(r"^\.(github|cursor|opencode|gemini)/skills/([^/]+)/.+$")
# .windsurf/skills/ joined the convergence in apm#1520; prior APM versions
# deployed windsurf skills there, so it is migrated like the other clients.
_LEGACY_SKILL_PATTERN = re.compile(
r"^\.(github|cursor|opencode|gemini|windsurf)/skills/([^/]+)/.+$"
)

# ------------------------------------------------------------------
# Shared message templates (single source of truth — H6)
Expand Down
22 changes: 19 additions & 3 deletions src/apm_cli/integration/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,8 +664,16 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None:
),
# Windsurf/Cascade -- .windsurf/ is the workspace config directory.
# Rules are markdown files with trigger/globs frontmatter under .windsurf/rules/.
# Skills use the standard SKILL.md format under .windsurf/skills/.
# Cascade auto-invokes them when the description frontmatter matches the
# Skills converge on the cross-tool ``.agents/skills/<name>/SKILL.md`` path
# (deploy_root=".agents"): Cascade natively discovers ``.agents/skills/`` at
# both workspace and user scope, so windsurf joins the same convergence as
# copilot/cursor/codex/gemini/opencode instead of keeping a windsurf-native
# ``.windsurf/skills/`` copy (apm#1520). Pass ``--legacy-skill-paths`` (or
# ``APM_LEGACY_SKILL_PATHS=1``) to restore the pre-convergence
# ``.windsurf/skills/`` layout; ``apply_legacy_skill_paths`` resets
# ``deploy_root`` to ``None``.
# Ref: https://docs.windsurf.com/windsurf/cascade/skills#skill-scopes
# Cascade auto-invokes a skill when its description frontmatter matches the
# task -- this is the universal invocation mechanism, so windsurf does
# NOT expose a separate ``agents`` primitive. Package authors who want
# their content to deploy to windsurf must declare it under
Expand All @@ -688,7 +696,12 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None:
"windsurf_rules",
output_compare=True,
),
"skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"),
"skills": PrimitiveMapping(
"skills",
"/SKILL.md",
"skill_standard",
deploy_root=".agents",
),
"commands": PrimitiveMapping("workflows", ".md", "windsurf_workflow"),
"hooks": PrimitiveMapping("", "hooks.json", "windsurf_hooks"),
},
Expand All @@ -697,6 +710,9 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None:
user_supported="partial",
user_root_dir=".codeium/windsurf",
unsupported_user_primitives=("instructions",),
# Skills deploy to .agents/ while rules/workflows/hooks stay under
# .windsurf/, so pack must include both roots (mirrors codex).
pack_prefixes=(".windsurf/", ".agents/"),
compile_family="agents",
hooks_config_display=".windsurf/hooks.json",
),
Expand Down
88 changes: 88 additions & 0 deletions tests/integration/test_agent_skills_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -811,3 +811,91 @@ def test_auto_migration_collision_skips_gracefully(
out = r2.output or ""
assert "Skill path migration skipped" in out or "already exist" in out
assert "--legacy-skill-paths" in out


# ---------------------------------------------------------------------------
# Windsurf convergence (apm#1520)
# ---------------------------------------------------------------------------


def _make_windsurf_project(tmp_path: Path) -> Path:
"""Project with ``.windsurf/`` present so the windsurf target deploys.

windsurf has ``auto_create=False`` and ``detect_by_dir=True`` keyed off
``.windsurf/`` (NOT ``.agents/``), so skills only deploy when the
workspace already has a ``.windsurf/`` directory -- even for the
converged ``.agents/skills/`` path.
"""
project = _make_project(tmp_path / "dst", with_github=False)
(project / ".windsurf").mkdir()
return project


def test_windsurf_install_deploys_to_agents_skills(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""apm#1520: ``apm install --target windsurf`` deploys skills to the
converged ``.agents/skills/<name>/SKILL.md`` path, NOT ``.windsurf/skills/``."""
bundle = _make_plugin_bundle(tmp_path / "src", pack_target="windsurf")
project = _make_windsurf_project(tmp_path)

monkeypatch.delenv("APM_LEGACY_SKILL_PATHS", raising=False)
result = _invoke(project, ["install", str(bundle), "--target", "windsurf"], monkeypatch)
assert result.exit_code == 0, f"output={result.output!r}"

assert (project / ".agents/skills" / SKILL_NAME / "SKILL.md").is_file(), (
"windsurf skills must converge on .agents/skills/"
)
assert not (project / ".windsurf/skills" / SKILL_NAME / "SKILL.md").exists(), (
"windsurf must no longer write the per-client .windsurf/skills/ copy"
)


def test_windsurf_legacy_flag_produces_windsurf_skills(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``--legacy-skill-paths`` restores the pre-convergence ``.windsurf/skills/``
layout for the windsurf target."""
bundle = _make_plugin_bundle(tmp_path / "src", pack_target="windsurf")
project = _make_windsurf_project(tmp_path)

result = _invoke(
project,
["install", str(bundle), "--target", "windsurf", "--legacy-skill-paths"],
monkeypatch,
)
assert result.exit_code == 0, f"output={result.output!r}"

assert (project / ".windsurf/skills" / SKILL_NAME / "SKILL.md").is_file(), (
"--legacy-skill-paths must restore .windsurf/skills/ for windsurf"
)
assert not (project / ".agents/skills" / SKILL_NAME / "SKILL.md").exists(), (
"legacy mode must not also write the converged .agents/skills/ copy"
)


def test_windsurf_auto_migration_from_windsurf_skills(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A legacy ``.windsurf/skills/`` install auto-migrates to ``.agents/skills/``
on the next converged ``apm install`` (apm#1520)."""
bundle = _make_plugin_bundle(tmp_path / "src", pack_target="windsurf")
project = _make_windsurf_project(tmp_path)

# --- First install: legacy mode -> .windsurf/skills/ ---
monkeypatch.setenv("APM_LEGACY_SKILL_PATHS", "1")
r1 = _invoke(project, ["install", str(bundle), "--target", "windsurf"], monkeypatch)
assert r1.exit_code == 0, f"legacy install failed: {r1.output!r}"
assert (project / ".windsurf/skills" / SKILL_NAME / "SKILL.md").is_file()

# --- Second install: converged mode -> migrate ---
monkeypatch.delenv("APM_LEGACY_SKILL_PATHS", raising=False)
r2 = _invoke(project, ["install", str(bundle), "--target", "windsurf"], monkeypatch)
assert r2.exit_code == 0, f"migration install failed: {r2.output!r}"

assert (project / ".agents/skills" / SKILL_NAME / "SKILL.md").is_file(), (
"converged path must exist after migration"
)
assert not (project / ".windsurf/skills" / SKILL_NAME / "SKILL.md").exists(), (
"legacy .windsurf/skills/ copy must be removed by the migration"
)
23 changes: 22 additions & 1 deletion tests/unit/install/test_skill_path_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class TestLegacySkillPattern:
".cursor/skills/review/SKILL.md",
".opencode/skills/deep/nested/file.md",
".gemini/skills/lint/SKILL.md",
".windsurf/skills/my-skill/SKILL.md",
],
)
def test_matches_legacy_clients(self, path: str) -> None:
Expand Down Expand Up @@ -132,15 +133,35 @@ def test_detects_multiple_clients(self, tmp_path: Path) -> None:
".cursor/skills/s1/SKILL.md",
".opencode/skills/s1/SKILL.md",
".gemini/skills/s1/SKILL.md",
".windsurf/skills/s1/SKILL.md",
]
),
}
)
plans = detect_legacy_skill_deployments(lf, tmp_path)
assert len(plans) == 4
assert len(plans) == 5
for plan in plans:
assert plan.dst_path == ".agents/skills/s1/SKILL.md"

def test_detects_windsurf_legacy(self, tmp_path: Path) -> None:
"""apm#1520: windsurf joined the convergence, so prior-version
.windsurf/skills/ deployments must be migrated to .agents/skills/."""
lf = _StubLockFile(
dependencies={
"pkg-a": _StubDep(
deployed_files=[
".windsurf/skills/my-skill/SKILL.md",
".agents/skills/my-skill/SKILL.md", # new path -- ignored
]
),
}
)
plans = detect_legacy_skill_deployments(lf, tmp_path)
assert len(plans) == 1
assert plans[0].src_path == ".windsurf/skills/my-skill/SKILL.md"
assert plans[0].dst_path == ".agents/skills/my-skill/SKILL.md"
assert plans[0].dep_name == "pkg-a"

def test_ignores_claude_and_codex(self, tmp_path: Path) -> None:
lf = _StubLockFile(
dependencies={
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/integration/test_data_driven_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,8 @@ def test_partition_parity_with_old_buckets(self):
"agents_opencode",
"agents_codex",
# NOTE: windsurf no longer exposes an 'agents' primitive
# (its content deploys as skills under .windsurf/skills/).
# (its content deploys as skills under the converged
# .agents/skills/ directory; apm#1520).
"commands", # was commands_claude, aliased
"commands_cursor",
"commands_gemini",
Expand Down
Loading
Loading