Class: GDKBox::Skills

Inherits:
Object
  • Object
show all
Defined in:
lib/gdkbox/skills.rb

Overview

Discovers and installs agent skills.

A skill is a directory containing a SKILL.md file. gdkbox knows about skills from three roots: the ones bundled in this repo's top-level skills/ directory, the user-wide ~/.claude/skills, and the current project's ./.claude/skills. They can be installed into a host Claude Code skills directory or pushed into a box for dispatched agents to use.

Defined Under Namespace

Classes: Skill

Constant Summary collapse

SOURCE_DIR =

Where the bundled skills live inside the gem/repo: /skills.

File.expand_path("../../skills", __dir__)
GLOBAL_DEST =

User-wide Claude Code skills directory.

File.expand_path("~/.claude/skills")

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(dest: GLOBAL_DEST) ⇒ Skills

Returns a new instance of Skills.



102
103
104
# File 'lib/gdkbox/skills.rb', line 102

def initialize(dest: GLOBAL_DEST)
  @dest = dest
end

Instance Attribute Details

#destObject (readonly)

Returns the value of attribute dest.



106
107
108
# File 'lib/gdkbox/skills.rb', line 106

def dest
  @dest
end

Class Method Details

.availableObject

Names of the skills shipped with this repo, sorted.



94
95
96
97
98
99
100
# File 'lib/gdkbox/skills.rb', line 94

def self.available
  return [] unless File.directory?(SOURCE_DIR)

  Dir.children(SOURCE_DIR)
    .select { |entry| File.directory?(File.join(SOURCE_DIR, entry)) }
    .sort
end

.description_for(path) ⇒ Object

Best-effort one-line summary pulled from a skill's SKILL.md frontmatter.



79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/gdkbox/skills.rb', line 79

def self.description_for(path)
  text = File.read(File.join(path, "SKILL.md"))
  front = text[/\A---\s*\n(.*?)\n---\s*\n/m, 1]
  return nil unless front

  data = begin
    YAML.safe_load(front)
  rescue StandardError
    nil
  end
  desc = data.is_a?(Hash) ? data["description"] : nil
  desc&.to_s&.gsub(/\s+/, " ")&.strip
end

.discover(project_dir: Dir.pwd) ⇒ Object

Every skill gdkbox can see, across all roots, deduped by name (the first/most-specific root wins). Returns Skill structs sorted by name.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/gdkbox/skills.rb', line 41

def self.discover(project_dir: Dir.pwd)
  found = {}
  roots(project_dir: project_dir).each do |origin, dir|
    next unless File.directory?(dir)

    Dir.children(dir).sort.each do |entry|
      path = File.join(dir, entry)
      next if found.key?(entry)
      next unless File.file?(File.join(path, "SKILL.md"))

      found[entry] = Skill.new(entry, path, origin, description_for(path))
    end
  end
  found.values.sort_by(&:name)
end

.project_dest(dir = Dir.pwd) ⇒ Object

Project-local Claude Code skills directory, relative to a working dir.



25
26
27
# File 'lib/gdkbox/skills.rb', line 25

def self.project_dest(dir = Dir.pwd)
  File.join(dir, ".claude", "skills")
end

.resolve(token, project_dir: Dir.pwd) ⇒ Object

Resolve a token (a discovered skill name, or a path to a skill directory) to a Skill. Raises Error when it cannot be found.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/gdkbox/skills.rb', line 59

def self.resolve(token, project_dir: Dir.pwd)
  maybe_path = File.expand_path(token)
  if token.match?(%r{[/\\]}) || File.directory?(maybe_path)
    unless File.file?(File.join(maybe_path, "SKILL.md"))
      raise Error, "'#{token}' is not a skill directory (no SKILL.md)."
    end

    return Skill.new(File.basename(maybe_path), maybe_path, :path, description_for(maybe_path))
  end

  skill = discover(project_dir: project_dir).find { |s| s.name == token }
  unless skill
    names = discover(project_dir: project_dir).map(&:name)
    hint = names.empty? ? "" : " Available: #{names.join(', ')}."
    raise Error, "No skill named '#{token}'.#{hint}"
  end
  skill
end

.roots(project_dir: Dir.pwd) ⇒ Object

The skill roots searched by discover, most specific first so that a project or user skill shadows a bundled one with the same name.



31
32
33
34
35
36
37
# File 'lib/gdkbox/skills.rb', line 31

def self.roots(project_dir: Dir.pwd)
  [
    [:project, project_dest(project_dir)],
    [:global, GLOBAL_DEST],
    [:bundled, SOURCE_DIR]
  ]
end

Instance Method Details

#install(name, force: false) ⇒ Object

Copy a single named skill into the destination directory.

Raises Error if the skill is unknown, or if it is already installed and force is false. Returns the path it was installed to.

Raises:



112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/gdkbox/skills.rb', line 112

def install(name, force: false)
  source = File.join(SOURCE_DIR, name)
  raise Error, "No skill named '#{name}' in this repo." unless File.directory?(source)

  target = File.join(@dest, name)
  if File.exist?(target) && !force
    raise Error, "Skill '#{name}' is already installed at #{target}. Use --force to overwrite."
  end

  FileUtils.mkdir_p(@dest)
  FileUtils.rm_rf(target)
  FileUtils.cp_r(source, target)
  target
end