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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/agent/context.rb +18 -43
- data/context/getting-started.md +48 -12
- data/context/index.yaml +5 -3
- data/context/usage.md +73 -0
- data/design.md +78 -0
- data/lib/agent/context/definition.rb +58 -0
- data/lib/agent/context/document.rb +88 -0
- data/lib/agent/context/errors.rb +20 -0
- data/lib/agent/context/exclusion.rb +90 -0
- data/lib/agent/context/installer.rb +76 -210
- data/lib/agent/context/package.rb +116 -0
- data/lib/agent/context/paths.rb +12 -0
- data/lib/agent/context/skills/definition.rb +146 -0
- data/lib/agent/context/skills/ownership.rb +69 -0
- data/lib/agent/context/skills.rb +15 -0
- data/lib/agent/context/version.rb +1 -1
- data/lib/agent/context.rb +3 -2
- data/license.md +2 -2
- data/post.md +12 -10
- data/readme.md +65 -81
- data/releases.md +13 -0
- data/specification.md +97 -18
- data.tar.gz.sig +0 -0
- metadata +53 -31
- metadata.gz.sig +0 -0
- data/lib/agent/context/index.rb +0 -325
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
42
|
-
def
|
|
43
|
-
|
|
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
|
-
#
|
|
85
|
-
def
|
|
86
|
-
gem
|
|
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
|
-
#
|
|
41
|
+
# Read the authored content of a selected definition.
|
|
93
42
|
def show_context_file(gem_name, file_name)
|
|
94
|
-
|
|
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
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
62
|
+
{context: installed_context, skills: installed_skills}
|
|
143
63
|
end
|
|
144
64
|
|
|
145
65
|
private
|
|
146
66
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|