simple-agent-extension 0.2.1

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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +16 -0
  3. data/.standard.yml +5 -0
  4. data/AGENTS.md +29 -0
  5. data/CHANGELOG.md +19 -0
  6. data/LICENSE +24 -0
  7. data/README.md +215 -0
  8. data/Rakefile +43 -0
  9. data/adr/adr-0001-single-source-multi-agent-compilation.md +118 -0
  10. data/adr/adr-0002-source-package-model-and-metadata-dsl.md +92 -0
  11. data/adr/adr-0003-compile-time-artifact-scope.md +67 -0
  12. data/adr/adr-0004-external-agents-and-run-time-agent-selection.md +78 -0
  13. data/config/geminabox.ru +9 -0
  14. data/exe/simple-agent-extension +8 -0
  15. data/lib/simple-agent-extension.rb +1 -0
  16. data/lib/simple_agent_extension/agent_base.rb +119 -0
  17. data/lib/simple_agent_extension/agent_directory_loader.rb +60 -0
  18. data/lib/simple_agent_extension/agent_property/deployment.rb +23 -0
  19. data/lib/simple_agent_extension/agent_property/metadata_translator.rb +55 -0
  20. data/lib/simple_agent_extension/agent_property/skill.rb +29 -0
  21. data/lib/simple_agent_extension/agent_property.rb +8 -0
  22. data/lib/simple_agent_extension/agent_registry.rb +99 -0
  23. data/lib/simple_agent_extension/agents/claude_code.rb +62 -0
  24. data/lib/simple_agent_extension/agents/copilot.rb +30 -0
  25. data/lib/simple_agent_extension/agents/opencode.rb +35 -0
  26. data/lib/simple_agent_extension/agents.rb +11 -0
  27. data/lib/simple_agent_extension/cli.rb +145 -0
  28. data/lib/simple_agent_extension/collector.rb +86 -0
  29. data/lib/simple_agent_extension/compiler.rb +185 -0
  30. data/lib/simple_agent_extension/deployer.rb +60 -0
  31. data/lib/simple_agent_extension/deployment_report.rb +52 -0
  32. data/lib/simple_agent_extension/extensions/agent.rb +23 -0
  33. data/lib/simple_agent_extension/extensions/base.rb +73 -0
  34. data/lib/simple_agent_extension/extensions/skill.rb +10 -0
  35. data/lib/simple_agent_extension/extensions.rb +29 -0
  36. data/lib/simple_agent_extension/frontmatter.rb +48 -0
  37. data/lib/simple_agent_extension/metadata.rb +27 -0
  38. data/lib/simple_agent_extension/metadata_composer.rb +32 -0
  39. data/lib/simple_agent_extension/metadata_loader.rb +63 -0
  40. data/lib/simple_agent_extension/runner.rb +95 -0
  41. data/lib/simple_agent_extension/version.rb +5 -0
  42. data/lib/simple_agent_extension.rb +23 -0
  43. data/packages/deep-review/agent/deep-design-reviewer.md +184 -0
  44. data/packages/deep-review/agent/metadata.yaml +9 -0
  45. data/packages/deep-review/skill/SKILL.md +122 -0
  46. data/packages/deep-review/skill/metadata.yaml +6 -0
  47. metadata +92 -0
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "simple_agent_extension"
5
+
6
+ module SimpleAgentExtension
7
+ # Command-line adapter for public package compilation and deployment.
8
+ class CLI
9
+ COMMANDS = {
10
+ "packages" => "List package names under the source root",
11
+ "build" => "Compile artifacts into the build root",
12
+ "deploy" => "Build artifacts, then deploy them to Agents",
13
+ "agents" => "List Agent names that can be targeted"
14
+ }.freeze
15
+
16
+ def self.run(arguments, output: $stdout, error: $stderr)
17
+ new(output: output, error: error).run(arguments)
18
+ end
19
+
20
+ def initialize(output:, error:)
21
+ @output = output
22
+ @error = error
23
+ end
24
+
25
+ def run(arguments)
26
+ arguments = arguments.dup
27
+ options = default_options
28
+ parser = option_parser(options)
29
+ parser.parse!(arguments)
30
+
31
+ return display_help(parser) if options[:help]
32
+
33
+ command = command(arguments)
34
+ validate_options(command, options)
35
+ AgentDirectoryLoader.new(options[:agent_directories]).load
36
+ run_command(command, options)
37
+ 0
38
+ rescue OptionParser::ParseError, Error, ArgumentError, KeyError => error
39
+ @error.puts error.message
40
+ 1
41
+ end
42
+
43
+ # Aligns the name column alone. Columns an Agent does not declare are
44
+ # dropped rather than padded, so no line carries trailing whitespace.
45
+ #
46
+ # @param [Array<Hash>] agents name, description, and homepage per Agent
47
+ # @return [Array<String>]
48
+ def agent_lines(agents)
49
+ width = agents.map { |agent| agent[:name].length }.max.to_i
50
+
51
+ agents.map do |agent|
52
+ [agent[:name].ljust(width), agent[:description], agent[:homepage]].compact.join(" ").rstrip
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def default_options
59
+ {
60
+ source_root: File.expand_path("packages"),
61
+ build_root: File.expand_path("build"),
62
+ agents: [],
63
+ agent_directories: [],
64
+ force: false,
65
+ help: false
66
+ }
67
+ end
68
+
69
+ def option_parser(options)
70
+ OptionParser.new do |parser|
71
+ parser.banner = banner
72
+
73
+ parser.on("--source-root DIRECTORY", "Source package root (default: ./packages)") do |directory|
74
+ options[:source_root] = File.expand_path(directory)
75
+ end
76
+ parser.on("--build-root DIRECTORY", "Build artifact root (default: ./build)") do |directory|
77
+ options[:build_root] = File.expand_path(directory)
78
+ end
79
+ parser.on("--agent NAME", "Build or deploy only NAME (repeatable)") do |name|
80
+ options[:agents] << name
81
+ end
82
+ parser.on("--agent-dir DIRECTORY", "Load direct *.rb Agent registrations from DIRECTORY") do |directory|
83
+ options[:agent_directories] << directory
84
+ end
85
+ parser.on("--force", "Deploy even if an Agent is not installed") do
86
+ options[:force] = true
87
+ end
88
+ parser.on("-h", "--help", "Show this help") do
89
+ options[:help] = true
90
+ end
91
+ end
92
+ end
93
+
94
+ def banner
95
+ width = COMMANDS.keys.map(&:length).max
96
+ commands = COMMANDS.map { |name, summary| " #{name.ljust(width)} #{summary}" }
97
+
98
+ ["Usage: simple-agent-extension COMMAND [options]", "", "Commands:", *commands, "", "Options:"].join("\n")
99
+ end
100
+
101
+ def display_help(parser)
102
+ @output.puts parser
103
+ 0
104
+ end
105
+
106
+ def command(arguments)
107
+ command = arguments.shift
108
+ raise ArgumentError, "missing command" unless command
109
+ raise ArgumentError, "unknown command: #{command}" unless COMMANDS.key?(command)
110
+ raise ArgumentError, "unexpected arguments: #{arguments.join(" ")}" unless arguments.empty?
111
+
112
+ command
113
+ end
114
+
115
+ def validate_options(command, options)
116
+ return unless options[:force] && command != "deploy"
117
+
118
+ raise ArgumentError, "--force is only valid for deploy"
119
+ end
120
+
121
+ def run_command(command, options)
122
+ runner = Runner.new(
123
+ source_root: options[:source_root],
124
+ build_root: options[:build_root],
125
+ agent_registry: AgentRegistry.default
126
+ )
127
+
128
+ case command
129
+ when "packages"
130
+ @output.puts runner.packages
131
+ when "agents"
132
+ @output.puts agent_lines(runner.agents)
133
+ when "build"
134
+ @output.puts runner.build(agents: options[:agents])
135
+ when "deploy"
136
+ @output.puts runner.build(agents: options[:agents])
137
+ print_deployments(runner.deploy(agents: options[:agents], force: options[:force]))
138
+ end
139
+ end
140
+
141
+ def print_deployments(deployments)
142
+ @output.puts DeploymentReport.new(deployments).lines
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,86 @@
1
+ module SimpleAgentExtension
2
+ # Traverses the `<package>/<type>/` layout shared by source extensions and
3
+ # compiled artifacts. This is the only place that knows that layout.
4
+ class Collector
5
+ # Source metadata is agent-independent input, before Agent-specific
6
+ # translation. All source extensions therefore share one tree:
7
+ #
8
+ # <source_root>/<package>/<type>/
9
+ #
10
+ # @param [String] root source package root
11
+ # @return [Collector] source extension collection
12
+ def self.source(root:)
13
+ new(root: root)
14
+ end
15
+
16
+ # Agent-specific metadata, filenames, and scope can differ. Build artifacts
17
+ # are partitioned by Agent before the shared package/type layout so those
18
+ # results remain distinct:
19
+ #
20
+ # <build_root>/<agent>/<scope>/<package>/<type>/
21
+ #
22
+ # @param [String] root build root
23
+ # @param [AgentBase] agent selects one Agent's artifact subtree
24
+ # @param [Symbol] scope selects that Agent's shared or own artifacts
25
+ # @return [Collector] compiled artifact collection
26
+ def self.build(root:, agent:, scope:)
27
+ new(root: File.join(root, agent.name, scope.to_s))
28
+ end
29
+
30
+ # @param [String] root
31
+ def initialize(root:)
32
+ @root = root
33
+ end
34
+ attr_reader :root
35
+
36
+ # Build artifacts have no `metadata.yaml` and no name to read from it, so
37
+ # Deployer walks +dirs+ instead.
38
+ #
39
+ # @param [String, nil] package
40
+ # @param [String, nil] type
41
+ # @return [Array<Extensions::Base>]
42
+ def extensions(package: nil, type: nil)
43
+ dirs(package: package, type: type).map { |pkg, kind, dir|
44
+ Extensions.for(kind).new(
45
+ package: pkg,
46
+ dir: dir,
47
+ files: files(pkg, kind)
48
+ )
49
+ }
50
+ end
51
+
52
+ # @param [String, nil] package
53
+ # @param [String, nil] type
54
+ # @return [Array<[String, String, String]>] package, type, dir
55
+ def dirs(package: nil, type: nil)
56
+ Dir.glob(File.join(package || "*", type || "*", ""), base: root).map { |rel|
57
+ pkg, kind = rel.chomp("/").split("/")
58
+ [pkg, kind, dir(package: pkg, type: kind)]
59
+ }.sort
60
+ end
61
+
62
+ # `build/<agent>/<scope>/<pkg>/<type>/` is compile's write target too, so
63
+ # Compiler asks here rather than assembling the path itself.
64
+ #
65
+ # @param [String] package
66
+ # @param [String] type
67
+ # @return [String]
68
+ def dir(package:, type:)
69
+ File.join(root, package, type)
70
+ end
71
+
72
+ private
73
+
74
+ # @param [String] package
75
+ # @param [String] type
76
+ # @return [Array<String>] paths relative to +dir+
77
+ def files(package, type)
78
+ base = dir(package: package, type: type)
79
+
80
+ Dir.glob("**/*", base: base)
81
+ .select { |path| File.file?(File.join(base, path)) }
82
+ .reject { |path| path == MetadataLoader::FILENAME }
83
+ .sort
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,185 @@
1
+ require "fileutils"
2
+
3
+ module SimpleAgentExtension
4
+ # Writes one source extension to `build/<agent>/<scope>/<pkg>/<type>/` for
5
+ # the configured Agent. It first resolves entrypoint metadata and artifact
6
+ # scope, then writes the artifact core file and its bundled files.
7
+ #
8
+ # The resulting scope has these meanings:
9
+ #
10
+ # [source package] -> (compile) -> shared skill/ (scope)
11
+ # -> agent dir A/ (scope)
12
+ # +-- skills/ (type)
13
+ # +- <pkg>/
14
+ # +-- agents/ (type)
15
+ # +- <pkg>/
16
+ # -> agent dir B/
17
+ #
18
+ # - `shared` is a skill with no agent-specific representation or metadata.
19
+ # - `own` is material for one agent's configuration tree. A skill becomes
20
+ #
21
+ # own when its representation or named metadata differs; an agent is
22
+ # always own because it has no shared destination.
23
+ #
24
+ # The scope is recorded in the build path so Deployer can map the artifact
25
+ # to a destination without rereading source or reapplying these rules.
26
+ class Compiler
27
+ FragmentTransporter = Data.define(
28
+ :overlaid_adaptive,
29
+ :common_static,
30
+ :agent_static
31
+ )
32
+
33
+ # @param [AgentBase] agent supplies translation, core-file, and deployment facts
34
+ # @param [String] build_root forwarded to Collector.build
35
+ def initialize(agent:, build_root:)
36
+ @agent = agent
37
+ @metadata_composer = MetadataComposer.new
38
+ @build_root = build_root
39
+ end
40
+
41
+ # @param [Extensions::Base] extension
42
+ # @return [String]
43
+ def compile(extension)
44
+ document = extension.entrypoint_document
45
+ dto = fragment_transporter(
46
+ loader: extension.metadata_loader,
47
+ document: document,
48
+ extension_name: extension.name
49
+ )
50
+
51
+ adapted_fragments = translate_for_agent(
52
+ type: extension.type,
53
+ overlaid_adaptive: dto.overlaid_adaptive
54
+ )
55
+ # A named distribution target rules out the shared destination before
56
+ # the content has any say: that destination is read by every Agent.
57
+ artifact_scope = if extension.deploy_to
58
+ :own
59
+ else
60
+ classify_artifact_scope(
61
+ type: extension.type,
62
+ overlaid_adaptive: dto.overlaid_adaptive,
63
+ adapted: adapted_fragments,
64
+ agent_static: dto.agent_static
65
+ )
66
+ end
67
+ artifact_metadata = @metadata_composer.overlay_agent_specific(
68
+ common: dto.common_static,
69
+ adapted: adapted_fragments,
70
+ agent_static: dto.agent_static
71
+ )
72
+ materialize_artifact(
73
+ extension,
74
+ body: document.body,
75
+ metadata: artifact_metadata,
76
+ artifact_scope: artifact_scope
77
+ )
78
+ end
79
+
80
+ # A shared artifact is valid only when the Agent supports sharing,
81
+ # adaptation leaves its metadata unchanged, and no Agent-specific static
82
+ # metadata is configured. Common static metadata does not affect this decision because it
83
+ # applies to every Agent.
84
+ #
85
+ # @param [String, Symbol] type
86
+ # @param [Metadata] overlaid_adaptive adaptive source fragments with extension identity applied
87
+ # @param [Metadata] adapted metadata after Agent translation
88
+ # @param [Metadata] agent_static
89
+ # @return [Symbol] :shared or :own
90
+ def classify_artifact_scope(type:, overlaid_adaptive:, adapted:, agent_static:)
91
+ return :own unless @agent.deployment.shared?(type)
92
+ return :own unless adapted == overlaid_adaptive && agent_static.empty?
93
+
94
+ :shared
95
+ end
96
+
97
+ # @param [MetadataLoader] loader
98
+ # @param [EntrypointDocument] document
99
+ # @param [String] extension_name
100
+ # @return [FragmentTransporter]
101
+ def fragment_transporter(loader:, document:, extension_name:)
102
+ FragmentTransporter.new(
103
+ overlaid_adaptive: overlaid_adaptive(
104
+ adaptive: loader.adaptive,
105
+ name: extension_name
106
+ ),
107
+ common_static: @metadata_composer.common_static_fragments(
108
+ frontmatter: document.metadata,
109
+ common_static: loader.common_static
110
+ ),
111
+ agent_static: loader.agent_static(@agent.name)
112
+ )
113
+ end
114
+
115
+ # @param [String, Symbol] type
116
+ # @param [Metadata] overlaid_adaptive
117
+ # @return [Metadata]
118
+ def translate_for_agent(type:, overlaid_adaptive:)
119
+ fragments = overlaid_adaptive.each_with_object({}) do |(field, value), output|
120
+ fragment =
121
+ if @agent.can_trans_meta?(type: type, field: field)
122
+ @agent.trans_meta(type: type, field: field, value: value)
123
+ else
124
+ {field => value}
125
+ end
126
+ output.merge!(fragment) if fragment
127
+ end
128
+
129
+ Metadata.from(fragments)
130
+ end
131
+
132
+ private
133
+
134
+ # Overlays adaptive source fragments on extension identity before field-by-field
135
+ # Agent processing.
136
+ #
137
+ # @param [Metadata] adaptive
138
+ # @param [String] name
139
+ # @return [Metadata]
140
+ def overlaid_adaptive(adaptive:, name:)
141
+ Metadata.from("name" => name).merge(adaptive)
142
+ end
143
+
144
+ # @param [Extensions::Base] extension
145
+ # @param [String] body
146
+ # @param [Metadata] metadata
147
+ # @param [Symbol] artifact_scope
148
+ # @return [String]
149
+ def materialize_artifact(extension, body:, metadata:, artifact_scope:)
150
+ dir = Collector.build(root: @build_root, agent: @agent,
151
+ scope: artifact_scope)
152
+ .dir(package: extension.package, type: extension.type)
153
+ artifact_core_file = @agent.artifact_core_file(
154
+ extension_type: extension.type,
155
+ extension_name: extension.name,
156
+ source_file: extension.entrypoint
157
+ )
158
+
159
+ FileUtils.rm_rf(dir)
160
+ write(File.join(dir, artifact_core_file), Frontmatter.render(metadata, body))
161
+ copy_bundled_files(extension, dir: dir, artifact_core_file: artifact_core_file)
162
+
163
+ dir
164
+ end
165
+
166
+ # @param [Extensions::Base] extension
167
+ # @param [String] dir
168
+ # @param [String] artifact_core_file
169
+ def copy_bundled_files(extension, dir:, artifact_core_file:)
170
+ (extension.files - [extension.entrypoint]).each do |file|
171
+ copy(File.join(extension.dir, file), File.join(dir, File.dirname(artifact_core_file), file))
172
+ end
173
+ end
174
+
175
+ def write(path, text)
176
+ FileUtils.mkdir_p(File.dirname(path))
177
+ File.write(path, text)
178
+ end
179
+
180
+ def copy(src, dest)
181
+ FileUtils.mkdir_p(File.dirname(dest))
182
+ FileUtils.cp(src, dest)
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,60 @@
1
+ require "fileutils"
2
+
3
+ module SimpleAgentExtension
4
+ # Maps `build/<agent>/<scope>/<pkg>/<type>/` to the directories an Agent
5
+ # exposes, then copies it. The build scope is Compiler's recorded decision:
6
+ #
7
+ # - skill + shared -> Agent#shared_skill_dir
8
+ # - skill + own -> the Agent's config-dir skill location
9
+ # - agent + own -> the Agent's config-dir agent location
10
+ #
11
+ # `agent + shared` is not a valid build artifact. Deployer never examines
12
+ # source metadata or recomputes scope.
13
+ class Deployer
14
+ # @return [Array<String>]
15
+ SCOPES = %i[shared own].freeze
16
+
17
+ # @param [AgentBase] agent supplies a deployment facet
18
+ # @param [String] build_root forwarded to Collector.build
19
+ def initialize(agent:, build_root:)
20
+ @agent = agent
21
+ @build_root = build_root
22
+ end
23
+
24
+ # @param [Boolean] force deploy even when the Agent product is not installed
25
+ # @return [Array<Array(String, String)>] source and destination pairs
26
+ def deploy(force: false)
27
+ return skip_uninstalled_agent unless force || @agent.installed?
28
+
29
+ SCOPES.flat_map { |scope|
30
+ Collector.build(root: @build_root, agent: @agent, scope: scope).dirs.flat_map { |_pkg, type, dir|
31
+ copy(dir, type, scope)
32
+ }
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def skip_uninstalled_agent
39
+ warn "skipped deployment for uninstalled agent: #{@agent.name} (#{@agent.config_dir})"
40
+ []
41
+ end
42
+
43
+ # @param [String] dir `<type>/` directory in the build tree
44
+ # @param [String] type
45
+ # @param [Symbol] scope
46
+ # @return [Array<Array(String, String)>]
47
+ def copy(dir, type, scope)
48
+ Dir.children(dir).map { |entry|
49
+ src = File.join(dir, entry)
50
+ dest = File.join(@agent.deployment.destination(type, scope), entry)
51
+
52
+ FileUtils.mkdir_p(File.dirname(dest))
53
+ FileUtils.rm_rf(dest)
54
+ FileUtils.cp_r(src, dest)
55
+
56
+ [src, dest]
57
+ }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,52 @@
1
+ module SimpleAgentExtension
2
+ # Renders `Runner#deploy` results for humans: an Agent heading, then one
3
+ # line per deployed extension rather than per file.
4
+ #
5
+ # opencode
6
+ # deep-review/agent -> ~/.config/opencode/agents
7
+ # deep-review/skill -> ~/.agents/skills
8
+ #
9
+ # The `<package>/<type>` label comes from the build tree, so the Agent
10
+ # specific filename an extension was compiled into never shows up here.
11
+ class DeploymentReport
12
+ INDENT = " "
13
+
14
+ # @param [Hash{String => Array<Array(String, String)>}] deployments
15
+ # source and destination pairs per Agent name
16
+ # @param [String] home replaced by `~` in displayed destinations
17
+ def initialize(deployments, home: Dir.home)
18
+ @deployments = deployments
19
+ @home = home
20
+ end
21
+
22
+ # @return [Array<String>]
23
+ def lines
24
+ @deployments.flat_map { |agent_name, pairs| [agent_name, *extension_lines(pairs)] }
25
+ end
26
+
27
+ private
28
+
29
+ def extension_lines(pairs)
30
+ pairs.map { |source, destination|
31
+ "#{INDENT}#{extension_label(source)} -> #{shorten(File.dirname(destination))}"
32
+ }.uniq
33
+ end
34
+
35
+ # @param [String] source `.../<package>/<type>/<entry>` in the build tree
36
+ # @return [String]
37
+ def extension_label(source)
38
+ File.split(File.dirname(source)).map { |part| File.basename(part) }.join("/")
39
+ end
40
+
41
+ # Destinations are directories under an Agent's config or shared skill
42
+ # root, so home itself is never one of them.
43
+ #
44
+ # @param [String] path absolute destination directory
45
+ # @return [String]
46
+ def shorten(path)
47
+ return path unless path.start_with?("#{@home}/")
48
+
49
+ path.sub(@home, "~")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,23 @@
1
+ module SimpleAgentExtension
2
+ module Extensions
3
+ class Agent < Base
4
+ # Found rather than derived from the name: in the build tree the name is
5
+ # read from this very file's frontmatter, and deriving it would close a
6
+ # loop.
7
+ #
8
+ # @return [String]
9
+ def entrypoint
10
+ candidates = files.select { |path|
11
+ File.dirname(path) == "." && File.extname(path) == ".md"
12
+ }
13
+
14
+ unless candidates.size == 1
15
+ raise AmbiguousEntrypoint,
16
+ "expected exactly one top level .md in #{dir}, got #{candidates.inspect}"
17
+ end
18
+
19
+ candidates.first
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,73 @@
1
+ module SimpleAgentExtension
2
+ module Extensions
3
+ EntrypointDocument = Data.define(:metadata, :body)
4
+
5
+ # An extension that owns loading metadata from its directory.
6
+ class Base
7
+ # @param [String] package
8
+ # @param [String] dir directory holding the extension, source or build
9
+ # @param [Array<String>] files paths relative to +dir+
10
+ def initialize(package:, dir:, files:)
11
+ @package = package
12
+ @dir = dir
13
+ @files = files
14
+ end
15
+ attr_reader :package, :dir, :files
16
+
17
+ # @return [Metadata] source YAML mapping
18
+ def metadata
19
+ metadata_loader.metadata
20
+ end
21
+
22
+ # @return [String]
23
+ def type
24
+ self.class.name.split("::").last.downcase
25
+ end
26
+
27
+ # The name the extension is deployed under.
28
+ #
29
+ # prefer metadata.yaml over document frontmatter
30
+ #
31
+ # @return [String]
32
+ def name
33
+ metadata["name"] || entrypoint_document.metadata["name"] || package
34
+ end
35
+
36
+ # @return [String] relative to +dir+
37
+ def entrypoint
38
+ raise NotImplementedError
39
+ end
40
+
41
+ # @return [String]
42
+ def entrypoint_path
43
+ File.join(dir, entrypoint)
44
+ end
45
+
46
+ # @return [EntrypointDocument]
47
+ def entrypoint_document
48
+ metadata, body = Frontmatter.parse(File.read(entrypoint_path))
49
+
50
+ EntrypointDocument.new(metadata, body)
51
+ end
52
+
53
+ # @return [MetadataLoader] source YAML reader
54
+ def metadata_loader
55
+ @metadata_loader ||= MetadataLoader.load(dir)
56
+ end
57
+
58
+ # @return [Array<String>, nil] Agent names this extension is distributed
59
+ # to; nil when it names none and therefore reaches every Agent
60
+ def deploy_to
61
+ metadata_loader.deploy_to
62
+ end
63
+
64
+ # @param [String] agent_name
65
+ # @return [Boolean]
66
+ def deployable_to?(agent_name)
67
+ names = deploy_to
68
+
69
+ names.nil? || names.include?(agent_name)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,10 @@
1
+ module SimpleAgentExtension
2
+ module Extensions
3
+ class Skill < Base
4
+ # @return [String]
5
+ def entrypoint
6
+ "SKILL.md"
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,29 @@
1
+ module SimpleAgentExtension
2
+ # Distributables. One `<pkg>/<type>/` directory is one extension.
3
+ #
4
+ # The class is the type; there is no separate attribute for it.
5
+ # Extension::Something ( eg, Skill, Agent, .. )
6
+ module Extensions
7
+ end
8
+ end
9
+
10
+ require_relative "extensions/base"
11
+ require_relative "extensions/skill"
12
+ require_relative "extensions/agent"
13
+
14
+ module SimpleAgentExtension
15
+ module Extensions
16
+ # Raises a plain KeyError for an unknown type: the collector walks the
17
+ # second level of `packages/` unconditionally, so a directory that is not
18
+ # one of these is a layout mistake, not something to route around
19
+ #
20
+ # @param [String, Symbol] type
21
+ # @return [Class] - subclass of Extensions::Base
22
+ def self.for(type)
23
+ constants.map { |const| const_get(const) }
24
+ .select { |klass| klass.is_a?(Class) && klass < Base }
25
+ .find { |klass| klass.name.split("::").last.downcase == type.to_s } ||
26
+ raise(KeyError, "key not found: #{type.inspect}")
27
+ end
28
+ end
29
+ end