mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
697cf0ccee
commit
5f5dd7ae30
17 changed files with 1457 additions and 6 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
196
crates/agent_ui/src/ui/rules_to_skills_modal.rs
Normal file
196
crates/agent_ui/src/ui/rules_to_skills_modal.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ test-support = []
|
|||
path = "src/paths.rs"
|
||||
|
||||
[dependencies]
|
||||
const_format.workspace = true
|
||||
dirs.workspace = true
|
||||
ignore.workspace = true
|
||||
util.workspace = true
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod prompts;
|
||||
pub mod rules_to_skills_migration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
|
|||
847
crates/prompt_store/src/rules_to_skills_migration.rs
Normal file
847
crates/prompt_store/src/rules_to_skills_migration.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue