Migrate Rules to global Skills and AGENTS.md (#56672)

Adds a one-time, idempotent startup migration that moves every user Rule
out of the `PromptStore` LMDB database into the new Skills + AGENTS.md
world, in a single pass:

- **Non-Default Rules → global Skills.** Each one becomes
`~/.agents/skills/<slug>/SKILL.md` with `disable-model-invocation:
true`, preserving the original behavior that non-Default Rules were only
ever invoked when the user named them. They're now invokable via
`/skill-name` (and still `@`-mentions).
- **Default Rules → global AGENTS.md.** Each one is appended to
`paths::agents_file()` (e.g. `~/.config/zed/AGENTS.md` on macOS/Linux,
`%APPDATA%\Zed\AGENTS.md` on Windows) under an `## H2` heading
containing the rule's title. Default Rules used to be auto-included in
every conversation; the global AGENTS.md is loaded into the system
prompt of every conversation, so the behavior is preserved.
- **Customized built-in prompts → global AGENTS.md** (currently just the
commit-message prompt). If the user has edited a built-in away from
Zed's shipped `default_content()`, the edited body is appended at the
top of the AGENTS.md block. Uncustomized built-ins (still on the shipped
default) are skipped so we don't pollute AGENTS.md with text the user
never wrote.

The migration is gated on the `skills` feature flag — users without the
flag never have their Rules touched in any way. A single global KVP flag
(`rules_to_skills_migration_done`) short-circuits the migration on
subsequent launches, so it runs at most once per machine even across
release channels. A process-lifetime `AtomicBool` guard additionally
prevents racing duplicate spawns when the underlying `cx.on_flags_ready`
callback fires multiple times at startup.

Migration is intentionally non-destructive: rule rows in the LMDB
database stay in place. Users can still see and edit them through the
existing UI, and a downgrade to a Zed build without skills support won't
lose anything.

Slug generation (`agent_skills::slugify_skill_name`) lowercases ASCII
letters, turns spaces into dashes, and drops every other
non-alphanumeric character entirely — so `foo!bar` becomes `foobar`, not
`foo-bar`. `&` is special-cased to become `and` (so `rock&roll` →
`rock-and-roll`). Slug collisions and pre-existing skill directories are
handled by appending `-2`, `-3`, etc.

A title-bar onboarding banner ("Skills have replaced Rules") surfaces
for every user on the `skills` feature flag. Clicking it opens a small
`AlertModal`-based explainer that summarizes the two destinations and
points users at the new `/skill-name` slash command (and notes that
`@`-mentions still work).

Closes AI-227
Closes AI-232

Release Notes:

- N/A
This commit is contained in:
Richard Feldman 2026-05-14 12:30:08 -04:00 committed by GitHub
parent 697cf0ccee
commit 5f5dd7ae30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1457 additions and 6 deletions

7
Cargo.lock generated
View file

@ -344,6 +344,7 @@ name = "agent_skills"
version = "0.1.0"
dependencies = [
"anyhow",
"const_format",
"fs",
"futures 0.3.32",
"gpui",
@ -364,6 +365,7 @@ dependencies = [
"agent-client-protocol",
"agent_servers",
"agent_settings",
"agent_skills",
"ai_onboarding",
"anyhow",
"async-channel 2.5.0",
@ -12271,6 +12273,7 @@ dependencies = [
name = "paths"
version = "0.1.0"
dependencies = [
"const_format",
"dirs",
"ignore",
"util",
@ -13648,6 +13651,8 @@ dependencies = [
"assets",
"chrono",
"collections",
"db",
"feature_flags",
"fs",
"futures 0.3.32",
"fuzzy",
@ -13660,6 +13665,7 @@ dependencies = [
"paths",
"rope",
"serde",
"serde_json",
"strum 0.27.2",
"tempfile",
"text",
@ -18205,6 +18211,7 @@ dependencies = [
"client",
"cloud_api_types",
"db",
"feature_flags",
"fs",
"git_ui",
"gpui",

View file

@ -551,6 +551,7 @@ circular-buffer = "1.0"
clap = { version = "4.4", features = ["derive", "wrap_help"] }
cocoa = "=0.26.0"
cocoa-foundation = "=0.2.0"
const_format = "0.2"
convert_case = "0.8.0"
core-foundation = "=0.10.0"
core-foundation-sys = "0.8.6"

View file

@ -13,6 +13,7 @@ path = "agent_skills.rs"
[dependencies]
anyhow.workspace = true
const_format.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true

View file

@ -1,4 +1,5 @@
use anyhow::{Context as _, Result};
use const_format::concatcp;
use fs::Fs;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
@ -13,6 +14,18 @@ pub const AGENTS_DIR_NAME: &str = ".agents";
/// Second segment of the skills directory path: `skills`.
pub const SKILLS_DIR_NAME: &str = "skills";
/// User-facing display form of the global skills directory path — i.e.
/// what a human should see in messages and prompts, with the platform's
/// native path separator and home-directory shorthand.
///
/// Windows doesn't recognize `~` as the home directory, so the env-var
/// form is used there instead.
#[cfg(target_os = "windows")]
pub const GLOBAL_SKILLS_DIR_DISPLAY: &str =
concatcp!("%USERPROFILE%\\", AGENTS_DIR_NAME, "\\", SKILLS_DIR_NAME);
#[cfg(not(target_os = "windows"))]
pub const GLOBAL_SKILLS_DIR_DISPLAY: &str = concatcp!("~/", AGENTS_DIR_NAME, "/", SKILLS_DIR_NAME);
/// Opaque identifier for the project scope a skill was loaded from.
///
/// `agent_skills` is a leaf crate and intentionally does not depend on
@ -273,13 +286,81 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> {
.context("Invalid YAML frontmatter"))
}
/// Maximum length for a valid skill name. Mirrors the upper bound enforced
/// by [`validate_name`].
pub const MAX_SKILL_NAME_LEN: usize = 64;
/// Convert an arbitrary human-readable string into a valid skill name, or
/// return `None` if no valid name can be produced (e.g. the input contains
/// no ASCII alphanumeric characters at all).
///
/// The transformation:
///
/// 1. Replaces each `&` with the word `and` (with separators on either
/// side), so titles like "rock & roll" or "AT&T" round-trip something
/// meaningful (`rock-and-roll`, `at-and-t`) rather than dropping the
/// `&` and silently mashing the neighbours together.
/// 2. ASCII-lowercases every ASCII letter.
/// 3. Replaces each space with `-`. Existing `-` characters are kept.
/// 4. **Drops** every other non-alphanumeric character entirely (NOT
/// replaced with a dash). So `foo!bar` slugifies to `foobar`, not
/// `foo-bar` — only word boundaries the user actually wrote (spaces)
/// become dashes.
/// 5. Collapses runs of `-` into a single `-`.
/// 6. Trims leading and trailing `-`.
/// 7. Truncates to [`MAX_SKILL_NAME_LEN`] bytes (then re-trims trailing `-`
/// in case the truncation landed on one).
///
/// The result, if `Some`, always satisfies [`validate_name`].
pub fn slugify_skill_name(input: &str) -> Option<String> {
// Substitute `&` with `-and-` BEFORE the per-character pass; the
// existing dash-collapsing and edge-trimming logic then handles the
// boundary cases (`foo & bar`, `&foo`, `foo&`, `&&`, etc.) for free.
let input = input.replace('&', "-and-");
let mut slug = String::with_capacity(input.len());
let mut last_was_dash = true; // suppress a leading `-`
for ch in input.chars() {
let mapped = if ch.is_ascii_alphanumeric() {
Some(ch.to_ascii_lowercase())
} else if ch == ' ' || ch == '-' {
Some('-')
} else {
// Drop the character entirely — and importantly, do NOT touch
// `last_was_dash`. That way `foo!bar` stays one run of
// alphanumerics (`foobar`) rather than getting a fake
// separator inserted (`foo-bar`).
None
};
let Some(c) = mapped else { continue };
if c == '-' {
if last_was_dash {
continue;
}
last_was_dash = true;
} else {
last_was_dash = false;
}
slug.push(c);
}
if slug.ends_with('-') {
slug.pop();
}
if slug.len() > MAX_SKILL_NAME_LEN {
slug.truncate(MAX_SKILL_NAME_LEN);
while slug.ends_with('-') {
slug.pop();
}
}
if slug.is_empty() { None } else { Some(slug) }
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Skill name cannot be empty");
}
if name.len() > 64 {
anyhow::bail!("Skill name must be at most 64 characters");
if name.len() > MAX_SKILL_NAME_LEN {
anyhow::bail!("Skill name must be at most {MAX_SKILL_NAME_LEN} characters");
}
if !name
@ -824,6 +905,236 @@ Content.
);
}
#[test]
fn test_slugify_basic() {
assert_eq!(
slugify_skill_name("My Cool Skill").as_deref(),
Some("my-cool-skill")
);
}
#[test]
fn test_slugify_strips_invalid_chars() {
// Punctuation is dropped; spaces between words still produce dashes.
// `Hello,` → `hello`, then `␣` → `-`, then `World!` → `world`, etc.
assert_eq!(
slugify_skill_name("Hello, World! (v2)").as_deref(),
Some("hello-world-v2")
);
}
#[test]
fn test_slugify_drops_punctuation_in_middle_no_spaces() {
// Punctuation between alphanumerics is dropped entirely — it does
// NOT become a dash. Only user-written spaces become dashes.
assert_eq!(slugify_skill_name("foo!bar").as_deref(), Some("foobar"));
assert_eq!(slugify_skill_name("foo?bar").as_deref(), Some("foobar"));
assert_eq!(slugify_skill_name("foo%bar").as_deref(), Some("foobar"));
assert_eq!(slugify_skill_name("100%sure").as_deref(), Some("100sure"));
assert_eq!(
slugify_skill_name("what's that").as_deref(),
Some("whats-that")
);
// `&` is special-cased to become `and` — see
// `test_slugify_ampersand_becomes_and` for the full coverage.
assert_eq!(
slugify_skill_name("don't&won't").as_deref(),
Some("dont-and-wont")
);
}
#[test]
fn test_slugify_ampersand_becomes_and() {
// No spaces around `&`.
assert_eq!(
slugify_skill_name("foo&bar").as_deref(),
Some("foo-and-bar")
);
assert_eq!(
slugify_skill_name("rock&roll").as_deref(),
Some("rock-and-roll")
);
// Spaces around `&`: collapses to a single dash on each side.
assert_eq!(
slugify_skill_name("foo & bar").as_deref(),
Some("foo-and-bar")
);
// Asymmetric spacing.
assert_eq!(
slugify_skill_name("foo& bar").as_deref(),
Some("foo-and-bar")
);
assert_eq!(
slugify_skill_name("foo &bar").as_deref(),
Some("foo-and-bar")
);
// Leading/trailing `&`: the substituted spaces become leading/
// trailing dashes which then get trimmed.
assert_eq!(slugify_skill_name("&foo").as_deref(), Some("and-foo"));
assert_eq!(slugify_skill_name("foo&").as_deref(), Some("foo-and"));
// `&` alone slugifies to the word `and`, not to `None`.
assert_eq!(slugify_skill_name("&").as_deref(), Some("and"));
assert_eq!(slugify_skill_name(" & ").as_deref(), Some("and"));
// Multiple `&`s with various spacing all collapse properly.
assert_eq!(slugify_skill_name("&&").as_deref(), Some("and-and"));
assert_eq!(
slugify_skill_name("foo & & bar").as_deref(),
Some("foo-and-and-bar")
);
// Mixed with other punctuation (other punctuation is still dropped).
assert_eq!(slugify_skill_name("AT&T").as_deref(), Some("at-and-t"));
assert_eq!(slugify_skill_name("Q&A!").as_deref(), Some("q-and-a"));
}
#[test]
fn test_slugify_punctuation_surrounded_by_spaces() {
// `foo ! bar` → `foo-bar`: the two spaces would each produce a
// dash, but consecutive dashes are collapsed.
assert_eq!(slugify_skill_name("foo ! bar").as_deref(), Some("foo-bar"));
assert_eq!(slugify_skill_name("foo ? bar").as_deref(), Some("foo-bar"));
assert_eq!(
slugify_skill_name("100 % sure").as_deref(),
Some("100-sure")
);
assert_eq!(
slugify_skill_name("foo @ bar @ baz").as_deref(),
Some("foo-bar-baz")
);
}
#[test]
fn test_slugify_punctuation_adjacent_to_space() {
// `foo! bar` and `foo !bar` both produce `foo-bar` — the
// punctuation contributes nothing, the single space contributes
// the dash.
assert_eq!(slugify_skill_name("foo! bar").as_deref(), Some("foo-bar"));
assert_eq!(slugify_skill_name("foo !bar").as_deref(), Some("foo-bar"));
assert_eq!(slugify_skill_name("foo? bar").as_deref(), Some("foo-bar"));
}
#[test]
fn test_slugify_leading_and_trailing_punctuation() {
// Punctuation at the edges is dropped; there's no leading/trailing
// dash to trim because the punctuation never became a dash in the
// first place.
assert_eq!(slugify_skill_name("!foo").as_deref(), Some("foo"));
assert_eq!(slugify_skill_name("foo!").as_deref(), Some("foo"));
assert_eq!(slugify_skill_name("!!!foo!!!").as_deref(), Some("foo"));
assert_eq!(slugify_skill_name("?foo?").as_deref(), Some("foo"));
assert_eq!(slugify_skill_name("...foo...").as_deref(), Some("foo"));
}
#[test]
fn test_slugify_only_punctuation_returns_none() {
assert_eq!(slugify_skill_name("!!!"), None);
assert_eq!(slugify_skill_name("?@$"), None);
assert_eq!(slugify_skill_name("()[]{}"), None);
assert_eq!(slugify_skill_name(".,;:"), None);
}
#[test]
fn test_slugify_mixed_punctuation_spaces_and_dashes() {
// A messy realistic input: combination of punctuation, spaces,
// existing dashes, and casing.
assert_eq!(
slugify_skill_name(" -- Hello, World!! -- ").as_deref(),
Some("hello-world")
);
assert_eq!(
slugify_skill_name("C++ vs. Rust?").as_deref(),
Some("c-vs-rust")
);
assert_eq!(
slugify_skill_name("v1.2.3-beta").as_deref(),
Some("v123-beta")
);
}
#[test]
fn test_slugify_underscores_are_dropped() {
// Underscores aren't a valid skill-name character and aren't
// separators — only spaces become dashes — so underscores get
// dropped entirely.
assert_eq!(slugify_skill_name("foo_bar").as_deref(), Some("foobar"));
assert_eq!(slugify_skill_name("FOO_BAR").as_deref(), Some("foobar"));
assert_eq!(
slugify_skill_name("snake_case style").as_deref(),
Some("snakecase-style")
);
}
#[test]
fn test_slugify_collapses_consecutive_dashes() {
assert_eq!(
slugify_skill_name("foo --- bar").as_deref(),
Some("foo-bar")
);
}
#[test]
fn test_slugify_trims_leading_and_trailing_dashes() {
assert_eq!(slugify_skill_name("---foo---").as_deref(), Some("foo"));
assert_eq!(slugify_skill_name(" foo ").as_deref(), Some("foo"));
}
#[test]
fn test_slugify_lowercases() {
assert_eq!(slugify_skill_name("FOO BAR").as_deref(), Some("foo-bar"));
assert_eq!(
slugify_skill_name("MyCoolSkill").as_deref(),
Some("mycoolskill")
);
}
#[test]
fn test_slugify_strips_non_ascii_letters() {
// Non-ASCII chars are replaced with `-`, then collapsed.
assert_eq!(slugify_skill_name("abc\u{00e9}").as_deref(), Some("abc"));
assert_eq!(slugify_skill_name("\u{4e2d}\u{6587}"), None);
}
#[test]
fn test_slugify_returns_none_for_empty_or_unmappable() {
assert_eq!(slugify_skill_name(""), None);
assert_eq!(slugify_skill_name(" "), None);
assert_eq!(slugify_skill_name("!!!"), None);
assert_eq!(slugify_skill_name("---"), None);
}
#[test]
fn test_slugify_truncates_long_inputs() {
let input = "a".repeat(200);
let slug = slugify_skill_name(&input).expect("should slugify");
assert_eq!(slug.len(), MAX_SKILL_NAME_LEN);
assert!(slug.chars().all(|c| c == 'a'));
}
#[test]
fn test_slugify_truncation_does_not_leave_trailing_dash() {
// The 64th byte lands on a `-`, which we must strip post-truncation.
let mut input = "a".repeat(63);
input.push_str(" extra");
let slug = slugify_skill_name(&input).expect("should slugify");
assert!(!slug.ends_with('-'));
assert!(slug.len() <= MAX_SKILL_NAME_LEN);
}
#[test]
fn test_slugify_output_passes_validate_name() {
for input in [
"My Cool Skill",
"Hello, World!",
"---foo---",
"123 abc",
"a".repeat(200).as_str(),
] {
let slug = slugify_skill_name(input).expect("should slugify");
validate_name(&slug).unwrap_or_else(|err| {
panic!("slug {slug:?} from {input:?} failed validation: {err}")
});
}
}
#[test]
fn test_parse_description_too_long() {
let long_desc = "a".repeat(1025);

View file

@ -30,6 +30,7 @@ acp_thread.workspace = true
action_log.workspace = true
agent-client-protocol.workspace = true
agent.workspace = true
agent_skills.workspace = true
async-channel.workspace = true
agent_servers.workspace = true
agent_settings.workspace = true

View file

@ -42,7 +42,7 @@ use ::ui::IconName;
use agent_client_protocol::schema as acp;
use agent_settings::{AgentProfileId, AgentSettings};
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _;
use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag};
use fs::Fs;
use gpui::{
Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, Window, actions,
@ -55,7 +55,7 @@ use language_model::{
ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
};
use project::{AgentId, DisableAiSettings};
use prompt_store::PromptBuilder;
use prompt_store::{PromptBuilder, rules_to_skills_migration};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide};
@ -552,6 +552,32 @@ pub fn init(
);
})
.detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(
|workspace: &mut Workspace,
_: &zed_actions::agent::OpenRulesToSkillsMigrationInfo,
window: &mut Window,
cx: &mut Context<Workspace>| {
// The banner is the only intended entry point and is
// gated on the skills flag, but dispatch from the
// command palette or a keybind is still possible — only
// open the explainer if the flag is enabled so it never
// surfaces outside its intended audience.
//
// Race note: `has_flag` returns false before server
// flags are received, so a dispatch during that brief
// window is a no-op even for users who genuinely have
// the flag. The banner itself has the same race — it
// stays hidden until flags arrive — so a user who can
// see the banner has, by definition, already passed it.
if cx.has_flag::<SkillsFeatureFlag>() {
crate::ui::RulesToSkillsModal::toggle(workspace, window, cx);
}
},
);
})
.detach();
cx.observe_new(ManageProfilesModal::register).detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(
@ -580,6 +606,18 @@ pub fn init(
})
.detach();
// Once the `skills` feature flag has resolved, kick off the one-time
// migration of non-Default Rules to global Skills. Idempotent and
// self-gated on the flag, so it's safe to call on every flag-ready
// notification (and a no-op for users without the flag).
{
let fs = fs.clone();
cx.on_flags_ready(move |_, cx| {
rules_to_skills_migration::migrate_rules_to_skills_if_needed(fs.clone(), cx);
})
.detach();
}
maybe_backfill_editor_layout(fs, is_new_install, cx);
}

View file

@ -3,6 +3,7 @@ mod end_trial_upsell;
mod hold_for_default;
mod mention_crease;
mod model_selector_components;
mod rules_to_skills_modal;
mod undo_reject_toast;
pub use agent_notification::*;
@ -10,6 +11,7 @@ pub use end_trial_upsell::*;
pub use hold_for_default::*;
pub use mention_crease::*;
pub use model_selector_components::*;
pub use rules_to_skills_modal::*;
pub use undo_reject_toast::*;
/// Returns the appropriate [`DocumentationSide`] for documentation asides

View file

@ -0,0 +1,196 @@
//! Mini modal shown when the user clicks the title-bar Skills
//! announcement banner. Renders one of two flavours depending on the
//! persisted [`MigrationResult`]:
//!
//! * **No rules migrated** (new user, or an existing user who never
//! touched Rules): a generic "Introducing Skills" intro that explains
//! what Skills are and how to invoke them.
//! * **Rules migrated**: a per-destination summary of exactly which
//! Rules ended up where (Skills directory, global AGENTS.md, top of
//! AGENTS.md for customized built-ins), capped at three names per
//! section with an "…and N more" overflow line.
use agent_skills::GLOBAL_SKILLS_DIR_DISPLAY;
use gpui::{
DismissEvent, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, Styled,
};
use paths::GLOBAL_AGENTS_FILE_DISPLAY;
use prompt_store::rules_to_skills_migration::{self, MigrationResult};
use ui::{
AlertModal, Button, ButtonCommon, ButtonStyle, Clickable, KeyBinding, ListBulletItem, h_flex,
prelude::*,
};
use workspace::{ModalView, Workspace};
/// Maximum number of rule names to list inline in the modal before
/// collapsing the rest into an "…and N more" line.
const MAX_LISTED_NAMES: usize = 3;
pub struct RulesToSkillsModal {
focus_handle: FocusHandle,
}
impl RulesToSkillsModal {
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
workspace.toggle_modal(window, cx, |_window, cx| Self {
focus_handle: cx.focus_handle(),
});
}
fn dismiss(&mut self, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl Focusable for RulesToSkillsModal {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for RulesToSkillsModal {}
impl ModalView for RulesToSkillsModal {}
impl Render for RulesToSkillsModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let result = rules_to_skills_migration::migration_result().unwrap_or_default();
let mut modal = AlertModal::new("rules-to-skills-migration")
.width(rems(28.))
.key_context("RulesToSkillsModal")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.dismiss(cx)))
.on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx)));
if result.is_empty() {
modal = render_introducing_skills(modal);
} else {
modal = render_migration_summary(modal, &result);
}
// Both flavours close with the same invocation instructions.
modal = modal.child(Label::new(
"To include a Skill in a prompt, type /skill-name (or @-mention it).",
));
modal.footer(
h_flex().p_3().items_center().justify_end().child(
Button::new("got-it", "Got it")
.style(ButtonStyle::Filled)
.layer(ui::ElevationIndex::ModalSurface)
.key_binding(
KeyBinding::for_action(&menu::Confirm, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _, _window, cx| {
this.dismiss(cx);
cx.stop_propagation();
})),
),
)
}
}
/// Render the modal body for users who had no Rules to migrate — a
/// generic introduction to the Skills feature.
fn render_introducing_skills(modal: AlertModal) -> AlertModal {
modal.title("Introducing Skills").child(Label::new(format!(
"Skills are reusable instructions for the agent, stored as Markdown files \
under {GLOBAL_SKILLS_DIR_DISPLAY}/<name>/SKILL.md."
)))
}
/// Render the modal body for users whose Rules were migrated, listing
/// each destination's contents (capped at [`MAX_LISTED_NAMES`] per
/// section).
fn render_migration_summary(mut modal: AlertModal, result: &MigrationResult) -> AlertModal {
modal = modal.title("Skills have replaced Rules");
if !result.skill_names.is_empty() {
modal = modal.child(Label::new(format!(
"These Rules have been migrated to Skills in {GLOBAL_SKILLS_DIR_DISPLAY}:"
)));
modal = add_bulleted_names(modal, &result.skill_names);
}
if !result.agents_md_names.is_empty() {
modal = modal.child(Label::new(format!(
"These Default Rules were added to {GLOBAL_AGENTS_FILE_DISPLAY}:"
)));
modal = add_bulleted_names(modal, &result.agents_md_names);
}
if !result.customized_builtins.is_empty() {
modal = modal.child(Label::new(customized_builtins_sentence(
&result.customized_builtins,
)));
}
modal
}
/// Append up to [`MAX_LISTED_NAMES`] bullet items naming individual
/// rules, plus a final "…and N more" bullet if the list is longer.
fn add_bulleted_names(mut modal: AlertModal, names: &[String]) -> AlertModal {
for name in names.iter().take(MAX_LISTED_NAMES) {
modal = modal.child(ListBulletItem::new(name.clone()));
}
if names.len() > MAX_LISTED_NAMES {
let extras = names.len() - MAX_LISTED_NAMES;
let label = if extras == 1 {
"…and 1 more".to_string()
} else {
format!("…and {extras} more")
};
modal = modal.child(ListBulletItem::new(label));
}
modal
}
/// Build the sentence describing any customized built-in prompts that
/// were prepended to AGENTS.md. Singular wording for the common
/// one-built-in case; comma-joined for the (currently hypothetical)
/// multi-built-in case.
fn customized_builtins_sentence(names: &[String]) -> String {
debug_assert!(
!names.is_empty(),
"caller should only invoke this for a non-empty list"
);
if names.len() == 1 {
format!(
"Your customization of the {name} built-in prompt has been added to the top of \
{GLOBAL_AGENTS_FILE_DISPLAY}.",
name = names[0],
)
} else {
format!(
"Your customizations of these built-in prompts have been added to the top of \
{GLOBAL_AGENTS_FILE_DISPLAY}: {joined}.",
joined = names.join(", "),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn customized_builtins_sentence_uses_singular_wording_for_one_item() {
let sentence = customized_builtins_sentence(&["Commit message".to_string()]);
assert!(sentence.contains("Your customization of the Commit message"));
assert!(sentence.contains("built-in prompt has been added"));
assert!(sentence.contains(GLOBAL_AGENTS_FILE_DISPLAY));
}
#[test]
fn customized_builtins_sentence_uses_plural_wording_for_multiple_items() {
let sentence = customized_builtins_sentence(&[
"Commit message".to_string(),
"Future Built-in".to_string(),
]);
assert!(sentence.contains("customizations of these built-in prompts"));
assert!(sentence.contains("Commit message, Future Built-in"));
}
}

View file

@ -15,6 +15,7 @@ test-support = []
path = "src/paths.rs"
[dependencies]
const_format.workspace = true
dirs.workspace = true
ignore.workspace = true
util.workspace = true

View file

@ -325,6 +325,23 @@ pub fn agents_file() -> &'static PathBuf {
AGENTS_FILE.get_or_init(|| config_dir().join("AGENTS.md"))
}
/// User-facing display form of the user-global `AGENTS.md` file path —
/// i.e. what a human should see in messages and prompts, with the
/// platform's native path separator and home/config directory shorthand.
///
/// Windows doesn't recognize `~` as the home directory, so the env-var
/// form (`%APPDATA%`) is used there instead. Note that this is the
/// *typical* location: a user with `XDG_CONFIG_HOME` set or running in a
/// Flatpak sandbox would see a different `agents_file()` at runtime than
/// this displays. The display string trades that precision for
/// readability in announcement copy.
#[cfg(target_os = "windows")]
pub const GLOBAL_AGENTS_FILE_DISPLAY: &str =
const_format::concatcp!("%APPDATA%\\", APP_NAME, "\\AGENTS.md");
#[cfg(not(target_os = "windows"))]
pub const GLOBAL_AGENTS_FILE_DISPLAY: &str =
const_format::concatcp!("~/.config/", APP_NAME_LOWERCASE, "/AGENTS.md");
/// Returns the path to the extensions directory.
///
/// This is where installed extensions are stored.

View file

@ -17,6 +17,8 @@ anyhow.workspace = true
assets.workspace = true
chrono.workspace = true
collections.workspace = true
db.workspace = true
feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
@ -29,11 +31,13 @@ parking_lot.workspace = true
paths.workspace = true
rope.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
text.workspace = true
util.workspace = true
uuid.workspace = true
[dev-dependencies]
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true

View file

@ -1,4 +1,5 @@
mod prompts;
pub mod rules_to_skills_migration;
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};

