agent-context 0.2.1 → 0.4.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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Shopify Inc.
5
+
6
+ require "open3"
7
+ require "fileutils"
8
+ require_relative "errors"
9
+
10
+ module Agent
11
+ module Context
12
+ # Represents local Git exclusions shared by context and skill installers.
13
+ class Exclusion
14
+ BEGIN_MARKER = "# BEGIN bake-agent-context"
15
+ END_MARKER = "# END bake-agent-context"
16
+
17
+ # Prepare exclusions while preserving repository-owned rules.
18
+ # @parameter root [String] The consuming project root.
19
+ # @parameter names [Array(String)] All dependency-owned skills.
20
+ # @returns [Exclusion | Nil] The prepared update when Git is available for this project.
21
+ def self.prepare(root, names)
22
+ output, status = Open3.capture2e(
23
+ "git", "rev-parse", "--show-prefix", "--git-path", "info/exclude",
24
+ chdir: root,
25
+ )
26
+ return unless status.success?
27
+
28
+ prefix, path = output.lines(chomp: true)
29
+ path = File.expand_path(path, root)
30
+ original = File.exist?(path) ? File.read(path) : ""
31
+
32
+ begin_marker = "#{BEGIN_MARKER} #{prefix}".rstrip
33
+ end_marker = "#{END_MARKER} #{prefix}".rstrip
34
+
35
+ # Replace this project's block, retaining the surrounding Git exclusions:
36
+ lines = original.lines(chomp: true)
37
+ begins = lines.each_index.select{|index| lines[index].strip == begin_marker}
38
+ ends = lines.each_index.select{|index| lines[index].strip == end_marker}
39
+
40
+ unless begins.empty? && ends.empty?
41
+ unless begins.length == 1 && ends.length == 1 && begins.first < ends.first
42
+ raise InvalidError, "Malformed Agent Context exclusion block: #{path}"
43
+ end
44
+
45
+ lines.slice!(begins.first..ends.first)
46
+ end
47
+
48
+ lines.pop while lines.last == ""
49
+ lines << "" unless lines.empty?
50
+
51
+ pattern = prefix.gsub(/[\\*?\[\]]/){|character| "\\#{character}"}
52
+ lines.concat([
53
+ begin_marker,
54
+ "# Generated by Bake Agent Context; do not edit this block manually.",
55
+ "/#{pattern}.agents/context/",
56
+ *names.sort.map{|name| "/#{pattern}.agents/skills/#{name}/"},
57
+ end_marker,
58
+ ])
59
+
60
+ new(path, original, lines.join("\n") + "\n")
61
+ rescue Errno::ENOENT
62
+ # Installation also works when Git is unavailable:
63
+ nil
64
+ end
65
+
66
+ # Initialize a prepared exclusion update.
67
+ # @parameter path [String] The Git exclusion file path.
68
+ # @parameter original [String] The original file contents.
69
+ # @parameter updated [String] The prepared file contents.
70
+ def initialize(path, original, updated)
71
+ @path = path
72
+ @original = original
73
+ @updated = updated
74
+ end
75
+
76
+ # Apply the prepared exclusion block.
77
+ def apply
78
+ write(@updated) if @updated != @original
79
+ end
80
+
81
+ private
82
+
83
+ def write(content)
84
+ FileUtils.mkdir_p(File.dirname(@path))
85
+
86
+ File.write(@path, content)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -1,256 +1,122 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Shopify Inc.
5
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Shopify Inc.
5
+ # Copyright, 2025-2026, by Samuel Williams.
6
6
 
7
7
  require "rubygems"
8
8
  require "fileutils"
9
- require "pathname"
10
- require "yaml"
9
+
10
+ require_relative "paths"
11
+ require_relative "errors"
12
+ require_relative "skills/ownership"
13
+ require_relative "skills/definition"
14
+ require_relative "exclusion"
15
+ require_relative "package"
11
16
 
12
17
  module Agent
13
18
  module Context
