ask-rails 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '081f609a014a72157291794a40c580ed6fabb625c2db0c7511538acae540ea9b'
4
- data.tar.gz: 1445552073a10f1fbae78f63d7220c894efbcb1cd6433f7aef8f977e4374b1e6
3
+ metadata.gz: e69600ee0def0363ad6aa80623bb2e6c160a5991bb8acf680a18d05eda8c9544
4
+ data.tar.gz: 558d9750d94d02b7de5ab05fcfe8edef68c33f28c6f5a7adf4e0e659856cc0d0
5
5
  SHA512:
6
- metadata.gz: c0fcb6d2a6f53c8fa89dafd2ce2d6f3393c305a0df0cbfe67472798a9973032645e81ccec5517a855bba25e90572ac5c1100ba4d73b5e98e70ab329629e1ad99
7
- data.tar.gz: 5227ec0290f2177f844922038439c99b7bfd8d0d35360b7256206825f220b0323d8d139117000169336f50242191b200f0896a79379d8769996aede9b2f12d36
6
+ metadata.gz: 40bc9bef781a02d9e020772846393ab0dcce0980b236739f532c87c06a7d7fd2fa24a13118d248a378236a2efc841cae130d61bcfc45230a02aac434626f743e
7
+ data.tar.gz: 3916e3a64be0401e75ff9a00a6febc537aa4d930db9696da1491f18aa0e99d89674aea97b778d1a0c2ca3fe48199e35ba49ff63a11e812d501ddb12590cd173c
@@ -4,39 +4,31 @@ module Ask
4
4
  module Rails
5
5
  class Persistence
6
6
  def initialize(model_class: nil)
7
- @model_class = model_class || default_model_class
7
+ @model_class = model_class
8
8
  end
9
9
 
10
10
  def save(session_id, data)
11
- record = @model_class.find_or_initialize_by(session_id: session_id)
11
+ record = model_class.find_or_initialize_by(session_id: session_id)
12
12
  record.update!(data: data)
13
13
  end
14
14
 
15
15
  def load(session_id)
16
- record = @model_class.find_by(session_id: session_id)
16
+ record = model_class.find_by(session_id: session_id)
17
17
  record&.data
18
18
  end
19
19
 
20
20
  def delete(session_id)
21
- @model_class.where(session_id: session_id).delete_all
21
+ model_class.where(session_id: session_id).delete_all
22
22
  end
23
23
 
24
24
  def list
25
- @model_class.pluck(:session_id)
25
+ model_class.pluck(:session_id)
26
26
  end
27
27
 
28
28
  private
29
29
 
30
- def default_model_class
31
- # Lazy reference so the model class doesn't need to exist at load time
32
- ask_session_model = Class.new(::ActiveRecord::Base) do
33
- self.table_name = "ask_sessions"
34
- end
35
- # Store it as a constant so it's reusable
36
- unless Object.const_defined?(:AskSession)
37
- Object.const_set(:AskSession, ask_session_model)
38
- end
39
- AskSession
30
+ def model_class
31
+ @model_class || (raise "No model class configured. Use Persistence.new(model_class: MyModel)")
40
32
  end
41
33
  end
42
34
  end
@@ -14,10 +14,9 @@ module Ask
14
14
  service_gems.each do |name|
15
15
  begin
16
16
  require "#{name.tr("-", "/")}/context"
17
- mod = name.camelize.constantize
18
- contexts << mod if mod.const_defined?(:DESCRIPTION)
17
+ mod = name.split("-").map(&:capitalize).join.constantize
18
+ contexts << mod if mod.respond_to?(:const_defined?) && mod.const_defined?(:DESCRIPTION)
19
19
  rescue LoadError, NameError
20
- # No context module for this gem
21
20
  end
22
21
  end
23
22
 
@@ -34,7 +33,8 @@ module Ask
34
33
  sections = ["## Available Services"]
35
34
 
36
35
  contexts.each do |mod|
37
- sections << "### #{mod.name.demodulize}"
36
+ name = mod.respond_to?(:name) ? mod.name.to_s.split("::").last || "Unknown" : "Unknown"
37
+ sections << "### #{name}"
38
38
  sections << mod::DESCRIPTION if mod.const_defined?(:DESCRIPTION)
39
39
  sections << "Documentation: #{mod::DOCS_URL}" if mod.const_defined?(:DOCS_URL)
