rails-agent-stack 0.1.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/.github/workflows/README.md +16 -0
- data/.github/workflows/ci.yml +32 -0
- data/.gitignore +8 -0
- data/.rspec +3 -0
- data/AGENTS.md +18 -0
- data/CHANGELOG.md +40 -0
- data/Gemfile +15 -0
- data/MIT-LICENSE +21 -0
- data/README.md +228 -0
- data/Rakefile +8 -0
- data/app/assets/images/rails_agents/kip-logo.png +0 -0
- data/app/assets/images/rails_agents/kip-waving.png +0 -0
- data/app/assets/stylesheets/rails_agents/application.css +411 -0
- data/app/controllers/rails_agents/application_controller.rb +25 -0
- data/app/controllers/rails_agents/connect_controller.rb +36 -0
- data/app/controllers/rails_agents/dashboard_controller.rb +70 -0
- data/app/controllers/rails_agents/handshake_controller.rb +55 -0
- data/app/controllers/rails_agents/open_wire_inbound_controller.rb +153 -0
- data/app/controllers/rails_agents/pulls_controller.rb +42 -0
- data/app/controllers/rails_agents/registrations_controller.rb +96 -0
- data/app/controllers/rails_agents/schema_controller.rb +123 -0
- data/app/controllers/rails_agents/sessions_controller.rb +76 -0
- data/app/controllers/rails_agents/tool_bridge_controller.rb +67 -0
- data/app/views/layouts/rails_agents/application.html.erb +18 -0
- data/app/views/rails_agents/dashboard/show.html.erb +139 -0
- data/app/views/rails_agents/registrations/new.html.erb +83 -0
- data/app/views/rails_agents/sessions/new.html.erb +73 -0
- data/app/views/rails_agents/shared/_auth_brand.html.erb +20 -0
- data/config/routes.rb +34 -0
- data/docs/AGENTS.md +220 -0
- data/docs/PRD.md +89 -0
- data/docs/assets/hero-banner.png +0 -0
- data/examples/support/agent.rb +14 -0
- data/examples/support/channels/.keep +0 -0
- data/examples/support/evals/smoke.yml +4 -0
- data/examples/support/knowledge/shipping.md +1 -0
- data/examples/support/memory.rb +3 -0
- data/examples/support/prompt.md +9 -0
- data/examples/support/skills/.keep +0 -0
- data/examples/support/tools/.keep +0 -0
- data/exe/rails-agents +6 -0
- data/lib/generators/rails_agents/agent/agent_generator.rb +80 -0
- data/lib/generators/rails_agents/agent/templates/agent.rb.tt +14 -0
- data/lib/generators/rails_agents/agent/templates/agent_database.rb.tt +42 -0
- data/lib/generators/rails_agents/agent/templates/channels/slack.rb.tt +8 -0
- data/lib/generators/rails_agents/agent/templates/evals/smoke.yml.tt +4 -0
- data/lib/generators/rails_agents/agent/templates/imports.yml.tt +9 -0
- data/lib/generators/rails_agents/agent/templates/memory.rb.tt +6 -0
- data/lib/generators/rails_agents/agent/templates/prompt.md.tt +10 -0
- data/lib/generators/rails_agents/agent/templates/prompt_database.md.tt +14 -0
- data/lib/generators/rails_agents/install/INSTALL +10 -0
- data/lib/generators/rails_agents/install/install_generator.rb +72 -0
- data/lib/generators/rails_agents/install/templates/AGENTS.md.tt +46 -0
- data/lib/generators/rails_agents/install/templates/initializer.rb.tt +8 -0
- data/lib/generators/rails_agents/install/templates/library_README.md.tt +28 -0
- data/lib/generators/rails_agents/install/templates/library_manifest.yml.tt +5 -0
- data/lib/generators/rails_agents/install/templates/rails_agents_autoload.rb.tt +10 -0
- data/lib/rails-agent-stack.rb +4 -0
- data/lib/rails_agents/background_agent.rb +15 -0
- data/lib/rails_agents/base.rb +294 -0
- data/lib/rails_agents/chat_agent.rb +15 -0
- data/lib/rails_agents/cli.rb +245 -0
- data/lib/rails_agents/client.rb +216 -0
- data/lib/rails_agents/configuration.rb +41 -0
- data/lib/rails_agents/creation_agent.rb +10 -0
- data/lib/rails_agents/credentials_writer.rb +38 -0
- data/lib/rails_agents/engine.rb +19 -0
- data/lib/rails_agents/knowledge_agent.rb +10 -0
- data/lib/rails_agents/library_imports.rb +163 -0
- data/lib/rails_agents/local_auth.rb +56 -0
- data/lib/rails_agents/local_sync.rb +113 -0
- data/lib/rails_agents/monitoring_agent.rb +10 -0
- data/lib/rails_agents/open_wire_adapter.rb +31 -0
- data/lib/rails_agents/operations_agent.rb +10 -0
- data/lib/rails_agents/railtie.rb +14 -0
- data/lib/rails_agents/tasks.rake +8 -0
- data/lib/rails_agents/version.rb +5 -0
- data/lib/rails_agents/workflow_agent.rb +11 -0
- data/lib/rails_agents.rb +57 -0
- data/rails-agent-stack.gemspec +49 -0
- data/rails-agents.code-workspace +25 -0
- data/spec/generators/agent_generator_spec.rb +36 -0
- data/spec/rails_agents/base_spec.rb +202 -0
- data/spec/rails_agents/client_spec.rb +121 -0
- data/spec/rails_agents/configuration_spec.rb +55 -0
- data/spec/rails_agents/library_imports_spec.rb +80 -0
- data/spec/rails_agents/local_sync_spec.rb +63 -0
- data/spec/rails_agents/taxonomy_spec.rb +52 -0
- data/spec/spec_helper.rb +27 -0
- metadata +229 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RailsAgents
|
|
8
|
+
class Client
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :status, :body
|
|
11
|
+
|
|
12
|
+
def initialize(message, status: nil, body: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@status = status
|
|
15
|
+
@body = body
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(config: RailsAgents.config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_run(agent:, message:, session_id: nil, metadata: {})
|
|
24
|
+
post("/runs", {
|
|
25
|
+
agent: agent,
|
|
26
|
+
message: message,
|
|
27
|
+
session_id: session_id,
|
|
28
|
+
project_id: @config.project_id,
|
|
29
|
+
metadata: metadata
|
|
30
|
+
}.compact)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stream_run(run_id, &block)
|
|
34
|
+
get_stream("/runs/#{run_id}/stream", &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def deploy(agent:, bundle_path: nil)
|
|
38
|
+
payload = { agent: agent, project_id: @config.project_id }
|
|
39
|
+
payload[:bundle_path] = bundle_path if bundle_path
|
|
40
|
+
post("/deploys", payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def install_channel(kind:, redirect_url: nil)
|
|
44
|
+
post("/channels/#{kind}/install", {
|
|
45
|
+
project_id: @config.project_id,
|
|
46
|
+
redirect_url: redirect_url
|
|
47
|
+
}.compact)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def sync_knowledge(agent:, paths: [])
|
|
51
|
+
post("/knowledge/sync", {
|
|
52
|
+
agent: agent,
|
|
53
|
+
project_id: @config.project_id,
|
|
54
|
+
paths: paths
|
|
55
|
+
})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_agent(agent:)
|
|
59
|
+
get("/agents/#{agent}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sync_files(agent:, files:)
|
|
63
|
+
put("/agents/#{agent}/files", { files: files })
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def list_files(agent:)
|
|
67
|
+
get("/agents/#{agent}/files")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handshake(email:, password:, workspace: nil)
|
|
71
|
+
post("/auth/handshake", {
|
|
72
|
+
email: email,
|
|
73
|
+
password: password,
|
|
74
|
+
workspace: workspace
|
|
75
|
+
}.compact, auth: false)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def claim_connect(code)
|
|
79
|
+
post("/auth/connect/claim", { code: code }, auth: false)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def email_start(email:, purpose:, name: nil, company: nil)
|
|
83
|
+
post("/auth/email/start", {
|
|
84
|
+
email: email,
|
|
85
|
+
purpose: purpose,
|
|
86
|
+
name: name,
|
|
87
|
+
company: company
|
|
88
|
+
}.compact, auth: false)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def email_verify(email:, code:, name: nil, company: nil)
|
|
92
|
+
post("/auth/email/verify", {
|
|
93
|
+
email: email,
|
|
94
|
+
code: code,
|
|
95
|
+
name: name,
|
|
96
|
+
company: company
|
|
97
|
+
}.compact, auth: false)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def logs(agent: nil, limit: 50)
|
|
101
|
+
get("/logs", query: { agent: agent, limit: limit, project_id: @config.project_id }.compact)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def traces(agent: nil, limit: 50)
|
|
105
|
+
get("/traces", query: { agent: agent, limit: limit, project_id: @config.project_id }.compact)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def evals(agent: nil)
|
|
109
|
+
get("/evals", query: { agent: agent, project_id: @config.project_id }.compact)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def search_packages(query:, registry: "all", limit: 18)
|
|
113
|
+
get("/packages", query: { q: query, registry: registry, limit: limit }.compact)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def install_package(payload)
|
|
117
|
+
post("/packages", payload)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def get(path, query: {})
|
|
123
|
+
request(:get, path, query: query)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def post(path, body, auth: true)
|
|
127
|
+
request(:post, path, body: body, auth: auth)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def put(path, body, auth: true)
|
|
131
|
+
request(:put, path, body: body, auth: auth)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def request(method, path, body: nil, query: {}, auth: true)
|
|
135
|
+
uri = build_uri(path, query)
|
|
136
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
137
|
+
http.use_ssl = uri.scheme == "https"
|
|
138
|
+
http.open_timeout = 10
|
|
139
|
+
http.read_timeout = 120
|
|
140
|
+
|
|
141
|
+
request_class =
|
|
142
|
+
case method
|
|
143
|
+
when :get then Net::HTTP::Get
|
|
144
|
+
when :put then Net::HTTP::Put
|
|
145
|
+
else Net::HTTP::Post
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
req = request_class.new(uri)
|
|
149
|
+
req["Accept"] = "application/json"
|
|
150
|
+
req["Content-Type"] = "application/json"
|
|
151
|
+
req["Authorization"] = "Bearer #{@config.api_key}" if auth && @config.api_key
|
|
152
|
+
req.body = JSON.generate(body) if body
|
|
153
|
+
|
|
154
|
+
response = http.request(req)
|
|
155
|
+
parse_response(response)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def get_stream(path, &block)
|
|
159
|
+
uri = build_uri(path)
|
|
160
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
161
|
+
http.use_ssl = uri.scheme == "https"
|
|
162
|
+
http.read_timeout = nil
|
|
163
|
+
|
|
164
|
+
req = Net::HTTP::Get.new(uri)
|
|
165
|
+
req["Accept"] = "text/event-stream"
|
|
166
|
+
req["Authorization"] = "Bearer #{@config.api_key}" if @config.api_key
|
|
167
|
+
|
|
168
|
+
http.request(req) do |response|
|
|
169
|
+
raise Error.new("Stream failed", status: response.code.to_i, body: response.body) unless response.is_a?(Net::HTTPOK)
|
|
170
|
+
|
|
171
|
+
buffer = +""
|
|
172
|
+
response.read_body do |chunk|
|
|
173
|
+
buffer << chunk
|
|
174
|
+
while (line = buffer.slice!(/.*\n/))
|
|
175
|
+
line = line.strip
|
|
176
|
+
next if line.empty? || line.start_with?(":")
|
|
177
|
+
|
|
178
|
+
if line.start_with?("data: ")
|
|
179
|
+
data = line.delete_prefix("data: ")
|
|
180
|
+
block.call(JSON.parse(data)) if block
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_uri(path, query = {})
|
|
188
|
+
base = @config.api_v1_base
|
|
189
|
+
base = "#{base}/" unless base.end_with?("/")
|
|
190
|
+
uri = URI.join(base, path.delete_prefix("/"))
|
|
191
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
192
|
+
uri
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_response(response)
|
|
196
|
+
body = response.body.to_s
|
|
197
|
+
parsed = body.empty? ? {} : JSON.parse(body)
|
|
198
|
+
|
|
199
|
+
return parsed if response.is_a?(Net::HTTPSuccess)
|
|
200
|
+
|
|
201
|
+
message =
|
|
202
|
+
if parsed.is_a?(Hash)
|
|
203
|
+
error = parsed["error"]
|
|
204
|
+
error = error["message"] if error.is_a?(Hash)
|
|
205
|
+
error || parsed["message"] || "Request failed"
|
|
206
|
+
else
|
|
207
|
+
"Request failed"
|
|
208
|
+
end
|
|
209
|
+
raise Error.new(message, status: response.code.to_i, body: parsed)
|
|
210
|
+
rescue JSON::ParserError
|
|
211
|
+
raise Error.new("Invalid JSON response", status: response.code.to_i, body: body) unless response.is_a?(Net::HTTPSuccess)
|
|
212
|
+
|
|
213
|
+
{}
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
class Configuration
|
|
5
|
+
# Always Rails Agent Cloud. Not overridable via ENV.
|
|
6
|
+
DEFAULT_ORIGIN = "https://cloud.rails-agent.com"
|
|
7
|
+
|
|
8
|
+
attr_accessor :api_key, :project_id
|
|
9
|
+
attr_reader :api_base, :dashboard_base
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@api_key = ENV["RAILS_AGENTS_API_KEY"]
|
|
13
|
+
@project_id = ENV["RAILS_AGENTS_PROJECT_ID"]
|
|
14
|
+
@api_base = DEFAULT_ORIGIN
|
|
15
|
+
@dashboard_base = DEFAULT_ORIGIN
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Kept for tests / rare overrides; production apps should not set these.
|
|
19
|
+
def api_base=(value)
|
|
20
|
+
stripped = value.to_s.strip
|
|
21
|
+
@api_base = stripped.empty? ? DEFAULT_ORIGIN : stripped
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dashboard_base=(value)
|
|
25
|
+
stripped = value.to_s.strip
|
|
26
|
+
@dashboard_base = stripped.empty? ? DEFAULT_ORIGIN : stripped
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configured?
|
|
30
|
+
api_key.to_s.strip != ""
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def api_v1_base
|
|
34
|
+
base = api_base.to_s.chomp("/")
|
|
35
|
+
return base if base.end_with?("/api/v1")
|
|
36
|
+
return "#{base}/v1" if base.end_with?("/api")
|
|
37
|
+
|
|
38
|
+
"#{base}/api/v1"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RailsAgents
|
|
6
|
+
module CredentialsWriter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def write!(api_key:, project_id: nil)
|
|
10
|
+
path = Rails.root.join("config/rails_agents_credentials.yml")
|
|
11
|
+
payload = {
|
|
12
|
+
"api_key" => api_key,
|
|
13
|
+
"project_id" => project_id
|
|
14
|
+
}.compact
|
|
15
|
+
File.write(path, payload.to_yaml)
|
|
16
|
+
|
|
17
|
+
env_path = Rails.root.join(".env")
|
|
18
|
+
lines = {
|
|
19
|
+
"RAILS_AGENTS_API_KEY" => api_key,
|
|
20
|
+
"RAILS_AGENTS_PROJECT_ID" => project_id
|
|
21
|
+
}.compact
|
|
22
|
+
|
|
23
|
+
if env_path.exist?
|
|
24
|
+
existing = File.read(env_path)
|
|
25
|
+
lines.each do |key, value|
|
|
26
|
+
if existing.match?(/^#{Regexp.escape(key)}=/)
|
|
27
|
+
existing = existing.gsub(/^#{Regexp.escape(key)}=.*$/, "#{key}=#{value}")
|
|
28
|
+
else
|
|
29
|
+
existing = existing.sub(/\n*\z/, "\n") + "#{key}=#{value}\n"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
File.write(env_path, existing)
|
|
33
|
+
else
|
|
34
|
+
File.write(env_path, lines.map { |k, v| "#{k}=#{v}" }.join("\n") + "\n")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module RailsAgents
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace RailsAgents
|
|
8
|
+
|
|
9
|
+
config.rails_agents = ActiveSupport::OrderedOptions.new
|
|
10
|
+
|
|
11
|
+
initializer "rails_agents.assets" do |app|
|
|
12
|
+
if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile)
|
|
13
|
+
app.config.assets.precompile += %w[
|
|
14
|
+
rails_agents/application.css
|
|
15
|
+
]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module RailsAgents
|
|
7
|
+
# Resolves workspace-shared capabilities declared by an agent's imports.yml.
|
|
8
|
+
# Sources live under app/agents_library; sync uploads virtual agent-local
|
|
9
|
+
# copies so the cloud runtime keeps its existing app/agents/<slug>/ contract.
|
|
10
|
+
class LibraryImports
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
Entry = Struct.new(:kind, :slug, :source, :target, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
KINDS = %w[tool skill connector plugin knowledge package].freeze
|
|
16
|
+
EXTENSIONS = {
|
|
17
|
+
"tool" => ".rb",
|
|
18
|
+
"skill" => ".rb",
|
|
19
|
+
"connector" => ".yml",
|
|
20
|
+
"plugin" => ".yml",
|
|
21
|
+
"knowledge" => ".md",
|
|
22
|
+
"package" => ".yml"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :agent_directory, :library_root
|
|
26
|
+
|
|
27
|
+
def initialize(agent_directory, library_root: nil)
|
|
28
|
+
@agent_directory = Pathname.new(agent_directory).expand_path
|
|
29
|
+
@library_root = library_root ? Pathname.new(library_root).expand_path : default_library_root
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def entries
|
|
33
|
+
return [] unless manifest_path.file?
|
|
34
|
+
|
|
35
|
+
document = YAML.safe_load(manifest_path.read, permitted_classes: [], aliases: false) || {}
|
|
36
|
+
imports = document.fetch("imports", [])
|
|
37
|
+
raise Error, "#{manifest_path} must contain an imports array" unless imports.is_a?(Array)
|
|
38
|
+
|
|
39
|
+
imports.map { |raw| build_entry(raw) }
|
|
40
|
+
rescue Psych::Exception => e
|
|
41
|
+
raise Error, "Invalid #{manifest_path}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def virtual_files
|
|
45
|
+
entries.flat_map do |entry|
|
|
46
|
+
raise Error, "Missing Library source: #{entry.source}" unless entry.source.exist?
|
|
47
|
+
|
|
48
|
+
if entry.kind == "package"
|
|
49
|
+
package_virtual_files(entry)
|
|
50
|
+
else
|
|
51
|
+
raise Error, "Missing Library source: #{entry.source}" unless entry.source.file?
|
|
52
|
+
|
|
53
|
+
[{
|
|
54
|
+
"path" => cloud_path(entry.target),
|
|
55
|
+
"content" => entry.source.read,
|
|
56
|
+
"library_source" => entry.source.relative_path_from(library_root).to_s
|
|
57
|
+
}]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def knowledge_paths
|
|
63
|
+
entries.select { |entry| entry.kind == "knowledge" }.map { |entry| entry.target.to_s }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def package_virtual_files(entry)
|
|
69
|
+
root =
|
|
70
|
+
if entry.source.directory?
|
|
71
|
+
entry.source
|
|
72
|
+
elsif entry.source.basename.to_s == "package.yml"
|
|
73
|
+
entry.source.dirname
|
|
74
|
+
else
|
|
75
|
+
entry.source
|
|
76
|
+
end
|
|
77
|
+
raise Error, "Missing Library package directory: #{root}" unless root.directory?
|
|
78
|
+
|
|
79
|
+
Dir.glob(root.join("**", "*").to_s).select { |path| File.file?(path) }.map do |path|
|
|
80
|
+
file = Pathname.new(path)
|
|
81
|
+
relative = file.relative_path_from(root)
|
|
82
|
+
target = Pathname.new("packages/#{entry.slug}").join(relative)
|
|
83
|
+
{
|
|
84
|
+
"path" => cloud_path(target),
|
|
85
|
+
"content" => file.read,
|
|
86
|
+
"library_source" => file.relative_path_from(library_root).to_s
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def manifest_path
|
|
92
|
+
agent_directory.join("imports.yml")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def default_library_root
|
|
96
|
+
agent_directory.parent.parent.join("agents_library")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_entry(raw)
|
|
100
|
+
raise Error, "Each import in #{manifest_path} must be a mapping" unless raw.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
kind = raw["kind"].to_s
|
|
103
|
+
kind = "connector" if kind == "plugin"
|
|
104
|
+
slug = normalize_slug(raw["slug"])
|
|
105
|
+
raise Error, "Unsupported Library kind: #{kind.inspect}" unless KINDS.include?(kind)
|
|
106
|
+
raise Error, "Library import slug is required" if slug.empty?
|
|
107
|
+
|
|
108
|
+
from = raw["from"].to_s
|
|
109
|
+
prefix = "library/"
|
|
110
|
+
raise Error, "Library import #{slug} must use from: library/..." unless from.start_with?(prefix)
|
|
111
|
+
|
|
112
|
+
source_relative = safe_relative(from.delete_prefix(prefix), "source")
|
|
113
|
+
source = library_root.join(source_relative)
|
|
114
|
+
if kind == "connector" && !source.exist? && source_relative.to_s.start_with?("connectors/")
|
|
115
|
+
legacy = source_relative.to_s.sub(/\Aconnectors\//, "plugins/")
|
|
116
|
+
legacy_source = library_root.join(legacy)
|
|
117
|
+
source = legacy_source if legacy_source.exist?
|
|
118
|
+
end
|
|
119
|
+
target_relative = raw["as"].to_s.strip
|
|
120
|
+
target_relative = default_target(kind, slug, source) if target_relative.empty?
|
|
121
|
+
target = safe_relative(target_relative, "target")
|
|
122
|
+
|
|
123
|
+
Entry.new(kind: kind, slug: slug, source: source, target: target)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize_slug(value)
|
|
127
|
+
value.to_s.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_|_\z/, "")[0, 64]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def safe_relative(value, label)
|
|
131
|
+
path = Pathname.new(value)
|
|
132
|
+
clean = path.cleanpath
|
|
133
|
+
if path.absolute? || clean.to_s == ".." || clean.to_s.start_with?("../")
|
|
134
|
+
raise Error, "Library import #{label} must stay inside its root"
|
|
135
|
+
end
|
|
136
|
+
clean
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def default_target(kind, slug, source)
|
|
140
|
+
extension = source.extname
|
|
141
|
+
extension = EXTENSIONS.fetch(kind) if extension.empty?
|
|
142
|
+
case kind
|
|
143
|
+
when "knowledge"
|
|
144
|
+
"knowledge/#{slug}#{extension}"
|
|
145
|
+
when "package"
|
|
146
|
+
"packages/#{slug}/package.yml"
|
|
147
|
+
when "connector", "plugin"
|
|
148
|
+
"connectors/#{slug}#{extension}"
|
|
149
|
+
else
|
|
150
|
+
"#{kind}s/#{slug}#{extension}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def cloud_path(target)
|
|
155
|
+
root = application_root
|
|
156
|
+
agent_directory.join(target).relative_path_from(root).to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def application_root
|
|
160
|
+
agent_directory.parent.parent.parent
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module RailsAgents
|
|
6
|
+
module LocalAuth
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def local_return_url
|
|
12
|
+
port = (request.port.presence || 3000).to_s
|
|
13
|
+
return nil unless %w[localhost 127.0.0.1].include?(request.host)
|
|
14
|
+
|
|
15
|
+
"http://127.0.0.1:#{port}#{rails_agents.connect_path}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def github_oauth_url
|
|
19
|
+
cloud = RailsAgents.config.dashboard_base.to_s.chomp("/")
|
|
20
|
+
cloud = "https://cloud.rails-agent.com" if cloud.blank?
|
|
21
|
+
return_to = "/dashboard"
|
|
22
|
+
url = "#{cloud}/api/v1/auth/github?return_to=#{ERB::Util.url_encode(return_to)}"
|
|
23
|
+
lr = local_return_url
|
|
24
|
+
url = "#{url}&local_return=#{ERB::Util.url_encode(lr)}" if lr
|
|
25
|
+
url
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def complete_cloud_auth!(response)
|
|
29
|
+
data = response.is_a?(Hash) ? response : {}
|
|
30
|
+
api_key =
|
|
31
|
+
data.dig("apiKey", "token") ||
|
|
32
|
+
(data["apiKey"].is_a?(String) ? data["apiKey"] : nil) ||
|
|
33
|
+
data["api_key"]
|
|
34
|
+
project_id = data.dig("project", "id") || data["projectId"] || data["project_id"]
|
|
35
|
+
embed_token = data["embedToken"] || data["embed_token"]
|
|
36
|
+
|
|
37
|
+
raise Client::Error.new("No API key returned from Cloud") if api_key.blank?
|
|
38
|
+
|
|
39
|
+
CredentialsWriter.write!(api_key: api_key, project_id: project_id)
|
|
40
|
+
RailsAgents.configure do |config|
|
|
41
|
+
config.api_key = api_key
|
|
42
|
+
config.project_id = project_id if project_id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Keep JWT out of the browser address bar — stash for the next dashboard render.
|
|
46
|
+
session[:ra_embed_token] = embed_token if embed_token.present?
|
|
47
|
+
redirect_to rails_agents.dashboard_path, notice: "Connected. Welcome to Rails Agent."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def redirect_if_configured!
|
|
51
|
+
return unless configured?
|
|
52
|
+
|
|
53
|
+
redirect_to rails_agents.dashboard_path
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module RailsAgents
|
|
7
|
+
# Pulls agent files from Rails Agent Cloud and writes them under app/agents/<slug>/.
|
|
8
|
+
#
|
|
9
|
+
# @deprecated Cloud-to-local authoring pull. Build agents locally (see AGENTS.md) and use
|
|
10
|
+
# `rails-agents sync` to push files to the cloud. This class remains for CLI compatibility.
|
|
11
|
+
class LocalSync
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
def initialize(client: Client.new, root: nil)
|
|
15
|
+
@client = client
|
|
16
|
+
@root = Pathname.new(root || (defined?(Rails) ? Rails.root : Dir.pwd))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# agent_id: cloud agent id (agt_…) or slug/name accepted by the API
|
|
20
|
+
def pull!(agent_id)
|
|
21
|
+
raise Error, "Not connected — run handshake or set RAILS_AGENTS_API_KEY" unless RailsAgents.config.configured?
|
|
22
|
+
|
|
23
|
+
agent = fetch_agent(agent_id)
|
|
24
|
+
slug = present(agent["slug"]) || slugify(agent["name"]) || slugify(agent_id)
|
|
25
|
+
raise Error, "Could not resolve agent slug for #{agent_id}" unless present(slug)
|
|
26
|
+
|
|
27
|
+
files_payload = @client.list_files(agent: agent["id"] || agent_id)
|
|
28
|
+
files = normalize_files_list(files_payload)
|
|
29
|
+
written = []
|
|
30
|
+
deleted = []
|
|
31
|
+
|
|
32
|
+
agent_dir = @root.join("app/agents", slug)
|
|
33
|
+
FileUtils.mkdir_p(agent_dir)
|
|
34
|
+
|
|
35
|
+
files.each do |file|
|
|
36
|
+
rel = normalize_relative_path(file["path"] || file[:path], slug)
|
|
37
|
+
abs = @root.join(rel)
|
|
38
|
+
ensure_inside_agents!(abs, slug)
|
|
39
|
+
|
|
40
|
+
content = file["content"] || file[:content]
|
|
41
|
+
if content.nil?
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
FileUtils.mkdir_p(abs.dirname)
|
|
46
|
+
File.write(abs, content)
|
|
47
|
+
written << rel.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
"ok" => true,
|
|
52
|
+
"agent_id" => agent["id"] || agent_id,
|
|
53
|
+
"slug" => slug,
|
|
54
|
+
"written" => written,
|
|
55
|
+
"deleted" => deleted,
|
|
56
|
+
"count" => written.size
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def fetch_agent(agent_id)
|
|
63
|
+
response = @client.get_agent(agent: agent_id)
|
|
64
|
+
data = response.is_a?(Hash) ? (response["data"] || response) : {}
|
|
65
|
+
data = data["agent"] if data.is_a?(Hash) && data["agent"].is_a?(Hash)
|
|
66
|
+
raise Error, "Agent not found: #{agent_id}" unless data.is_a?(Hash) && present(data["id"] || data["slug"] || data["name"])
|
|
67
|
+
|
|
68
|
+
data
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_files_list(payload)
|
|
72
|
+
return [] if payload.nil?
|
|
73
|
+
|
|
74
|
+
if payload.is_a?(Array)
|
|
75
|
+
payload
|
|
76
|
+
elsif payload.is_a?(Hash)
|
|
77
|
+
payload["data"] || payload["files"] || []
|
|
78
|
+
else
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_relative_path(path, slug)
|
|
84
|
+
raw = path.to_s.sub(%r{\A/+}, "")
|
|
85
|
+
raise Error, "Invalid path" if raw.empty? || raw.include?("..")
|
|
86
|
+
|
|
87
|
+
if raw.start_with?("app/agents/")
|
|
88
|
+
raw
|
|
89
|
+
elsif raw.start_with?("#{slug}/")
|
|
90
|
+
"app/agents/#{raw}"
|
|
91
|
+
else
|
|
92
|
+
"app/agents/#{slug}/#{raw}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ensure_inside_agents!(abs_path, slug)
|
|
97
|
+
agents_root = @root.join("app/agents", slug).expand_path
|
|
98
|
+
expanded = abs_path.expand_path
|
|
99
|
+
return if expanded.to_s.start_with?(agents_root.to_s + File::SEPARATOR) || expanded == agents_root
|
|
100
|
+
|
|
101
|
+
raise Error, "Refusing to write outside app/agents/#{slug}: #{abs_path}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def present(value)
|
|
105
|
+
str = value.to_s.strip
|
|
106
|
+
str.empty? ? nil : str
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def slugify(value)
|
|
110
|
+
present(value.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, ""))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|