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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ idempotent. The script uses `skills@1.5.14`, explicit Git tags or commits, and
explicit skill names. In this CLI, `#ref` selects a Git branch or tag; `@name`
selects a skill and must not be used as a version pin. Sources pinned to a raw
commit are checked out and verified before being passed to the manager as a
local source. Before reporting success, the script verifies all 37 entrypoints
local source. Before reporting success, the script verifies all 38 entrypoints
in the shared and Claude Code manager roots and bootstraps the copied
`gemini-files-api` dependencies in both roots. The Claude Code root honors
`CLAUDE_CONFIG_DIR` when it is set. The script also converts the pinned
Expand Down Expand Up @@ -66,6 +66,19 @@ not commit a local folder with the same name as a global skill. External
project-only skills should be installed by that project's bootstrap from an
explicit `#ref`, which also produces its project `skills-lock.json`.

Check one or more project repositories against the baseline before adding or
renaming project skills:

```bash
./scripts/check_project_skill_ownership.rb <project-repo> [<project-repo> ...]
```

The check derives managed names from `bootstrap.sh`; it rejects tracked
managed-name client-root mirrors, duplicate project skill names, and a
`SKILL.md` name that does not match its directory. It evaluates paths and
contents from the Git index so unstaged working-tree edits cannot hide what
the next commit contains.

Projects that use Impeccable should install its reviewed version locally from
`pbakaus/impeccable#skill-v3.9.1` in their project bootstrap. For example:

