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.
- checksums.yaml +7 -0
- data/.rubocop.yml +16 -0
- data/.standard.yml +5 -0
- data/AGENTS.md +29 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +24 -0
- data/README.md +215 -0
- data/Rakefile +43 -0
- data/adr/adr-0001-single-source-multi-agent-compilation.md +118 -0
- data/adr/adr-0002-source-package-model-and-metadata-dsl.md +92 -0
- data/adr/adr-0003-compile-time-artifact-scope.md +67 -0
- data/adr/adr-0004-external-agents-and-run-time-agent-selection.md +78 -0
- data/config/geminabox.ru +9 -0
- data/exe/simple-agent-extension +8 -0
- data/lib/simple-agent-extension.rb +1 -0
- data/lib/simple_agent_extension/agent_base.rb +119 -0
- data/lib/simple_agent_extension/agent_directory_loader.rb +60 -0
- data/lib/simple_agent_extension/agent_property/deployment.rb +23 -0
- data/lib/simple_agent_extension/agent_property/metadata_translator.rb +55 -0
- data/lib/simple_agent_extension/agent_property/skill.rb +29 -0
- data/lib/simple_agent_extension/agent_property.rb +8 -0
- data/lib/simple_agent_extension/agent_registry.rb +99 -0
- data/lib/simple_agent_extension/agents/claude_code.rb +62 -0
- data/lib/simple_agent_extension/agents/copilot.rb +30 -0
- data/lib/simple_agent_extension/agents/opencode.rb +35 -0
- data/lib/simple_agent_extension/agents.rb +11 -0
- data/lib/simple_agent_extension/cli.rb +145 -0
- data/lib/simple_agent_extension/collector.rb +86 -0
- data/lib/simple_agent_extension/compiler.rb +185 -0
- data/lib/simple_agent_extension/deployer.rb +60 -0
- data/lib/simple_agent_extension/deployment_report.rb +52 -0
- data/lib/simple_agent_extension/extensions/agent.rb +23 -0
- data/lib/simple_agent_extension/extensions/base.rb +73 -0
- data/lib/simple_agent_extension/extensions/skill.rb +10 -0
- data/lib/simple_agent_extension/extensions.rb +29 -0
- data/lib/simple_agent_extension/frontmatter.rb +48 -0
- data/lib/simple_agent_extension/metadata.rb +27 -0
- data/lib/simple_agent_extension/metadata_composer.rb +32 -0
- data/lib/simple_agent_extension/metadata_loader.rb +63 -0
- data/lib/simple_agent_extension/runner.rb +95 -0
- data/lib/simple_agent_extension/version.rb +5 -0
- data/lib/simple_agent_extension.rb +23 -0
- data/packages/deep-review/agent/deep-design-reviewer.md +184 -0
- data/packages/deep-review/agent/metadata.yaml +9 -0
- data/packages/deep-review/skill/SKILL.md +122 -0
- data/packages/deep-review/skill/metadata.yaml +6 -0
- 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,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
|