ruby-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubySkills
4
+ # Installs skills declared in the Skillfile.
5
+ #
6
+ # Resolves each skill, copies it into +.ruby-skills+, notifies tool
7
+ # adapters, and records the result in +Skills.lock+.
8
+ #
9
+ # @example Install every declared skill
10
+ # RubySkills::Installer.new.install
11
+ #
12
+ # @example Install one skill
13
+ # RubySkills::Installer.new.install("rails-performance")
14
+ #
15
+ # @see RubySkills::Manifest
16
+ # @see RubySkills::Resolver
17
+ # @since 0.1.0
18
+ class Installer
19
+ # @param config [RubySkills::Config] project paths used during install
20
+ def initialize(config: Config.new)
21
+ @config = config
22
+ end
23
+
24
+ # Install one skill or every skill declared in the Skillfile.
25
+ #
26
+ # @param skill_name [String, nil] skill to install, or +nil+ to install all
27
+ # @return [void]
28
+ # @raise [RubySkills::Error] if the Skillfile is missing or a skill cannot be installed
29
+ def install(skill_name = nil)
30
+ load_skills(skill_name).each do |skill|
31
+ install_skill(skill)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # @api private
38
+ # @param skill_name [String, nil]
39
+ # @return [Array<RubySkills::Manifest::Skill>]
40
+ # @raise [RubySkills::Error] if the named skill is not in the Skillfile
41
+ def load_skills(skill_name)
42
+ manifest = Manifest.new.load!
43
+
44
+ return manifest.skills if skill_name.nil?
45
+
46
+ skill = manifest.skills.find { |declared| declared.name == skill_name }
47
+ raise RubySkills::Error, "Skill `#{skill_name}` is not in the Skillfile" unless skill
48
+
49
+ [skill]
50
+ end
51
+
52
+ # @api private
53
+ # @param skill [RubySkills::Manifest::Skill]
54
+ # @return [void]
55
+ def install_skill(skill)
56
+ resolved = Resolver.new.resolve(skill)
57
+ destination = @config.skills_path.join(resolved.name)
58
+
59
+ FileUtils.rm_rf(destination)
60
+ FileUtils.mkdir_p(@config.skills_path)
61
+ FileUtils.cp_r(resolved.path, destination)
62
+
63
+ adapters.each do |adapter|
64
+ adapter.new.install(resolved.name, destination)
65
+ end
66
+
67
+ Lockfile.new(config: @config).add(
68
+ resolved.name,
69
+ version: skill.version || "unspecified",
70
+ source: resolved.source
71
+ )
72
+
73
+ puts "✓ Installed #{resolved.name}"
74
+ end
75
+
76
+ # @api private
77
+ # @return [Array<Class>] adapter classes notified when a skill is installed
78
+ def adapters
79
+ [
80
+ Adapters::Claude,
81
+ Adapters::Codex,
82
+ Adapters::Cursor,
83
+ Adapters::Vscode
84
+ ]
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubySkills
4
+ # Reads and writes the project +Skills.lock+ file.
5
+ #
6
+ # The lockfile records installed skill names, versions, and sources so
7
+ # later installs and removals stay consistent.
8
+ #
9
+ # @example List installed skills
10
+ # RubySkills::Lockfile.new.skills
11
+ #
12
+ # @see RubySkills::Config#lockfile_path
13
+ # @since 0.1.0
14
+ class Lockfile
15
+ # @param config [RubySkills::Config] project paths used to locate Skills.lock
16
+ def initialize(config: Config.new)
17
+ @config = config
18
+ end
19
+
20
+ # Installed skills stored in the lockfile.
21
+ #
22
+ # @return [Hash{String => Hash}] skill names mapped to +version+ and +source+
23
+ def skills
24
+ return {} unless @config.lockfile_path.exist?
25
+
26
+ data = YAML.safe_load_file(
27
+ @config.lockfile_path,
28
+ permitted_classes: [],
29
+ aliases: true
30
+ )
31
+
32
+ data.fetch("skills", {})
33
+ end
34
+
35
+ # Record an installed skill in the lockfile.
36
+ #
37
+ # @param name [String] skill identifier
38
+ # @param version [String] installed skill version
39
+ # @param source [String] origin used to install the skill
40
+ # @return [void]
41
+ def add(name, version:, source:)
42
+ data = load_data
43
+
44
+ data["skills"][name] = {
45
+ "version" => version,
46
+ "source" => source
47
+ }
48
+
49
+ write(data)
50
+ end
51
+
52
+ # Drop a skill from the lockfile.
53
+ #
54
+ # @param name [String] skill identifier to remove
55
+ # @return [void]
56
+ def remove(name)
57
+ data = load_data
58
+
59
+ data["skills"].delete(name)
60
+
61
+ write(data)
62
+ end
63
+
64
+ private
65
+
66
+ # @api private
67
+ # @return [Hash] lockfile payload with a +version+ header and +skills+ map
68
+ def load_data
69
+ {
70
+ "version" => 1,
71
+ "skills" => skills
72
+ }
73
+ end
74
+
75
+ # @api private
76
+ # @param data [Hash] lockfile payload to persist
77
+ # @return [void]
78
+ def write(data)
79
+ @config.lockfile_path.write(
80
+ YAML.dump(data)
81
+ )
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubySkills
4
+ # Loads and evaluates a project +Skillfile+.
5
+ #
6
+ # The Skillfile is Ruby DSL. Each +skill+ declaration becomes a
7
+ # {RubySkills::Manifest::Skill} stored in {#skills}.
8
+ #
9
+ # @example Load the Skillfile from the current project
10
+ # manifest = RubySkills::Manifest.new.load!
11
+ # manifest.skills
12
+ #
13
+ # @since 0.1.0
14
+ class Manifest
15
+ # Declared skill entry loaded from a Skillfile.
16
+ #
17
+ # @!attribute [rw] name
18
+ # @return [String] skill identifier
19
+ # @!attribute [rw] github
20
+ # @return [String, nil] GitHub repository in +owner/name+ form
21
+ # @!attribute [rw] path
22
+ # @return [String, nil] local filesystem path to the skill
23
+ # @!attribute [rw] version
24
+ # @return [String, nil] optional pinned version
25
+ Skill = Struct.new(
26
+ :name,
27
+ :github,
28
+ :path,
29
+ :version,
30
+ keyword_init: true
31
+ )
32
+
33
+ # @return [Array<Skill>] skills declared in the Skillfile
34
+ attr_reader :skills
35
+
36
+ # @param path [Pathname, String] path to the Skillfile
37
+ def initialize(path: default_skill_path)
38
+ @path = path
39
+ @skills = []
40
+ end
41
+
42
+ # Read and evaluate the Skillfile, populating {#skills}.
43
+ #
44
+ # @return [Manifest] self
45
+ # @raise [RubySkills::Error] if the Skillfile is missing or invalid
46
+ def load!
47
+ unless @path.exist?
48
+ raise RubySkills::Error,
49
+ "Skillfile not found. Run `ruby-skills init` first."
50
+ end
51
+
52
+ instance_eval(@path.read, @path.to_s, 1)
53
+
54
+ self
55
+ rescue SyntaxError => e
56
+ raise RubySkills::Error, "Invalid Skillfile: #{e.message}"
57
+ end
58
+
59
+ # Declare a skill in the Skillfile DSL.
60
+ #
61
+ # Exactly one source must be provided: +github+ or +path+.
62
+ #
63
+ # @param name [String] skill identifier
64
+ # @param github [String, nil] GitHub repository in +owner/name+ form
65
+ # @param path [String, nil] local filesystem path to the skill
66
+ # @param version [String, nil] optional pinned version
67
+ # @return [void]
68
+ # @raise [RubySkills::Error] if neither +github+ nor +path+ is given
69
+ def skill(name, github: nil, path: nil, version: nil)
70
+ unless github || path
71
+ raise RubySkills::Error,
72
+ "#{name}: either github or path: must be provided"
73
+ end
74
+
75
+ @skills << Skill.new(
76
+ name: name,
77
+ github: github,
78
+ path: path,
79
+ version: version
80
+ )
81
+ end
82
+
83
+ private
84
+
85
+ # @api private
86
+ # @return [Pathname] default path used when none is given to {#initialize}
87
+ def default_skill_path
88
+ Config.new.skills_path
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubySkills
4
+ # Uninstalls a skill from the project, adapters, and lockfile.
5
+ #
6
+ # @example Remove an installed skill
7
+ # RubySkills::Remover.new.remove("rails-performance")
8
+ #
9
+ # @see RubySkills::Lockfile
10
+ # @see RubySkills::Adapters
11
+ # @since 0.1.0
12
+ class Remover
13
+ # @param config [RubySkills::Config] project paths used to locate skills
14
+ def initialize(config: Config.new)
15
+ @config = config
16
+ end
17
+
18
+ # Remove an installed skill from disk, tool adapters, and the lockfile.
19
+ #
20
+ # @param name [String] skill identifier to remove
21
+ # @return [void]
22
+ # @raise [RubySkills::Error] if the skill is not installed
23
+ def remove(name)
24
+ path = @config.skills_path.join(name)
25
+
26
+ unless path.exist?
27
+ raise RubySkills::Error,
28
+ "Skill `#{name}` is not installed"
29
+ end
30
+
31
+ adapters.each do |adapter|
32
+ adapter.new.remove(name)
33
+ end
34
+
35
+ FileUtils.rm_rf(path)
36
+
37
+ Lockfile.new.remove(name)
38
+
39
+ puts "✓ Removed #{name}"
40
+ end
41
+
42
+ private
43
+
44
+ # @api private
45
+ # @return [Array<Class>] adapter classes notified when a skill is removed
46
+ def adapters
47
+ RubySkills::Adapters.all
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module RubySkills
6
+ # Turns a declared skill into a local directory that can be installed.
7
+ #
8
+ # Local skills are resolved from {RubySkills::Manifest::Skill#path}.
9
+ # Remote skills are cloned from GitHub into a temporary directory.
10
+ #
11
+ # @example Resolve a declared skill
12
+ # skill = RubySkills::Manifest::Skill.new(
13
+ # name: "rails-performance",
14
+ # path: "./skills/rails-performance"
15
+ # )
16
+ # RubySkills::Resolver.new.resolve(skill)
17
+ #
18
+ # @see RubySkills::Manifest::Skill
19
+ # @since 0.1.0
20
+ class Resolver
21
+ # Skill directory ready to be installed.
22
+ #
23
+ # @!attribute [rw] name
24
+ # @return [String] skill identifier
25
+ # @!attribute [rw] path
26
+ # @return [Pathname] local directory containing the skill
27
+ # @!attribute [rw] source
28
+ # @return [String] origin used to resolve the skill
29
+ ResolvedSkill = Struct.new(
30
+ :name,
31
+ :path,
32
+ :source,
33
+ keyword_init: true
34
+ )
35
+
36
+ # Resolve a skill from a local path or a remote repository.
37
+ #
38
+ # @param skill [RubySkills::Manifest::Skill] declared skill to resolve
39
+ # @return [ResolvedSkill] skill directory ready to install
40
+ # @raise [RubySkills::Error] if the skill has no usable source
41
+ def resolve(skill)
42
+ if skill.path
43
+ resolve_local(skill)
44
+ elsif skill.github
45
+ resolve_remote(skill)
46
+ else
47
+ raise RubySkills::Error,
48
+ "Unable to resolve #{skill.name}"
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # @api private
55
+ # @param skill [RubySkills::Manifest::Skill]
56
+ # @return [ResolvedSkill]
57
+ # @raise [RubySkills::Error] if the local skill path is not a directory
58
+ def resolve_local(skill)
59
+ path = Pathname.new(skill.path).expand_path
60
+
61
+ unless path.directory?
62
+ raise RubySkills::Error,
63
+ "Skill path does not exist: #{path}"
64
+ end
65
+
66
+ ResolvedSkill.new(
67
+ name: skill.name,
68
+ path: path,
69
+ source: "path:#{path}"
70
+ )
71
+ end
72
+
73
+ # @api private
74
+ # @param skill [RubySkills::Manifest::Skill]
75
+ # @return [ResolvedSkill] skill cloned into a temporary directory
76
+ # @raise [RubySkills::Error] if the GitHub repository cannot be cloned
77
+ def resolve_remote(skill)
78
+ directory = Pathname.new(
79
+ Dir.mktmpdir("ruby-skills")
80
+ )
81
+
82
+ repository = "https://github.com/#{skill.github}.git"
83
+
84
+ success = system(
85
+ "git",
86
+ "clone",
87
+ "--depth",
88
+ "1",
89
+ repository,
90
+ directory.to_s,
91
+ out: File::NULL,
92
+ err: File::NULL
93
+ )
94
+
95
+ unless success
96
+ raise RubySkills::Error,
97
+ "Unable to clone #{skill.github}"
98
+ end
99
+
100
+ ResolvedSkill.new(
101
+ name: skill.name,
102
+ path: directory,
103
+ source: "github:#{skill.github}"
104
+ )
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubySkills
4
+ # Reinstalls skills to pick up newer versions.
5
+ #
6
+ # Delegates the work to {RubySkills::Installer}. When +skill_name+ is
7
+ # omitted, every skill in the Skillfile is updated.
8
+ #
9
+ # @example Update a single skill
10
+ # RubySkills::Updater.new.update("rails-performance")
11
+ #
12
+ # @example Update every declared skill
13
+ # RubySkills::Updater.new.update
14
+ #
15
+ # @see RubySkills::Installer
16
+ # @since 0.1.0
17
+ class Updater
18
+ # Update one skill or every skill declared in the Skillfile.
19
+ #
20
+ # @param skill_name [String, nil] skill to update, or +nil+ to update all
21
+ # @return [void]
22
+ def update(skill_name = nil)
23
+ Installer.new.install(skill_name)
24
+
25
+ if skill_name
26
+ puts "✓ Updated #{skill_name}"
27
+ else
28
+ puts "✓ All skills updated"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubySkills
4
+ # Current gem version.
5
+ #
6
+ # @return [String]
7
+ VERSION = "0.1.0"
8
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ # Package manager for installing, updating, removing and sharing AI
8
+ # development skills in Ruby and Rails projects.
9
+ #
10
+ # @see RubySkills::CLI
11
+ # @see RubySkills::Config
12
+ # @since 0.1.0
13
+ module RubySkills
14
+ # Base error for all Ruby Skills failures.
15
+ class Error < StandardError; end
16
+ end
17
+
18
+ Dir.glob("ruby_skills/**/*.rb", base: __dir__).sort.each do |path|
19
+ require path.delete_suffix(".rb")
20
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/ruby_skills/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "ruby-skills"
7
+ spec.version = RubySkills::VERSION
8
+ spec.authors = ["Renato Franco"]
9
+ spec.email = ["renator.franco93@gmail.com"]
10
+
11
+ spec.summary = "Package, share, and install Ruby engineering knowledge."
12
+ spec.description = <<~DESCRIPTION
13
+ Ruby Skills is a package manager for distributing engineering
14
+ knowledge to Ruby development tools and coding agents.
15
+ DESCRIPTION
16
+
17
+ spec.homepage = "https://rubyskills.org"
18
+ spec.license = "MIT"
19
+
20
+ spec.required_ruby_version = ">= 3.1.0"
21
+
22
+ spec.metadata = {
23
+ "homepage_uri" => spec.homepage,
24
+ "source_code_uri" => "https://github.com/renatofranco/ruby-skills",
25
+ "rubygems_mfa_required" => "true"
26
+ }
27
+
28
+ spec.files = Dir.chdir(__dir__) {
29
+ `git ls-files -z`.split("\x0").reject do |file|
30
+ file.start_with?(
31
+ "test/",
32
+ "spec/",
33
+ "features/",
34
+ ".git",
35
+ ".github"
36
+ )
37
+ end
38
+ }
39
+
40
+ spec.bindir = "bin"
41
+ spec.executables = ["ruby-skills"]
42
+ spec.require_paths = ["lib"]
43
+
44
+ spec.add_dependency "thor", "~> 1.3"
45
+ spec.add_development_dependency "rake", "~> 13.0"
46
+ spec.add_development_dependency "rspec", "~> 3.12"
47
+ spec.add_development_dependency "rubocop", "~> 1.58"
48
+ spec.add_development_dependency "rubocop-performance", "~> 1.20"
49
+ spec.add_development_dependency "rubocop-rspec", "~> 3.0"
50
+ spec.add_development_dependency "yard", "~> 0.9"
51
+ end
metadata ADDED
@@ -0,0 +1,169 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-skills
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Renato Franco
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.12'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.12'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.58'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.58'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-performance
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.20'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.20'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop-rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: yard
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.9'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.9'
110
+ description: |
111
+ Ruby Skills is a package manager for distributing engineering
112
+ knowledge to Ruby development tools and coding agents.
113
+ email:
114
+ - renator.franco93@gmail.com
115
+ executables:
116
+ - ruby-skills
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - ".rspec"
121
+ - ".rubocop.yml"
122
+ - ".yardopts"
123
+ - Gemfile
124
+ - Gemfile.lock
125
+ - LICENSE
126
+ - README.md
127
+ - Rakefile
128
+ - bin/ruby-skills
129
+ - lib/ruby_skills.rb
130
+ - lib/ruby_skills/adapters/base.rb
131
+ - lib/ruby_skills/adapters/claude.rb
132
+ - lib/ruby_skills/adapters/codex.rb
133
+ - lib/ruby_skills/adapters/cursor.rb
134
+ - lib/ruby_skills/adapters/vscode.rb
135
+ - lib/ruby_skills/cli.rb
136
+ - lib/ruby_skills/config.rb
137
+ - lib/ruby_skills/installer.rb
138
+ - lib/ruby_skills/lockfile.rb
139
+ - lib/ruby_skills/manifest.rb
140
+ - lib/ruby_skills/remover.rb
141
+ - lib/ruby_skills/resolver.rb
142
+ - lib/ruby_skills/updater.rb
143
+ - lib/ruby_skills/version.rb
144
+ - ruby-skills.gemspec
145
+ homepage: https://rubyskills.org
146
+ licenses:
147
+ - MIT
148
+ metadata:
149
+ homepage_uri: https://rubyskills.org
150
+ source_code_uri: https://github.com/renatofranco/ruby-skills
151
+ rubygems_mfa_required: 'true'
152
+ rdoc_options: []
153
+ require_paths:
154
+ - lib
155
+ required_ruby_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: 3.1.0
160
+ required_rubygems_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '0'
165
+ requirements: []
166
+ rubygems_version: 4.0.10
167
+ specification_version: 4
168
+ summary: Package, share, and install Ruby engineering knowledge.
169
+ test_files: []