Expand Down
8 changes: 6 additions & 2 deletions bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ owned_skills=(
run_add . \
--global --agent "${agents[@]}" --skill "${owned_skills[@]}" --yes --copy

standalone_skills=(
swift-concurrency
)

run_add 'jamesrochabrun/skills#2.1.1' \
--global --agent "${agents[@]}" --skill swift-concurrency --yes --copy
--global --agent "${agents[@]}" --skill "${standalone_skills[@]}" --yes --copy

asc_skills=(
asc-app-create-ui
Expand Down Expand Up @@ -104,7 +108,7 @@ trap - EXIT

managed_skills=(
"${owned_skills[@]}"
swift-concurrency
"${standalone_skills[@]}"
"${asc_skills[@]}"
)

Expand Down
147 changes: 147 additions & 0 deletions scripts/check_project_skill_ownership.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require "open3"
require "yaml"

SOURCE_ROOT = File.expand_path("..", __dir__)
BOOTSTRAP_PATH = File.join(SOURCE_ROOT, "bootstrap.sh")
PROJECT_SKILL_ROOTS = %w[
.agent/skills
.agents/skills
.claude/skills
.codex/skills
.cursor/skills
.opencode/skills
Comment thread
VladimirBrejcha marked this conversation as resolved.
skills/codex
].freeze

def fail_usage(message)
warn message
warn "Usage: scripts/check_project_skill_ownership.rb <project-repo> [...]"
exit 64
end

def parse_bootstrap_array(source, name)
match = source.match(/^#{Regexp.escape(name)}=\(\n([\s\S]*?)^\)/)
raise "bootstrap.sh is missing #{name}" unless match

match[1].lines.map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
end

def read_skill_name(content, label)
lines = content.lines(chomp: true)
raise "missing YAML front matter" unless lines.first == "---"

closing_offset = lines[1..]&.index("---")
raise "unterminated YAML front matter" unless closing_offset

metadata = YAML.safe_load(
lines[1, closing_offset].join("\n"),
aliases: false,
filename: label
)
raise "front matter must be a mapping" unless metadata.is_a?(Hash)

name = metadata["name"]
raise "missing name" unless name.is_a?(String) && !name.strip.empty?

name
rescue Psych::SyntaxError => error
raise "invalid YAML front matter: #{error.problem}"
end

fail_usage("At least one project repository is required") if ARGV.empty?

bootstrap = File.read(BOOTSTRAP_PATH)
managed_names = (
parse_bootstrap_array(bootstrap, "owned_skills") +
parse_bootstrap_array(bootstrap, "standalone_skills") +
parse_bootstrap_array(bootstrap, "asc_skills")
).freeze

unless managed_names.length == managed_names.uniq.length
raise "bootstrap.sh contains duplicate managed skill names"
end

all_errors = []

ARGV.each do |argument|
repo = File.expand_path(argument)
fail_usage("Not a directory: #{argument}") unless File.directory?(repo)

output, status = Open3.capture2e("git", "-C", repo, "ls-files", "--stage", "-z")
fail_usage("Not a Git worktree: #{argument}") unless status.success?

repo_label = File.basename(repo)
names_to_paths = Hash.new { |hash, key| hash[key] = [] }
index_entries = output.split("\0").reject(&:empty?).map do |record|
metadata, relative = record.split("\t", 2)
mode, object, stage = metadata&.split(" ", 3)
unless mode && object && stage && relative
raise "unexpected git ls-files --stage output"
end

{ mode: mode, object: object, path: relative, stage: stage }
end
conflicted_paths = index_entries.reject { |entry| entry[:stage] == "0" }.map { |entry| entry[:path] }.uniq.sort
conflicted_paths.each do |relative|
all_errors << "#{repo_label}:#{relative}: unresolved index entry"
end
index_entries.select! { |entry| entry[:stage] == "0" }
tracked_paths = index_entries.map { |entry| entry[:path] }.sort

PROJECT_SKILL_ROOTS.each do |root|
managed_names.each do |name|
entrypoint = "#{root}/#{name}"
next unless tracked_paths.any? { |relative| relative == entrypoint || relative.start_with?("#{entrypoint}/") }

all_errors << "#{repo_label}:#{entrypoint}: managed-global skill #{name.inspect} must not be committed in a project skill root"
end
end

blob_cache = {}
index_entries.select { |entry| File.basename(entry[:path]) == "SKILL.md" }.each do |entry|
relative = entry[:path]
unless %w[100644 100755].include?(entry[:mode])
all_errors << "#{repo_label}:#{relative}: skill entrypoint must be a regular file in the Git index"
next
end

begin
content = blob_cache.fetch(entry[:object]) do
blob, blob_status = Open3.capture2e("git", "-C", repo, "cat-file", "blob", entry[:object])
raise "unable to read staged content" unless blob_status.success?

blob_cache[entry[:object]] = blob
end
name = read_skill_name(content, relative)
rescue StandardError => error
all_errors << "#{repo_label}:#{relative}: #{error.message}"
next
end

names_to_paths[name] << relative
directory_name = File.basename(File.dirname(relative))
if directory_name != name
all_errors << "#{repo_label}:#{relative}: skill name #{name.inspect} must match directory #{directory_name.inspect}"
end
if managed_names.include?(name) && PROJECT_SKILL_ROOTS.none? { |root| relative.start_with?("#{root}/#{name}/") }
all_errors << "#{repo_label}:#{relative}: managed-global skill #{name.inspect} must not be committed in a project"
end
end

names_to_paths.sort.each do |name, paths|
next unless paths.length > 1

all_errors << "#{repo_label}: duplicate project skill name #{name.inspect}: #{paths.sort.join(', ')}"
end
end

unless all_errors.empty?
warn "project skill ownership check failed:"
all_errors.sort.each { |error| warn "- #{error}" }
exit 1
end

puts "project skill ownership check passed"
189 changes: 189 additions & 0 deletions scripts/test_check_project_skill_ownership.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# frozen_string_literal: true

require "fileutils"
require "minitest/autorun"
require "open3"
require "tmpdir"

class CheckProjectSkillOwnershipTest < Minitest::Test
CHECKER = File.expand_path("check_project_skill_ownership.rb", __dir__)

def with_repo
Dir.mktmpdir("project-skill-ownership-") do |repo|
system("git", "-C", repo, "init", "--quiet", exception: true)
yield repo
end
end

def add_skill(repo, directory, name)
skill_dir = File.join(repo, directory)
FileUtils.mkdir_p(skill_dir)
File.write(
File.join(skill_dir, "SKILL.md"),
"---\nname: #{name}\ndescription: Fixture skill.\n---\n\n# Fixture\n"
)
system("git", "-C", repo, "add", directory, exception: true)
end

def run_checker(repo)
Open3.capture3("ruby", CHECKER, repo)
end

def test_accepts_unique_project_owned_skill
with_repo do |repo|
add_skill(repo, ".agents/skills/project-helper", "project-helper")

stdout, stderr, status = run_checker(repo)

assert status.success?, stderr
assert_includes stdout, "project skill ownership check passed"
end
end

def test_rejects_managed_global_shadow
with_repo do |repo|
add_skill(repo, ".agents/skills/code-review", "code-review")

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "managed-global skill"
end
end

def test_rejects_standalone_managed_global_shadow
with_repo do |repo|
add_skill(repo, ".agents/skills/swift-concurrency", "swift-concurrency")

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "managed-global skill"
end
end

def test_rejects_managed_global_client_root_symlink
with_repo do |repo|
link_dir = File.join(repo, ".codex/skills")
FileUtils.mkdir_p(link_dir)
File.symlink("../../external-code-review", File.join(link_dir, "code-review"))
system("git", "-C", repo, "add", ".codex/skills/code-review", exception: true)

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "managed-global skill"
end
end

def test_rejects_managed_global_skills_codex_symlink
with_repo do |repo|
link_dir = File.join(repo, "skills/codex")
FileUtils.mkdir_p(link_dir)
File.symlink("../../external-code-review", File.join(link_dir, "code-review"))
system("git", "-C", repo, "add", "skills/codex/code-review", exception: true)

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "managed-global skill"
end
end

def test_rejects_staged_shadow_deleted_only_from_worktree
with_repo do |repo|
add_skill(repo, ".agents/skills/code-review", "code-review")
FileUtils.rm_rf(File.join(repo, ".agents/skills/code-review"))

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "managed-global skill"
end
end

def test_accepts_staged_removal_of_managed_global_shadow
with_repo do |repo|
add_skill(repo, ".agents/skills/code-review", "code-review")
system(
"git", "-C", repo,
"-c", "user.name=Fixture",
"-c", "user.email=fixture@example.invalid",
"commit", "--quiet", "-m", "Add fixture",
exception: true
)
system("git", "-C", repo, "rm", "--quiet", "-r", ".agents/skills/code-review", exception: true)

stdout, stderr, status = run_checker(repo)

assert status.success?, stderr
assert_includes stdout, "project skill ownership check passed"
end
end

def test_ignores_unrelated_file_whose_name_only_ends_in_skill_md
with_repo do |repo|
docs_dir = File.join(repo, "docs")
FileUtils.mkdir_p(docs_dir)
File.write(File.join(docs_dir, "EXAMPLE-SKILL.md"), "# Not a skill entrypoint\n")
system("git", "-C", repo, "add", "docs/EXAMPLE-SKILL.md", exception: true)

stdout, stderr, status = run_checker(repo)

assert status.success?, stderr
assert_includes stdout, "project skill ownership check passed"
end
end

def test_reads_skill_front_matter_from_the_index
with_repo do |repo|
add_skill(repo, ".agents/skills/alias", "canonical-name")
File.write(
File.join(repo, ".agents/skills/alias/SKILL.md"),
"---\nname: alias\ndescription: Unstaged repair.\n---\n"
)

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "must match directory"
end
end

def test_rejects_symlinked_skill_entrypoint
with_repo do |repo|
skill_dir = File.join(repo, "project-helper")
FileUtils.mkdir_p(skill_dir)
File.symlink("../external/SKILL.md", File.join(skill_dir, "SKILL.md"))
system("git", "-C", repo, "add", "project-helper/SKILL.md", exception: true)

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "must be a regular file in the Git index"
end
end

def test_rejects_duplicate_front_matter_names
with_repo do |repo|
add_skill(repo, ".agents/skills/first", "first")
add_skill(repo, "skills/codex/first", "first")

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "duplicate project skill name"
end
end

def test_rejects_directory_name_mismatch
with_repo do |repo|
add_skill(repo, ".agents/skills/alias", "canonical-name")

_stdout, stderr, status = run_checker(repo)

refute status.success?
assert_includes stderr, "must match directory"
end
end
end
Loading