View file

@ -0,0 +1,847 @@
//! One-time migration from Rules (stored in the user's `PromptStore`
//! LMDB database) to two destinations:
//!
//! * **Non-Default Rules → global Agent Skills** under
//! `~/.agents/skills/<slug>/SKILL.md`. Non-Default Rules were only ever
//! included in a conversation when the user explicitly invoked them by
//! name, which maps cleanly onto Skills with
//! `disable-model-invocation: true` (slash-only, never auto-suggested
//! to the model). See [`migrate_non_default_rules_to_skills`].
//!
//! * **Default Rules → global AGENTS.md** at the platform-appropriate
//! path (see [`paths::agents_file`]). Default Rules were auto-included
//! in every conversation; the global AGENTS.md is loaded into the
//! system prompt of every conversation, so the migration target
//! preserves the behavior. Each rule is appended under an `## H2`
//! heading containing the rule's title. See
//! [`migrate_default_rules_to_agents_md`].
//!
//! **Customized built-in prompts** (currently just
//! [`BuiltInPrompt::CommitMessage`]) are treated the same as Default
//! user Rules — if the user has edited the body away from the
//! built-in's `default_content()`, the edited body is appended to
//! AGENTS.md ahead of any user Default Rules. Uncustomized built-ins
//! (still using Zed's shipped default content) are skipped so we don't
//! pollute AGENTS.md with text the user never wrote.
//!
//! Both migrations are gated by:
//!
//! * the `skills` feature flag — users without it never have their Rules
//! touched in any way;
//! * a single global "migration already ran" flag persisted in
//! [`GlobalKeyValueStore`] — keyed by [`MIGRATION_DONE_KEY`], so a
//! shared home directory only gets populated once per machine even
//! across release channels.
//!
//! The migration is intentionally non-destructive: rule rows in the LMDB
//! database are left in place after the migration. That way users can
//! still see and edit their Rules via the existing UI, and a user who
//! downgrades to a Zed build without skills support won't lose anything.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use agent_skills::{SKILL_FILE_NAME, global_skills_dir, slugify_skill_name};
use anyhow::{Context as _, Result};
use db::kvp::GlobalKeyValueStore;
use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag};
use fs::Fs;
use gpui::{App, AsyncApp, Entity, TaskExt as _};
use serde::{Deserialize, Serialize};
use util::ResultExt as _;
use crate::{BuiltInPrompt, PromptId, PromptStore};
use strum::IntoEnumIterator as _;
/// Global KVP flag: set to `"1"` once the migration has been considered
/// for this machine, regardless of whether any rules were actually
/// migrated. Used to short-circuit the migration on every subsequent
/// launch.
pub const MIGRATION_DONE_KEY: &str = "rules_to_skills_migration_done";
/// Global KVP key for the JSON-serialized [`MigrationResult`] produced by
/// the most recent migration run — the lists of source-Rule titles that
/// were migrated to each destination. The title-bar banner and its
/// explainer modal read this to decide what (if anything) to tell the
/// user about what changed.
pub const MIGRATION_RESULT_KEY: &str = "rules_to_skills_migration_result";
/// A persistent record of what the rules-to-skills migration actually
/// migrated. Persisted in [`GlobalKeyValueStore`] under
/// [`MIGRATION_RESULT_KEY`] and read back by the announcement UI so the
/// modal can list specific rule names instead of vaguely gesturing.
///
/// All three lists hold the *original* user-facing Rule titles, not the
/// derived skill slug or any other transformed identifier — those are
/// what users would recognize.
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct MigrationResult {
/// Non-Default Rules that were turned into global Skills under
/// `~/.agents/skills/`.
#[serde(default)]
pub skill_names: Vec<String>,
/// Default Rules that were appended to the global AGENTS.md.
#[serde(default)]
pub agents_md_names: Vec<String>,
/// Customized built-in prompts whose edited bodies were appended to
/// the top of the global AGENTS.md.
#[serde(default)]
pub customized_builtins: Vec<String>,
}
impl MigrationResult {
/// `true` if the migration didn't actually move any Rule anywhere —
/// i.e. the user had no Rules of any kind to migrate. The
/// announcement banner/modal uses this to switch between the
/// "Introducing: Skills" generic intro and the "Skills have replaced
/// Rules" migration summary.
pub fn is_empty(&self) -> bool {
self.skill_names.is_empty()
&& self.agents_md_names.is_empty()
&& self.customized_builtins.is_empty()
}
}
/// Read the most recently persisted [`MigrationResult`], if any. Returns
/// `None` when the migration hasn't run on this machine yet or the
/// persisted blob couldn't be parsed.
pub fn migration_result() -> Option<MigrationResult> {
let json = GlobalKeyValueStore::global()
.read_kvp(MIGRATION_RESULT_KEY)
.log_err()
.flatten()?;
serde_json::from_str(&json).log_err()
}
/// Placeholder description written into the YAML frontmatter of migrated
/// skills. Migrated skills are model-disabled, so the model never sees
/// this string — it exists only because the SKILL.md schema requires a
/// non-empty `description`.
const PLACEHOLDER_DESCRIPTION: &str = "(no description)";
/// Returns `true` if a previous launch has already completed the
/// rules-to-skills migration check.
pub fn migration_done() -> bool {
GlobalKeyValueStore::global()
.read_kvp(MIGRATION_DONE_KEY)
.log_err()
.flatten()
.is_some()
}
/// Process-lifetime guard ensuring the migration task is spawned at most
/// once per process. The KVP-backed [`migration_done`] flag handles the
/// across-launch idempotency, but it isn't enough on its own: this
/// function is wired to `cx.on_flags_ready`, which is implemented via
/// `observe_global::<FeatureFlagStore>` and therefore fires every time
/// the flag store mutates. At startup that can happen several times in
/// rapid succession (window construction, settings observers touching
/// globals, etc.). Without this guard, each of those firings would see
/// `migration_done() == false` (because the first in-flight spawn hasn't
/// written the KVP yet), spawn its own task, and the tasks would race —
/// each one calling `pick_available_skill_dir` and dutifully picking the
/// next free `-N` suffix because its sibling task already created the
/// previous one. The visible result is N duplicate `<rule>-2`,
/// `<rule>-3`, … directories per rule, where N is the number of times
/// the callback fired before the first spawn finished writing
/// `MIGRATION_DONE_KEY`.
static MIGRATION_TASK_SPAWNED: AtomicBool = AtomicBool::new(false);
/// Migrate non-Default user rules to global Skills, if not already done.
///
/// Safe to call on every startup — short-circuits immediately when the
/// migration has already run, when another invocation in this process
/// has already started it, or when the user doesn't have the `skills`
/// feature flag enabled.
pub fn migrate_rules_to_skills_if_needed(fs: Arc<dyn Fs>, cx: &mut App) {
if !cx.has_flag::<SkillsFeatureFlag>() {
return;
}
if migration_done() {
return;
}
// Atomically claim the right to spawn the migration task. If another
// invocation has already claimed it, we bail without spawning a
// second one — see the doc comment on `MIGRATION_TASK_SPAWNED` for
// why the KVP-backed check above isn't sufficient on its own.
if MIGRATION_TASK_SPAWNED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let prompt_store = PromptStore::global(cx);
cx.spawn(async move |cx| {
let prompt_store = prompt_store.await.context("loading prompt store")?;
// Snapshot the (id, title) pairs for every user rule, split by
// whether it's a Default rule or not. BuiltIn prompts (e.g. the
// commit-message prompt) are excluded — they're not user-facing
// "Rules" in the agent sense.
let (default_rules, non_default_rules) = prompt_store.read_with(cx, |store, _| {
let mut default = Vec::new();
let mut non_default = Vec::new();
for metadata in store.all_prompt_metadata() {
if metadata.id.as_user().is_none() {
continue;
}
let Some(title) = metadata.title.as_ref().map(|t| t.to_string()) else {
continue;
};
if metadata.default {
default.push((metadata.id, title));
} else {
non_default.push((metadata.id, title));
}
}
(default, non_default)
});
let mut result = MigrationResult::default();
result.skill_names =
migrate_non_default_rules_to_skills(fs.as_ref(), &prompt_store, cx, non_default_rules)
.await;
let (agents_md_names, customized_builtins) = migrate_default_rules_to_agents_md(
fs.as_ref(),
paths::agents_file(),
&prompt_store,
cx,
default_rules,
)
.await;
result.agents_md_names = agents_md_names;
result.customized_builtins = customized_builtins;
// Persist the result BEFORE the done flag: if we crash between
// these two writes the next launch will see `done == false` and
// re-run, picking up the same (deterministic) result — worst
// case is the AGENTS.md append happens twice, which is a
// pre-existing limitation of the AGENTS.md migration.
write_migration_result(&result).await;
mark_migration_done().await;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
/// Returns `true` if `body` (the result of `PromptStore::load` for the
/// given built-in) differs from the built-in's shipped `default_content`.
/// Customization detection is done by trimmed-string comparison so that
/// whitespace-only differences (e.g. trailing newlines) don't count as a
/// customization.
fn is_customized_builtin_body(builtin: BuiltInPrompt, body: &str) -> bool {
body.trim() != builtin.default_content().trim()
}
/// Convert every non-Default user rule into a global Agent Skill on disk.
/// Returns the titles of rules that were successfully migrated (i.e. the
/// ones the user will recognize when the announcement modal lists
/// "these Rules have been migrated to Skills").
async fn migrate_non_default_rules_to_skills(
fs: &dyn Fs,
prompt_store: &Entity<PromptStore>,
cx: &mut AsyncApp,
rules: Vec<(PromptId, String)>,
) -> Vec<String> {
if rules.is_empty() {
return Vec::new();
}
let skills_dir = global_skills_dir();
let mut migrated = Vec::with_capacity(rules.len());
for (id, title) in rules {
let body = match load_rule_body(prompt_store, cx, id, &title).await {
Some(body) => body,
None => continue,
};
let Some(slug) = slugify_skill_name(&title) else {
log::warn!(
"Skipping rule {title:?}: title contains no characters \
valid for a skill name"
);
continue;
};
match write_migrated_skill(fs, &skills_dir, &slug, &body).await {
Ok(()) => migrated.push(title),
Err(err) => {
log::warn!("Failed to write skill for rule {title:?}: {err:#}");
}
}
}
migrated
}
/// Append all auto-included Rules to the global `AGENTS.md`, creating it
/// if necessary. Each rule lands under an `## H2` heading containing its
/// title, with the rule body underneath.
///
/// The appended block contains, in order:
///
/// 1. Each [`BuiltInPrompt`] the user has customized (uncustomized
/// built-ins are skipped so we don't write Zed's shipped default text
/// into the user's personal AGENTS.md).
/// 2. Each user Default Rule, in the order given.
///
/// Returns `(default_user_rule_titles, customized_builtin_titles)` of
/// what actually got appended, for the announcement modal to surface.
async fn migrate_default_rules_to_agents_md(
fs: &dyn Fs,
agents_md_path: &Path,
prompt_store: &Entity<PromptStore>,
cx: &mut AsyncApp,
default_user_rules: Vec<(PromptId, String)>,
) -> (Vec<String>, Vec<String>) {
let mut entries: Vec<(String, String)> = Vec::new();
let mut customized_builtin_titles: Vec<String> = Vec::new();
let mut default_user_titles: Vec<String> = Vec::new();
// Customized built-ins come first.
for builtin in BuiltInPrompt::iter() {
let id = PromptId::BuiltIn(builtin);
let title = builtin.title().to_string();
let Some(body) = load_rule_body(prompt_store, cx, id, &title).await else {
continue;
};
if !is_customized_builtin_body(builtin, &body) {
continue;
}
customized_builtin_titles.push(title.clone());
entries.push((title, body));
}
// Then user Default Rules.
for (id, title) in default_user_rules {
let Some(body) = load_rule_body(prompt_store, cx, id, &title).await else {
continue;
};
default_user_titles.push(title.clone());
entries.push((title, body));
}
if entries.is_empty() {
return (default_user_titles, customized_builtin_titles);
}
if let Err(err) = append_default_rules_to_agents_md(fs, agents_md_path, &entries).await {
log::warn!("Failed to append default rules to AGENTS.md: {err:#}");
// Treat a write failure as "nothing was actually migrated" so the
// announcement modal doesn't lie about what's in AGENTS.md.
return (Vec::new(), Vec::new());
}
(default_user_titles, customized_builtin_titles)
}
async fn load_rule_body(
prompt_store: &Entity<PromptStore>,
cx: &mut AsyncApp,
id: PromptId,
title: &str,
) -> Option<String> {
let task = prompt_store.update(cx, |store, cx| store.load(id, cx));
match task.await {
Ok(body) => Some(body),
Err(err) => {
log::warn!("Skipping rule {title:?}: failed to load body: {err:#}");
None
}
}
}
/// Build the markdown text to append for the given (title, body) rules
/// and write it to `agents_md_path`, preserving any existing AGENTS.md
/// content above the appended block.
async fn append_default_rules_to_agents_md(
fs: &dyn Fs,
agents_md_path: &Path,
rules: &[(String, String)],
) -> Result<()> {
if rules.is_empty() {
return Ok(());
}
let appended = format_default_rules_section(rules);
// `fs.load` errors when the file is missing OR unreadable; treat both
// as "no existing content" so the file gets (re-)created from the
// migrated text.
let existing_trimmed = fs
.load(agents_md_path)
.await
.ok()
.map(|s| s.trim().to_string());
let final_contents = match existing_trimmed.as_deref() {
Some(existing) if !existing.is_empty() => format!("{existing}\n\n{appended}\n"),
_ => format!("{appended}\n"),
};
fs.write(agents_md_path, final_contents.as_bytes()).await?;
Ok(())
}
/// Build the markdown text representing the migrated Default Rules block.
/// Each rule contributes an `## H2` heading followed by its body, with
/// rules separated by blank lines.
fn format_default_rules_section(rules: &[(String, String)]) -> String {
let mut out = String::new();
for (title, body) in rules {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str("## ");
out.push_str(title);
out.push_str("\n\n");
out.push_str(body.trim());
}
out
}
async fn mark_migration_done() {
GlobalKeyValueStore::global()
.write_kvp(MIGRATION_DONE_KEY.into(), "1".into())
.await
.log_err();
}
async fn write_migration_result(result: &MigrationResult) {
let json = match serde_json::to_string(result) {
Ok(json) => json,
Err(err) => {
log::warn!("Failed to serialize rules-to-skills migration result: {err:#}");
return;
}
};
GlobalKeyValueStore::global()
.write_kvp(MIGRATION_RESULT_KEY.into(), json)
.await
.log_err();
}
/// Write a single migrated rule to disk as `<skills_dir>/<name>/SKILL.md`.
///
/// Three cases:
///
/// 1. `<skills_dir>/<slug>/SKILL.md` already exists with byte-identical
/// content to what we'd write — likely because the migration ran
/// successfully on a previous launch and is now being asked to
/// re-migrate the same source rule. Skip silently; don't create a
/// `<slug>-2` duplicate of the same content.
/// 2. `<skills_dir>/<slug>/` doesn't exist — happy path. Create it and
/// write the SKILL.md there.
/// 3. `<skills_dir>/<slug>/` exists with *different* content (a real
/// name collision or a hand-edited skill we shouldn't touch). Pick
/// the first free `<slug>-2`, `<slug>-3`, … and write there with the
/// suffixed name baked into the SKILL.md frontmatter.
async fn write_migrated_skill(
fs: &dyn Fs,
skills_dir: &Path,
slug: &str,
body: &str,
) -> Result<()> {
let primary_dir = skills_dir.join(slug);
let primary_file = primary_dir.join(SKILL_FILE_NAME);
let primary_content = format_skill_file(slug, body);
// Case 1: primary exists with identical content — nothing to do.
// Compare trimmed so a stray leading/trailing newline difference
// (which is meaningless inside a SKILL.md) doesn't trick us into
// generating a `<slug>-N` duplicate.
if fs.is_file(&primary_file).await
&& fs
.load(&primary_file)
.await
.ok()
.is_some_and(|existing| existing.trim() == primary_content.trim())
{
return Ok(());
}
// Cases 2 and 3: find a free directory (the primary if free,
// otherwise a `-N` suffix) and write the SKILL.md there.
let (name, dir) = pick_available_skill_dir(fs, skills_dir, slug).await?;
fs.create_dir(&dir).await?;
let content = if name == slug {
primary_content
} else {
format_skill_file(&name, body)
};
let skill_file_path = dir.join(SKILL_FILE_NAME);
fs.write(&skill_file_path, content.as_bytes()).await?;
Ok(())
}
/// Build the SKILL.md file contents for a migrated rule.
fn format_skill_file(name: &str, body: &str) -> String {
let mut output = format!(
"---\nname: {name}\ndescription: {PLACEHOLDER_DESCRIPTION}\n\
disable-model-invocation: true\n---\n"
);
let trimmed_body = body.trim();
if !trimmed_body.is_empty() {
output.push('\n');
output.push_str(trimmed_body);
output.push('\n');
}
output
}
/// Cap on how many suffixed variants we'll try before giving up. In
/// practice nobody has more than a handful of rules with the same slug;
/// the cap exists purely to bound the worst case if a user has somehow
/// filled `~/.agents/skills/` with thousands of `name-N` directories.
const MAX_SLUG_SUFFIX: usize = 1000;
async fn pick_available_skill_dir(
fs: &dyn Fs,
skills_dir: &Path,
slug: &str,
) -> Result<(String, PathBuf)> {
let primary = skills_dir.join(slug);
if !fs.is_dir(&primary).await {
return Ok((slug.to_string(), primary));
}
for i in 2..=MAX_SLUG_SUFFIX {
let candidate_name = format!("{slug}-{i}");
let candidate_dir = skills_dir.join(&candidate_name);
if !fs.is_dir(&candidate_dir).await {
return Ok((candidate_name, candidate_dir));
}
}
anyhow::bail!(
"no free skill directory found under {} for slug {slug:?} \
after {MAX_SLUG_SUFFIX} attempts",
skills_dir.display()
);
}
#[cfg(test)]
mod tests {
use super::*;
use agent_skills::{SkillSource, parse_skill_frontmatter};
use fs::FakeFs;
use gpui::TestAppContext;
#[test]
fn format_skill_file_includes_disable_model_invocation() {
let content = format_skill_file("my-rule", "Body text.");
assert!(content.contains("\nname: my-rule\n"));
assert!(content.contains(&format!("\ndescription: {PLACEHOLDER_DESCRIPTION}\n")));
assert!(content.contains("\ndisable-model-invocation: true\n"));
assert!(content.ends_with("Body text.\n"));
}
#[test]
fn format_skill_file_handles_empty_body() {
let content = format_skill_file("my-rule", " \n ");
// Even for an empty body, the closing `---` must be present.
assert!(content.contains("\n---\n"));
assert!(content.contains("disable-model-invocation: true"));
}
#[test]
fn format_skill_file_round_trips_through_parser() {
let content = format_skill_file("my-rule", "Hello world.");
let skill = parse_skill_frontmatter(
Path::new("/skills/my-rule/SKILL.md"),
&content,
SkillSource::Global,
)
.expect("migrated SKILL.md should parse");
assert_eq!(skill.name, "my-rule");
assert_eq!(skill.description, PLACEHOLDER_DESCRIPTION);
assert!(skill.disable_model_invocation);
}
#[gpui::test]
async fn pick_available_skill_dir_returns_primary_when_unused(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let skills_dir = PathBuf::from("/skills");
fs.create_dir(&skills_dir).await.unwrap();
let (name, dir) = pick_available_skill_dir(fs.as_ref(), &skills_dir, "my-rule")
.await
.unwrap();
assert_eq!(name, "my-rule");
assert_eq!(dir, skills_dir.join("my-rule"));
}
#[gpui::test]
async fn pick_available_skill_dir_appends_suffix_on_collision(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let skills_dir = PathBuf::from("/skills");
fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
fs.create_dir(&skills_dir.join("my-rule-2")).await.unwrap();
let (name, dir) = pick_available_skill_dir(fs.as_ref(), &skills_dir, "my-rule")
.await
.unwrap();
assert_eq!(name, "my-rule-3");
assert_eq!(dir, skills_dir.join("my-rule-3"));
}
#[gpui::test]
async fn write_migrated_skill_creates_directory_and_file(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let skills_dir = PathBuf::from("/skills");
fs.create_dir(&skills_dir).await.unwrap();
write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.")
.await
.unwrap();
let written = fs
.load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
.await
.expect("SKILL.md should exist");
let skill = parse_skill_frontmatter(
&skills_dir.join("my-rule").join(SKILL_FILE_NAME),
&written,
SkillSource::Global,
)
.expect("written SKILL.md should parse");
assert_eq!(skill.name, "my-rule");
assert!(skill.disable_model_invocation);
}
#[gpui::test]
async fn write_migrated_skill_skips_when_primary_content_is_identical(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let skills_dir = PathBuf::from("/skills");
fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
// Seed the primary location with byte-identical content to what the
// migration would write.
let identical = format_skill_file("my-rule", "Body.");
fs.insert_file(
&skills_dir.join("my-rule").join(SKILL_FILE_NAME),
identical.as_bytes().to_vec(),
)
.await;
write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.")
.await
.unwrap();
// No `-2` duplicate should have been produced.
assert!(!fs.is_dir(&skills_dir.join("my-rule-2")).await);
// Primary still has the same content.
let primary = fs
.load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
.await
.unwrap();
assert_eq!(primary, identical);
}
#[gpui::test]
async fn write_migrated_skill_skips_when_primary_differs_only_in_whitespace(
cx: &mut TestAppContext,
) {
let fs = FakeFs::new(cx.executor());
let skills_dir = PathBuf::from("/skills");
fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
// Same logical content but with extra leading/trailing whitespace
// (which is meaningless inside a SKILL.md).
let mut padded = String::from("\n\n");
padded.push_str(format_skill_file("my-rule", "Body.").trim());
padded.push_str("\n\n");
fs.insert_file(
&skills_dir.join("my-rule").join(SKILL_FILE_NAME),
padded.as_bytes().to_vec(),
)
.await;
write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Body.")
.await
.unwrap();
// No `-2` duplicate.
assert!(!fs.is_dir(&skills_dir.join("my-rule-2")).await);
// Primary content was NOT overwritten — the user's whitespace is
// preserved verbatim.
let primary = fs
.load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
.await
.unwrap();
assert_eq!(primary, padded);
}
#[gpui::test]
async fn write_migrated_skill_does_not_clobber_existing_skill(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let skills_dir = PathBuf::from("/skills");
fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
fs.insert_file(
&skills_dir.join("my-rule").join(SKILL_FILE_NAME),
b"---\nname: my-rule\ndescription: pre-existing\n---\n\nDo not touch.\n".to_vec(),
)
.await;
write_migrated_skill(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.")
.await
.unwrap();
let pre_existing = fs
.load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
.await
.unwrap();
assert!(pre_existing.contains("Do not touch."));
let migrated = fs
.load(&skills_dir.join("my-rule-2").join(SKILL_FILE_NAME))
.await
.expect("migrated SKILL.md should have landed at the suffixed path");
assert!(migrated.contains("Migrated body."));
assert!(migrated.contains("disable-model-invocation: true"));
}
#[test]
fn format_default_rules_section_renders_headings_and_bodies() {
let rules = vec![
(
"My First Rule".to_string(),
"Body of first rule.".to_string(),
),
(
"Second Rule".to_string(),
"Body of second rule.".to_string(),
),
];
let section = format_default_rules_section(&rules);
let expected = "## My First Rule\n\nBody of first rule.\n\n\
## Second Rule\n\nBody of second rule.";
assert_eq!(section, expected);
}
#[test]
fn format_default_rules_section_trims_individual_bodies() {
// Leading and trailing whitespace on each body is trimmed, so we
// don't end up with weird gaps between sections.
let rules = vec![(
"Whitespace Rule".to_string(),
"\n\n Body with surrounding whitespace. \n\n".to_string(),
)];
let section = format_default_rules_section(&rules);
assert_eq!(
section,
"## Whitespace Rule\n\nBody with surrounding whitespace."
);
}
#[test]
fn format_default_rules_section_handles_empty_input() {
assert_eq!(format_default_rules_section(&[]), "");
}
#[test]
fn is_customized_builtin_body_returns_false_for_exact_default() {
let default = BuiltInPrompt::CommitMessage.default_content();
assert!(!is_customized_builtin_body(
BuiltInPrompt::CommitMessage,
default,
));
}
#[test]
fn is_customized_builtin_body_ignores_surrounding_whitespace() {
// Trailing/leading whitespace doesn't count as a real edit.
let default = BuiltInPrompt::CommitMessage.default_content();
let padded = format!("\n\n {} \n\n", default.trim());
assert!(!is_customized_builtin_body(
BuiltInPrompt::CommitMessage,
&padded,
));
}
#[test]
fn is_customized_builtin_body_returns_true_for_real_edit() {
let mut edited = BuiltInPrompt::CommitMessage.default_content().to_string();
edited.push_str("\n\nAlways mention the ticket number.");
assert!(is_customized_builtin_body(
BuiltInPrompt::CommitMessage,
&edited,
));
}
#[test]
fn is_customized_builtin_body_returns_true_for_completely_different_body() {
assert!(is_customized_builtin_body(
BuiltInPrompt::CommitMessage,
"Use emoji and rhyming couplets.",
));
}
#[gpui::test]
async fn append_default_rules_creates_agents_md_when_missing(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let agents_md = PathBuf::from("/config/AGENTS.md");
// Don't pre-create the file or its parent dir; `fs.write` should
// create both.
let rules = vec![("Rule One".to_string(), "Body one.".to_string())];
append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
.await
.unwrap();
let contents = fs.load(&agents_md).await.unwrap();
assert_eq!(contents, "## Rule One\n\nBody one.\n");
}
#[gpui::test]
async fn append_default_rules_appends_to_existing_agents_md(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let agents_md = PathBuf::from("/config/AGENTS.md");
fs.create_dir(agents_md.parent().unwrap()).await.unwrap();
fs.insert_file(
&agents_md,
b"# Top-level Agents Doc\n\nPre-existing user content.\n".to_vec(),
)
.await;
let rules = vec![
("Rule One".to_string(), "Body one.".to_string()),
("Rule Two".to_string(), "Body two.".to_string()),
];
append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
.await
.unwrap();
let contents = fs.load(&agents_md).await.unwrap();
// Existing content is preserved (verbatim, just trimmed of
// trailing whitespace), followed by a blank-line separator and
// the appended migrated section.
assert!(contents.starts_with("# Top-level Agents Doc\n\nPre-existing user content."));
assert!(contents.contains("\n\n## Rule One\n\nBody one."));
assert!(contents.contains("\n\n## Rule Two\n\nBody two.\n"));
}
#[gpui::test]
async fn append_default_rules_treats_whitespace_only_file_as_empty(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let agents_md = PathBuf::from("/config/AGENTS.md");
fs.create_dir(agents_md.parent().unwrap()).await.unwrap();
fs.insert_file(&agents_md, b" \n\n \n".to_vec()).await;
let rules = vec![("Rule One".to_string(), "Body one.".to_string())];
append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
.await
.unwrap();
// Existing whitespace is discarded; the result is just the
// migrated section as if the file had been missing.
let contents = fs.load(&agents_md).await.unwrap();
assert_eq!(contents, "## Rule One\n\nBody one.\n");
}
#[gpui::test]
async fn append_default_rules_no_op_for_empty_rules(cx: &mut TestAppContext) {
let fs = FakeFs::new(cx.executor());
let agents_md = PathBuf::from("/config/AGENTS.md");
append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &[])
.await
.unwrap();
// The file should not have been created.
assert!(!fs.is_file(&agents_md).await);
}
}

View file

@ -40,6 +40,7 @@ chrono.workspace = true
client.workspace = true
cloud_api_types.workspace = true
db.workspace = true
feature_flags.workspace = true
git_ui.workspace = true
gpui = { workspace = true, features = ["screen-capture"] }
icons.workspace = true

View file

@ -1,5 +1,6 @@
// This module provides infrastructure for showing onboarding banners in the title bar.
// It's currently not in use but is kept for future feature announcements.
// Currently used by the "Skills have replaced Rules" announcement; older usages
// (Claude Agent, ACP) lived here previously and were removed.
#![allow(dead_code)]
use gpui::{Action, Entity, Global, Render, SharedString, TaskExt};

View file

@ -25,6 +25,7 @@ use auto_update::AutoUpdateStatus;
use call::ActiveCall;
use client::{Client, UserStore, zed_urls};
use cloud_api_types::Plan;
use feature_flags::{FeatureFlagAppExt as _, SkillsFeatureFlag};
use gpui::{
Action, Anchor, Animation, AnimationExt, AnyElement, App, Context, Element, Entity, Focusable,
@ -454,6 +455,23 @@ impl TitleBar {
titlebar
});
// The banner label stays static ("Introducing: Skills") regardless
// of whether the user had Rules to migrate; the explainer modal
// is where the migration-specific summary surfaces. Keeping the
// label static avoids the rebuild-on-migration-completion plumbing
// we'd otherwise need to dodge the title-bar-vs-migration race.
let banner = Some(cx.new(|cx| {
OnboardingBanner::new(
"Skills Migration Announcement",
IconName::Sparkle,
"Skills",
Some("Introducing:".into()),
zed_actions::agent::OpenRulesToSkillsMigrationInfo.boxed_clone(),
cx,
)
.visible_when(|cx| cx.has_flag::<SkillsFeatureFlag>())
}));
let mut this = Self {
platform_titlebar,
application_menu,
@ -463,7 +481,7 @@ impl TitleBar {
user_store,
client,
_subscriptions: subscriptions,
banner: None,
banner,
update_version,
screen_share_popover_handle: PopoverMenuHandle::default(),
_diagnostics_subscription: None,

View file

@ -512,6 +512,10 @@ pub mod agent {
ResetAgentZoom,
/// Pastes clipboard content without any formatting.
PasteRaw,
/// Opens the "Skills have replaced Rules" explainer modal,
/// describing the one-time migration of non-Default Rules to
/// global Skills. Dispatched from the title-bar banner.
OpenRulesToSkillsMigrationInfo,
]
);