Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 8 additions & 41 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,33 +48,24 @@ class GemSkillLinker
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir, tmpdir: tmpdir)
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
# lock. A skill that resolves under the temp dir is never linked —
# SkillInstaller refuses ephemeral sources at the shared seam — but its
# gem still counts as present for pruning, so an ephemeral resolution
# cannot delete a durable link minted earlier. Never raises — skill
# links are hygiene riding a dependency install, and hygiene must not
# block correctness (failures are reported on stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -162,30 +153,6 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
41 changes: 39 additions & 2 deletions lib/dev/skill_installer.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,11 +28,19 @@ class SkillInstaller

# @param skills_dir [Pathname, String] target dir the symlinks live in;
# defaults to the user-global ~/.cursor/skills
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills")
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture skill trees themselves live under the real temp dir)
def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills", tmpdir: Dir.tmpdir)
@skills_dir = Pathname(skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Install or refresh one skill symlink. Never raises: a broken skill
# Install or refresh one skill symlink. A source that resolves under the
# temp dir is never linked (warned and skipped): a durable link to
# purgeable state silently dangles later, whatever produced it — e.g. a
# dev running from a temp clone would re-point the machine-global links
# at itself through SHIPPED_SKILLS_DIR. Never raises: a broken skill
# install must not block the command it rides (the failure is reported
# on stderr).
#
Expand All@@ -43,6 +51,12 @@ def install(name, source_dir)
source = Pathname(source_dir)
return unless source.directory?

if ephemeral?(source)
$stderr.puts "dev: warning: not linking #{name} — #{source} is under the temp dir " \
"and would dangle once it is purged."
return
end

link = @skills_dir / name
return if link.symlink? && link.readlink == source

Expand DownExpand Up@@ -92,6 +106,29 @@ def remove(name)

private

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while realpath resolution reports the
# /private/var/... spelling, so containment must check both.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a path resolves under the temp dir — a durable link to it
# would dangle once the temp dir is purged, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Prune symlinks that point under source_root but whose target skill no
# longer exists (e.g. a skill removed from the knowledge repo). Links
# pointing elsewhere are never touched.
Expand Down
4 changes: 3 additions & 1 deletion test/dev/learnings/accessor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,9 @@ def build_env(dir, project_root: :default)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
project = project_root == :default ? Pathname(dir) / "repo" : project_root
FileUtils.mkdir_p(project) if project
Expand Down
6 changes: 4 additions & 2 deletions test/dev/learnings/synchronizer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,9 @@ def build_env(dir, refresh_floor: 0)
File.write(config, "knowledge_repo: #{source}\n")
settings = Dev::Settings.new(config_path: config)
cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor)
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
# The fixture cache lives under the real temp dir; the tmpdir override
# keeps the installer's ephemeral-source guard out of these tests' way.
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer)
Expand DownExpand Up@@ -142,7 +144,7 @@ def commit_all(source, message)
dir = Dir.mktmpdir("dev-learnings-sync-test-")
saved_env = ENV.delete("DEV_KNOWLEDGE_REPO")
settings = Dev::Settings.new(config_path: File.join(dir, "config.yml"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"))
installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills"), tmpdir: File.join(dir, "tmp"))
synchronizer = Dev::Learnings::Synchronizer.for(settings: settings, skill_installer: installer)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Expand Down
95 changes: 84 additions & 11 deletions test/dev/skill_installer_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,20 @@ def build_skill(dir, *path_parts)
source
end

# Installer under test. The real Dir.tmpdir contains these tests' own
# fixture trees, so every installer gets a tmpdir override pointing inside
# the fixture dir — sources built by build_skill read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_installer(dir, skills_dir:)
Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: File.join(dir, "tmp"))
end

test "install creates the symlink on first run" do
Given "a skill source and an empty skills dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -38,7 +46,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("ai-flow", source)

When "installing again"
Expand All@@ -58,7 +66,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "old-location"), File.join(skills_dir, "ai-flow"))
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing the skill"
installer.install("ai-flow", source)
Expand All@@ -78,7 +86,7 @@ def build_skill(dir, *path_parts)
user_dir = File.join(skills_dir, "ai-flow")
FileUtils.mkdir_p(user_dir)
File.write(File.join(user_dir, "SKILL.md"), "user's own\n")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -99,7 +107,7 @@ def build_skill(dir, *path_parts)
Given "an installer and a nonexistent source"
dir = Dir.mktmpdir("dev-skill-test-")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing from the missing source"
installer.install("ai-flow", File.join(dir, "missing"))
Expand All@@ -111,14 +119,79 @@ def build_skill(dir, *path_parts)
FileUtils.rm_rf(dir)
end

