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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/README.md +16 -0
  3. data/.github/workflows/ci.yml +32 -0
  4. data/.gitignore +8 -0
  5. data/.rspec +3 -0
  6. data/AGENTS.md +18 -0
  7. data/CHANGELOG.md +40 -0
  8. data/Gemfile +15 -0
  9. data/MIT-LICENSE +21 -0
  10. data/README.md +228 -0
  11. data/Rakefile +8 -0
  12. data/app/assets/images/rails_agents/kip-logo.png +0 -0
  13. data/app/assets/images/rails_agents/kip-waving.png +0 -0
  14. data/app/assets/stylesheets/rails_agents/application.css +411 -0
  15. data/app/controllers/rails_agents/application_controller.rb +25 -0
  16. data/app/controllers/rails_agents/connect_controller.rb +36 -0
  17. data/app/controllers/rails_agents/dashboard_controller.rb +70 -0
  18. data/app/controllers/rails_agents/handshake_controller.rb +55 -0
  19. data/app/controllers/rails_agents/open_wire_inbound_controller.rb +153 -0
  20. data/app/controllers/rails_agents/pulls_controller.rb +42 -0
  21. data/app/controllers/rails_agents/registrations_controller.rb +96 -0
  22. data/app/controllers/rails_agents/schema_controller.rb +123 -0
  23. data/app/controllers/rails_agents/sessions_controller.rb +76 -0
  24. data/app/controllers/rails_agents/tool_bridge_controller.rb +67 -0
  25. data/app/views/layouts/rails_agents/application.html.erb +18 -0
  26. data/app/views/rails_agents/dashboard/show.html.erb +139 -0
  27. data/app/views/rails_agents/registrations/new.html.erb +83 -0
  28. data/app/views/rails_agents/sessions/new.html.erb +73 -0
  29. data/app/views/rails_agents/shared/_auth_brand.html.erb +20 -0
  30. data/config/routes.rb +34 -0
  31. data/docs/AGENTS.md +220 -0
  32. data/docs/PRD.md +89 -0
  33. data/docs/assets/hero-banner.png +0 -0
  34. data/examples/support/agent.rb +14 -0
  35. data/examples/support/channels/.keep +0 -0
  36. data/examples/support/evals/smoke.yml +4 -0
  37. data/examples/support/knowledge/shipping.md +1 -0
  38. data/examples/support/memory.rb +3 -0
  39. data/examples/support/prompt.md +9 -0
  40. data/examples/support/skills/.keep +0 -0
  41. data/examples/support/tools/.keep +0 -0
  42. data/exe/rails-agents +6 -0
  43. data/lib/generators/rails_agents/agent/agent_generator.rb +80 -0
  44. data/lib/generators/rails_agents/agent/templates/agent.rb.tt +14 -0
  45. data/lib/generators/rails_agents/agent/templates/agent_database.rb.tt +42 -0
  46. data/lib/generators/rails_agents/agent/templates/channels/slack.rb.tt +8 -0
  47. data/lib/generators/rails_agents/agent/templates/evals/smoke.yml.tt +4 -0
  48. data/lib/generators/rails_agents/agent/templates/imports.yml.tt +9 -0
  49. data/lib/generators/rails_agents/agent/templates/memory.rb.tt +6 -0
  50. data/lib/generators/rails_agents/agent/templates/prompt.md.tt +10 -0
  51. data/lib/generators/rails_agents/agent/templates/prompt_database.md.tt +14 -0
  52. data/lib/generators/rails_agents/install/INSTALL +10 -0
  53. data/lib/generators/rails_agents/install/install_generator.rb +72 -0
  54. data/lib/generators/rails_agents/install/templates/AGENTS.md.tt +46 -0
  55. data/lib/generators/rails_agents/install/templates/initializer.rb.tt +8 -0
  56. data/lib/generators/rails_agents/install/templates/library_README.md.tt +28 -0
  57. data/lib/generators/rails_agents/install/templates/library_manifest.yml.tt +5 -0
  58. data/lib/generators/rails_agents/install/templates/rails_agents_autoload.rb.tt +10 -0
  59. data/lib/rails-agent-stack.rb +4 -0
  60. data/lib/rails_agents/background_agent.rb +15 -0
  61. data/lib/rails_agents/base.rb +294 -0
  62. data/lib/rails_agents/chat_agent.rb +15 -0
  63. data/lib/rails_agents/cli.rb +245 -0
  64. data/lib/rails_agents/client.rb +216 -0
  65. data/lib/rails_agents/configuration.rb +41 -0
  66. data/lib/rails_agents/creation_agent.rb +10 -0
  67. data/lib/rails_agents/credentials_writer.rb +38 -0
  68. data/lib/rails_agents/engine.rb +19 -0
  69. data/lib/rails_agents/knowledge_agent.rb +10 -0
  70. data/lib/rails_agents/library_imports.rb +163 -0
  71. data/lib/rails_agents/local_auth.rb +56 -0
  72. data/lib/rails_agents/local_sync.rb +113 -0
  73. data/lib/rails_agents/monitoring_agent.rb +10 -0
  74. data/lib/rails_agents/open_wire_adapter.rb +31 -0
  75. data/lib/rails_agents/operations_agent.rb +10 -0
  76. data/lib/rails_agents/railtie.rb +14 -0
  77. data/lib/rails_agents/tasks.rake +8 -0
  78. data/lib/rails_agents/version.rb +5 -0
  79. data/lib/rails_agents/workflow_agent.rb +11 -0
  80. data/lib/rails_agents.rb +57 -0
  81. data/rails-agent-stack.gemspec +49 -0
  82. data/rails-agents.code-workspace +25 -0
  83. data/spec/generators/agent_generator_spec.rb +36 -0
  84. data/spec/rails_agents/base_spec.rb +202 -0
  85. data/spec/rails_agents/client_spec.rb +121 -0
  86. data/spec/rails_agents/configuration_spec.rb +55 -0
  87. data/spec/rails_agents/library_imports_spec.rb +80 -0
  88. data/spec/rails_agents/local_sync_spec.rb +63 -0
  89. data/spec/rails_agents/taxonomy_spec.rb +52 -0
  90. data/spec/spec_helper.rb +27 -0
  91. 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgents
4
+ # Legacy compatibility class. New output-producing agents should use WorkflowAgent.
5
+ class CreationAgent < Base
6
+ def self.agent_kind
7
+ :creation
8
+ end
9
+ end
10
+ 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgents
4
+ # Answers questions from your database, docs, and knowledge files (RAG).
5
+ class KnowledgeAgent < Base
6
+ def self.agent_kind
7
+ :knowledge
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgents
4
+ # Watches runs, traces, evals, and alerts on anomalies or SLA breaches.
5
+ class MonitoringAgent < Base
6
+ def self.agent_kind
7
+ :monitoring
8
+ end
9
+ end
10
+ end