40
40
  sections << "Authentication: #{mod::AUTH_HOW}" if mod.const_defined?(:AUTH_HOW)
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Tools
6
+ class QueryDatabase < Ask::Rails::Tool
7
+ description "Run a read-only SQL query against the application database. " \
8
+ "Returns columns and rows. Only SELECT queries are allowed in production."
9
+
10
+ param :sql, type: :string, desc: "SQL query (SELECT only in production)", required: true
11
+ param :limit, type: :integer, desc: "Max rows to return (default 50)", required: false
12
+
13
+ WRITE_STATEMENTS = /\A\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i
14
+
15
+ def execute(sql:, limit: 50)
16
+ sql = sql.strip
17
+
18
+ if WRITE_STATEMENTS.match?(sql)
19
+ return Ask::Result.failure(
20
+ "Only SELECT queries are allowed. Write statements (#{sql.match(WRITE_STATEMENTS)[1]}) are rejected in all environments."
21
+ )
22
+ end
23
+
24
+ if Rails.env.production? && !sql.match?(/\A\s*SELECT\b/i)
25
+ return Ask::Result.failure(
26
+ "Only SELECT queries are allowed in the production environment."
27
+ )
28
+ end
29
+
30
+ pool = ActiveRecord::Base.connection_pool
31
+ pool.with_connection do |conn|
32
+ limited_sql = sql.match?(/\bLIMIT\b/i) ? sql : "#{sql.chomp(';')} LIMIT #{limit.to_i}"
33
+ result = conn.exec_query(limited_sql)
34
+ columns = result.columns
35
+ rows = result.rows.first(limit.to_i).map { |row| build_row(row, columns) }
36
+ {
37
+ columns: columns,
38
+ rows: rows,
39
+ count: rows.size,
40
+ truncated: result.rows.size > limit.to_i
41
+ }
42
+ end
43
+ rescue ActiveRecord::StatementInvalid => e
44
+ Ask::Result.failure("SQL error: #{e.message}")
45
+ rescue ActiveRecord::ConnectionNotEstablished => e
46
+ Ask::Result.failure("Database not connected: #{e.message}. Verify the database is running and Rails is connected.")
47
+ end
48
+
49
+ private
50
+
51
+ def build_row(row, columns)
52
+ columns.each_with_index.each_with_object({}) do |(col, i), hash|
53
+ value = row[i]
54
+ hash[col] = sanitize_value(value)
55
+ end
56
+ end
57
+
58
+ def sanitize_value(value)
59
+ return "[BINARY DATA]" if binary_value?(value)
60
+ return value.iso8601 if value.respond_to?(:iso8601)
61
+ value
62
+ end
63
+
64
+ def binary_value?(value)
65
+ value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT && value.bytesize > 0
66
+ rescue
67
+ false
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Tools
6
+ class ReadLog < Ask::Rails::Tool
7
+ description "Read application log files with filtering. Supports Rails default " \
8
+ "logger and log rotation. Reads from the end of the file (most recent first)."
9
+
10
+ param :lines, type: :integer, desc: "Number of recent lines (default 50, max 500)", required: false
11
+ param :level, type: :string, desc: "Filter by level: ERROR, WARN, INFO, DEBUG", required: false
12
+ param :search, type: :string, desc: "Search term (plain text, case-insensitive)", required: false
13
+ param :file, type: :string, desc: "Log file name (default: log/<env>.log)", required: false
14
+
15
+ MAX_LINES = 500
16
+ LEVEL_PATTERNS = {
17
+ "ERROR" => /\bERROR\b/,
18
+ "WARN" => /\bWARN\b/,
19
+ "INFO" => /\bINFO\b/,
20
+ "DEBUG" => /\bDEBUG\b/
21
+ }.freeze
22
+
23
+ def execute(lines: 50, level: nil, search: nil, file: nil)
24
+ lines = [lines.to_i, MAX_LINES].min
25
+ log_path = resolve_log_path(file)
26
+
27
+ unless log_path.exist?
28
+ return Ask::Result.failure(
29
+ "Log file not found: #{log_path}. The application may not have written any logs yet."
30
+ )
31
+ end
32
+
33
+ content = read_tail(log_path, MAX_LINES * 2)
34
+ return { lines: [], total_lines: 0, path: log_path.to_s } if content.empty?
35
+
36
+ raw_lines = content.lines
37
+
38
+ filtered = apply_filters(raw_lines, level: level, search: search)
39
+ recent = filtered.last(lines).map(&:chomp)
40
+
41
+ {
42
+ lines: recent,
43
+ total_lines: raw_lines.size,
44
+ matched_lines: filtered.size,
45
+ path: log_path.to_s,
46
+ filters_applied: { level: level, search: search }.compact
47
+ }
48
+ end
49
+
50
+ private
51
+
52
+ def resolve_log_path(custom_path)
53
+ return rails_root.join(custom_path) if custom_path
54
+ rails_root.join("log", "#{Rails.env}.log")
55
+ end
56
+
57
+ def read_tail(path, max_bytes)
58
+ return "" unless path.exist?
59
+
60
+ size = path.size
61
+ return path.read if size <= max_bytes
62
+
63
+ File.open(path, "rb") do |f|
64
+ f.seek(-max_bytes, IO::SEEK_END)
65
+ partial = f.read(max_bytes)
66
+ if (idx = partial.index("\n"))
67
+ partial[idx + 1..]
68
+ else
69
+ partial
70
+ end
71
+ end
72
+ end
73
+
74
+ def apply_filters(lines, level: nil, search: nil)
75
+ filtered = lines
76
+ filtered = filtered.select { |l| LEVEL_PATTERNS.fetch(level) { |_| // }.match?(l) } if level
77
+ filtered = filtered.select { |l| l.downcase.include?(search.downcase) } if search
78
+ filtered
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Rails
5
+ module Tools
6
+ class ReadModel < Ask::Rails::Tool
7
+ description "Inspect an ActiveRecord model — columns, associations, validations, " \
8
+ "scopes, and indexes. Returns structured data the agent can act on."
9
+
10
+ param :name, type: :string, desc: "Model class name (e.g. 'User', 'Blog::Post')", required: true
11
+ param :detail, type: :string, desc: "Which details: 'all' (default), 'columns', 'associations', 'validations'", required: false
12
+
13
+ def execute(name:, detail: "all")
14
+ klass = safe_constantize(name)
15
+ return Ask::Result.failure("Model '#{name}' not found or is not an ActiveRecord model.") unless klass
16
+
17
+ result = { name: klass.name, table_name: klass.table_name }
18
+
19
+ result[:primary_key] = klass.primary_key if klass.respond_to?(:primary_key)
20
+
21
+ if %w[all columns].include?(detail)
22
+ result[:columns] = klass.columns.map { |c|
23
+ entry = { name: c.name, type: c.type, null: c.null, default: c.default }
24
+ entry[:primary_key] = true if c.name == klass.primary_key
25
+ entry
26
+ }
27
+ end
28
+
29
+ if %w[all associations].include?(detail)
30
+ result[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
31
+ refs.map { |a|
32
+ entry = { name: a.name, class_name: a.class_name }
33
+ entry[:through] = a.options[:through] if a.options[:through]
34
+ entry[:source] = a.options[:source] if a.options[:source]
35
+ entry[:foreign_key] = a.foreign_key if a.respond_to?(:foreign_key)
36
+ entry
37
+ }
38
+ }
39
+ end
40
+
41
+ if %w[all validations].include?(detail)
42
+ result[:validators] = klass.validators.map { |v|
43
+ {
44
+ attribute: v.attributes.first&.to_s,
45
+ kind: v.kind,
46
+ options: v.options.reject { |k, _| k == :if }
47
+ }
48
+ }.reject { |v| v[:attribute].nil? }
49
+ end
50
+
51
+ result
52
+ end
53
+
54
+ private
55
+
56
+ def safe_constantize(name)
57
+ klass = name.safe_constantize
58
+ return nil unless klass
59
+ return nil unless klass < ActiveRecord::Base
60
+ klass
61
+ rescue
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Rails
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/ask/rails.rb CHANGED
@@ -74,3 +74,6 @@ require_relative "rails/tools/read_file"
74
74
  require_relative "rails/tools/run_command"
75
75
  require_relative "rails/tools/search_codebase"
76
76
  require_relative "rails/tools/read_routes"
77
+ require_relative "rails/tools/query_database"
78
+ require_relative "rails/tools/read_model"
79
+ require_relative "rails/tools/read_log"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -152,7 +152,10 @@ files:
152
152
  - lib/ask/rails/railtie.rb
153
153
  - lib/ask/rails/service_discovery.rb
154
154
  - lib/ask/rails/tool.rb
155
+ - lib/ask/rails/tools/query_database.rb
155
156
  - lib/ask/rails/tools/read_file.rb
157
+ - lib/ask/rails/tools/read_log.rb
158
+ - lib/ask/rails/tools/read_model.rb
156
159
  - lib/ask/rails/tools/read_routes.rb
157
160
  - lib/ask/rails/tools/run_command.rb
158
161
  - lib/ask/rails/tools/search_codebase.rb