14
- # Installer class for managing context files from Ruby gems.
15
- #
16
- # This class provides methods to find, list, show, and install context files
17
- # from gems that provide them in a `context/` directory.
19
+ # Discovers, lists, reads, and installs context from resolved Ruby gems.
18
20
  class Installer
19
- CANONICAL_ORDER = [
20
- "getting-started",
21
- "overview",
22
- "usage",
23
- "configuration",
24
- "migration",
25
- "troubleshooting",
26
- "debugging"
27
- ]
28
-
29
- # Initialize a new Installer instance.
30
- #
31
- # @parameter root [String] The root directory to work from (default: current directory).
32
- # @parameter specifications [Gem::Specification] The gem specifications to search (default: all installed gems).
21
+ # Retain the consuming project and gem specifications.
33
22
  def initialize(root: Dir.pwd, specifications: ::Gem::Specification)
34
- @root = root
35
- @context_path = ".context"
23
+ @root = File.expand_path(root)
24
+ @context_path = File.join(@root, CONTEXT_PATH)
36
25
  @specifications = specifications
37
26
  end
38
27
 
28
+ attr_reader :root
39
29
  attr_reader :context_path
40
30
 
41
- # Find all gems that have a context directory
42
- def find_gems_with_context(skip_local: true)
43
- gems_with_context = []
44
-
45
- @specifications.each do |spec|
46
- # Skip gems loaded from current working directory if requested:
47
- next if skip_local && spec.full_gem_path == @root
48
-
49
- context_path = File.join(spec.full_gem_path, "context")
50
- if Dir.exist?(context_path)
51
- gems_with_context << {
52
- name: spec.name,
53
- version: spec.version.to_s,
54
- summary: spec.summary,
55
- metadata: spec.metadata,
56
- path: context_path
57
- }
58
- end
59
- end
60
-
61
- gems_with_context
62
- end
63
-
64
- # Find a specific gem with context.
65
- def find_gem_with_context(gem_name)
66
- spec = @specifications.find {|spec| spec.name == gem_name}
67
- return nil unless spec
68
-
69
- context_path = File.join(spec.full_gem_path, "context")
70
-
71
- if Dir.exist?(context_path)
72
- {
73
- name: spec.name,
74
- version: spec.version.to_s,
75
- summary: spec.summary,
76
- metadata: spec.metadata,
77
- path: context_path
78
- }
79
- else
80
- nil
81
- end
31
+ # Load providers with a context directory, including empty providers.
32
+ def providers(skip_local: true)
33
+ packages(skip_local: skip_local)
82
34
  end
83
35
 
84
- # List context files for a gem.
85
- def list_context_files(gem_name)
86
- gem = find_gem_with_context(gem_name)
87
- return nil unless gem
88
-
89
- Dir.glob(File.join(gem[:path], "**/*")).select {|f| File.file?(f)}
36
+ # Load one provider by its gem name.
37
+ def find_provider(name)
38
+ packages(gem: name).first
90
39
  end
91
40
 
92
- # Show content of a specific context file.
41
+ # Read the authored content of a selected definition.
93
42
  def show_context_file(gem_name, file_name)
94
- gem = find_gem_with_context(gem_name)
95
- return nil unless gem
96
-
97
- # Try to find the file with or without extension:
98
- possible_paths = [
99
- File.join(gem[:path], file_name),
100
- File.join(gem[:path], "#{file_name}.md"),
101
- File.join(gem[:path], "#{file_name}.md")
102
- ]
103
-
104
- file_path = possible_paths.find {|path| File.exist?(path)}
105
- return nil unless file_path
106
-
107
- File.read(file_path)
43
+ find_provider(gem_name)&.find_definition(file_name)&.content
108
44
  end
109
45
 