test "install refuses a source under the temp dir and warns" do
Given "a skill source living under the ephemeral temp dir (e.g. a dev checkout in a build workspace)"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "no link is minted and the skip is warned"
!File.exist?(File.join(skills_dir, "ai-flow"))
$stderr.string.include?("not linking ai-flow")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install refuses an ephemeral source even when it would replace a dangling link" do
Given "a link already dangling, and a refresh source under the temp dir"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "clone", "share", "cursor-skills", "ai-flow")
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
File.symlink(File.join(dir, "gone"), File.join(skills_dir, "ai-flow"))
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the existing link is left alone rather than re-pointed at purgeable state"
File.readlink(File.join(skills_dir, "ai-flow")) == File.join(dir, "gone")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots (macOS /var vs /private/var)" do
Given "a tmpdir override that is a symlink to the dir the source lives under"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "tmp", "share", "cursor-skills", "ai-flow")
tmp_alias = File.join(dir, "tmp-alias")
File.symlink(File.join(dir, "tmp"), tmp_alias)
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "installing the skill"
installer.install("ai-flow", source)

Then "the path is recognized as ephemeral and never links"
!File.exist?(File.join(skills_dir, "ai-flow"))

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "install warns instead of raising when the skills dir cannot be created" do
Given "a skills dir under a read-only parent"
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "share", "cursor-skills", "ai-flow")
read_only_parent = File.join(dir, "read-only")
FileUtils.mkdir_p(read_only_parent)
FileUtils.chmod(0o555, read_only_parent)
installer = Dev::SkillInstaller.new(skills_dir: File.join(read_only_parent, "skills"))
installer = build_installer(dir, skills_dir: File.join(read_only_parent, "skills"))
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -141,7 +214,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "typed-errors")
FileUtils.mkdir_p(File.join(dir, "source", "not-a-skill"))
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills"
installer.install_all(File.join(dir, "source"))
Expand All@@ -160,7 +233,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
build_skill(dir, "gems", "rspock-1.2.0", "skills", "rspock")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)

When "installing all skills with a prefix"
installer.install_all(File.join(dir, "gems", "rspock-1.2.0", "skills"), prefix: "gem-rspock--")
Expand All@@ -179,7 +252,7 @@ def build_skill(dir, *path_parts)
build_skill(dir, "source", "kept")
removed = build_skill(dir, "source", "removed")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install_all(source_root)
FileUtils.rm_rf(removed)
foreign_target = build_skill(dir, "elsewhere", "mine")
Expand All@@ -206,7 +279,7 @@ def build_skill(dir, *path_parts)
skills_dir = File.join(dir, "skills")
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -227,7 +300,7 @@ def build_skill(dir, *path_parts)
dir = Dir.mktmpdir("dev-skill-test-")
source = build_skill(dir, "source", "linked")
skills_dir = File.join(dir, "skills")
installer = Dev::SkillInstaller.new(skills_dir: skills_dir)
installer = build_installer(dir, skills_dir: skills_dir)
installer.install("linked", source)
user_dir = File.join(skills_dir, "user-owned")
FileUtils.mkdir_p(user_dir)
Expand Down
Loading