110
- # Install context from a specific gem.
111
- def install_gem_context(gem_name)
112
- gem = find_gem_with_context(gem_name)
113
- return false unless gem
114
-
115
- target_path = File.join(@context_path, gem_name)
116
-
117
- # Remove old package directory if it exists to ensure clean install
118
- FileUtils.rm_rf(target_path) if Dir.exist?(target_path)
119
-
120
- FileUtils.mkdir_p(target_path)
121
-
122
- # Copy all files from the gem's context directory:
123
- FileUtils.cp_r(File.join(gem[:path], "."), target_path)
124
-
125
- # Generate index.yaml if it doesn't exist, passing the full gem hash
126
- ensure_gem_index(gem, target_path)
127
-
128
- true
129
- end
130
-
131
- # Install context from all gems.
132
- def install_all_context(skip_local: true)
133
- gems = find_gems_with_context(skip_local: skip_local)
134
- installed = []
135
-
136
- gems.each do |gem|
137
- if install_gem_context(gem[:name])
138
- installed << gem[:name]
139
- end
46
+ # Install all selected definitions and reconcile dependency-owned skills.
47
+ def install(gem: nil)
48
+ # Resolve providers and load every definition once:
49
+ selected = packages(gem: gem)
50
+ raise Error, "No context found for gem: #{gem}" if gem && selected.empty?
51
+ definitions = selected.flat_map(&:definitions)
52
+ skills = definitions.grep(Skills::Definition)
53
+
54
+ # Apply the skill-specific destination and ownership rules:
55
+ installed_skills = install_skills(skills, package: gem)
56
+
57
+ # Refresh ordinary files from the same loaded definitions:
58
+ installed_context = selected.filter_map do |package|
59
+ package.name if package.install_files(File.join(@context_path, package.name))
140
60
  end
141
61
 
142
- installed
62
+ {context: installed_context, skills: installed_skills}
143
63
  end
144
64
 
145
65
  private
146
66
 
147
- # Generate a dynamic index from gemspec when no index.yaml is present
148
- def generate_dynamic_index(gem, gem_directory)
149
- # Collect all markdown files
150
- markdown_files = Dir.glob(File.join(gem_directory, "**", "*.md")).sort
151
-
152
- # Sort files: canonical first, then alpha
153
- files_sorted = markdown_files.sort_by do |file_path|
154
- base_filename = File.basename(file_path, ".md").downcase
155
- canonical_index = CANONICAL_ORDER.index(base_filename)
156
- [canonical_index ? CANONICAL_ORDER.index(base_filename) : CANONICAL_ORDER.length, base_filename]
67
+ def packages(gem: nil, skip_local: true)
68
+ specifications = @specifications.select do |specification|
69
+ gem ? specification.name == gem : !skip_local || File.expand_path(specification.full_gem_path) != @root
157
70
  end
158
-
159
- files = []
160
- files_sorted.each do |file_path|
161
- next if File.basename(file_path) == "index.yaml" # Skip the index file itself
162
- title, description = extract_content(file_path)
163
- relative_path = file_path.sub("#{gem_directory}/", "")
164
- files << {
165
- "path" => relative_path,
166
- "title" => title,
167
- "description" => description
168
- }
71
+ if gem && specifications.length > 1
72
+ raise ConflictError, "Multiple resolved versions provide gem #{gem.inspect}"
169
73
  end
170
74
 
171
- {
172
- "description" => gem[:summary] || "Context files for #{gem[:name]}",
173
- "metadata" => gem[:metadata],
174
- "files" => files
175
- }
75
+ specifications.filter_map{|specification| Package.load(specification)}
176
76
  end
177
77
 
178
- # Check if a gem has an index.yaml file, generate one if not
179
- def ensure_gem_index(gem, gem_directory)
180
- index_path = File.join(gem_directory, "index.yaml")
181
-
182
- unless File.exist?(index_path)
183
- # Generate dynamic index from gemspec
184
- index = generate_dynamic_index(gem, gem_directory)
185
-
186
- # Write the generated index
187
- File.write(index_path, index.to_yaml)
188
- Console.debug("Generated dynamic index for #{gem[:name]}: #{index_path}")
78
+ def install_skills(definitions, package:)
79
+ # Validate selected names and ownership before writing any skill:
80
+ duplicates = definitions.group_by(&:name).select{|_name, matches| matches.length > 1}
81
+ raise ConflictError, "Multiple providers supply skills: #{duplicates.keys.join(", ")}" unless duplicates.empty?
82
+
83
+ skills_path = File.join(@root, ".agents", "skills")
84
+ owners = Skills::Ownership.scan(skills_path)
85
+ validate_skill_destinations(definitions, skills_path, owners)
86
+
87
+ names = definitions.map(&:name)
88
+ owned = owners.select do |_name, owner|
89
+ owner["ecosystem"] == "gem" && (!package || owner["package"] == package)
189
90
  end
190
-
191
- # Load and return the index
192
- YAML.load_file(index_path)
193
- rescue => error
194
- Console.debug("Error generating index for #{gem[:name]}: #{error.message}")
195
- # Return a fallback index
196
- {
197
- "description" => gem[:summary] || "Context files for #{gem[:name]}",
198
- "metadata" => gem[:metadata],
199
- "files" => []
200
- }
201
- end
202
-
203
- def extract_content(file_path)
204
- content = File.read(file_path)
205
- lines = content.lines.map(&:strip)
206
-
207
- title = extract_title(lines)
208
- description = extract_description(lines)
209
-
210
- [title, description]
211
- end
212
-
213
- def extract_title(lines)
214
- # Look for the first markdown header
215
- header_line = lines.find {|line| line.start_with?("#")}
216
- if header_line
217
- # Remove markdown header syntax and clean up
218
- header_line.sub(/^#+\s*/, "").strip
219
- else
220
- # If no header found, use a default
221
- "Documentation"
91
+ stale = owned.keys - names
92
+
93
+ # Refresh selected skills, then remove stale entries in this scope:
94
+ FileUtils.mkdir_p(skills_path) unless definitions.empty? && stale.empty?
95
+ definitions.each do |definition|
96
+ destination = File.join(skills_path, definition.name)
97
+ FileUtils.rm_rf(destination)
98
+ definition.install(destination)
222
99
  end
100
+ stale.each{|name| FileUtils.rm_rf(File.join(skills_path, name))}
101
+
102
+ # Keep local Git exclusions aligned with the remaining installed skills:
103
+ Exclusion.prepare(@root, (owners.keys - stale + names).uniq)&.apply
104
+ names
223
105
  end
224
106
 
225
- def extract_description(lines)
226
- # Skip empty lines and headers to find the first paragraph
227
- content_start = false
228
- description_lines = []
229
-
230
- lines.each do |line|
231
- # Skip headers
232
- next if line.start_with?("#")
107
+ def validate_skill_destinations(definitions, skills_path, owners)
108
+ definitions.each do |definition|
109
+ owner = owners[definition.name]
110
+ destination = File.join(skills_path, definition.name)
233
111
 
234
- # Skip empty lines until we find content
235
- if !content_start && line.empty?
236
- next
112
+ if owner && owner.values_at("ecosystem", "package") != ["gem", definition.specification.name]
113
+ raise ConflictError, "Skill #{definition.name.inspect} belongs to #{owner["ecosystem"]}:#{owner["package"]}"
237
114
  end
238
115
 
239
- # Mark that we've found content
240
- content_start = true
241
-
242
- # If we hit an empty line after finding content, we've reached the end of the first paragraph
243
- if line.empty?
244
- break
116
+ if (File.exist?(destination) || File.symlink?(destination)) && !owner
117
+ raise ConflictError, "Skill #{definition.name.inspect} is not managed by Agent Context"
245
118
  end
246
-
247
- description_lines << line
248
119
  end
249
-
250
- # Join the lines and truncate if too long
251
- description = description_lines.join(" ").strip
252
-
253
- description
254
120
  end
255
121
  end
256
122
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Shopify Inc.
5
+
6
+ require "fileutils"
7
+ require "pathname"
8
+ require_relative "definition"
9
+ require_relative "document"
10
+ require_relative "skills/definition"
11
+ require_relative "errors"
12
+
13
+ module Agent
14
+ module Context
15
+ # Represents a resolved gem and its loaded context definitions.
16
+ class Package
17
+ # Load and classify a gem's context, keeping skill resources opaque.
18
+ def self.load(specification)
19
+ package = new(specification, [])
20
+ root = package.context_path
21
+ return unless File.exist?(root) || File.symlink?(root)
22
+
23
+ unless File.lstat(root).directory?
24
+ raise InvalidError, "Context provider must be a regular directory: #{root}"
25
+ end
26
+
27
+ package.load_directory(root)
28
+ package
29
+ end
30
+
31
+ # Retain the provider and its loaded definitions.
32
+ def initialize(specification, definitions)
33
+ @specification = specification
34
+ @definitions = definitions
35
+ end
36
+
37
+ # @attribute [Gem::Specification] The resolved provider.
38
+ attr_reader :specification
39
+
40
+ # @attribute [Array(Definition)] All loaded context definitions.
41
+ attr_reader :definitions
42
+
43
+ # @returns [String] The provider gem name.
44
+ def name
45
+ @specification.name
46
+ end
47
+
48
+ # @returns [String] The provider gem version.
49
+ def version
50
+ @specification.version.to_s
51
+ end
52
+
53
+ # @returns [String] The provider context directory.
54
+ def context_path
55
+ File.join(@specification.full_gem_path, "context")
56
+ end
57
+
58
+ # Load Markdown before descending into companion directories.
59
+ def load_directory(directory)
60
+ # Classify declarations before traversing their resource directories:
61
+ directories = []
62
+ Dir.children(directory).sort.each do |name|
63
+ file = File.join(directory, name)
64
+ stat = File.lstat(file)
65
+ if stat.directory?
66
+ directories << file
67
+ elsif stat.file?
68
+ document = File.extname(file).downcase == ".md" ? Document.load(file) : nil
69
+ if document&.skill?
70
+ raise InvalidError, "Skill documents must be directly inside context/: #{file}" unless directory == context_path
71
+ @definitions << Skills::Definition.from_document(document, @specification)
72
+ else
73
+ @definitions << Definition.new(file.delete_prefix("#{context_path}/"), file, document)
74
+ end
75
+ end
76
+ end
77
+
78
+ # Companion resources belong to the skill and are copied as opaque files:
79
+ resources = @definitions.grep(Skills::Definition).map(&:resources)
80
+ directories.each do |directory|
81
+ load_directory(directory) unless resources.include?(directory)
82
+ end
83
+ @definitions.sort_by!(&:path)
84
+ end
85
+
86
+ # Find an authored source path, optionally omitting .md, then an installed skill name.
87
+ def find_definition(file)
88
+ requested = Pathname.new(file)
89
+ if file.empty? || requested.absolute? || requested.each_filename.any?{|part| part == ".." || part == "."}
90
+ raise InvalidError, "Context file must be a relative path inside context/"
91
+ end
92
+
93
+ candidates = [file]
94
+ candidates << "#{file}.md" if File.extname(file).empty?
95
+ candidates.each do |path|
96
+ definition = @definitions.find{|item| item.path == path}
97
+ return definition if definition
98
+ end
99
+ @definitions.find{|item| item.skill? && item.name == file}
100
+ end
101
+
102
+ # Refresh ordinary context from the loaded documents and opaque resources.
103
+ def install_files(destination)
104
+ files = @definitions.reject(&:skill?)
105
+ FileUtils.rm_rf(destination)
106
+ return false if files.empty?
107
+
108
+ files.each do |definition|
109
+ target = File.join(destination, definition.path)
110
+ definition.write_to(target)
111
+ end
112
+ true
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Shopify Inc.
5
+
6
+ # @namespace
7
+ module Agent
8
+ # @namespace
9
+ module Context
10
+ CONTEXT_PATH = File.join(".agents", "context")
11
+ end
12
+ end