talk_to_your_app 0.1.0.pre.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +402 -0
  4. data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +39 -0
  5. data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +18 -0
  6. data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +31 -0
  7. data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +12 -0
  8. data/lib/generators/talk_to_your_app/install/install_generator.rb +27 -0
  9. data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +78 -0
  10. data/lib/talk_to_your_app/audit_logger.rb +115 -0
  11. data/lib/talk_to_your_app/auth/api_key.rb +29 -0
  12. data/lib/talk_to_your_app/auth/basic.rb +24 -0
  13. data/lib/talk_to_your_app/auth/middleware.rb +74 -0
  14. data/lib/talk_to_your_app/configuration.rb +129 -0
  15. data/lib/talk_to_your_app/connection_registry.rb +131 -0
  16. data/lib/talk_to_your_app/current.rb +14 -0
  17. data/lib/talk_to_your_app/custom_tool.rb +40 -0
  18. data/lib/talk_to_your_app/plugin.rb +59 -0
  19. data/lib/talk_to_your_app/plugin_registry.rb +48 -0
  20. data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +26 -0
  21. data/lib/talk_to_your_app/plugins/db/plugin.rb +57 -0
  22. data/lib/talk_to_your_app/plugins/db/tools/query.rb +126 -0
  23. data/lib/talk_to_your_app/plugins/db/tools/schema.rb +60 -0
  24. data/lib/talk_to_your_app/plugins/db/tools/tables.rb +28 -0
  25. data/lib/talk_to_your_app/plugins/flipper/plugin.rb +132 -0
  26. data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +41 -0
  27. data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +42 -0
  28. data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +41 -0
  29. data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +23 -0
  30. data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +33 -0
  31. data/lib/talk_to_your_app/plugins/health/plugin.rb +31 -0
  32. data/lib/talk_to_your_app/plugins/health/registry.rb +68 -0
  33. data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +24 -0
  34. data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +27 -0
  35. data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +122 -0
  36. data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +90 -0
  37. data/lib/talk_to_your_app/plugins/jobs/interface.rb +38 -0
  38. data/lib/talk_to_your_app/plugins/jobs/plugin.rb +87 -0
  39. data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +28 -0
  40. data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +25 -0
  41. data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +23 -0
  42. data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +30 -0
  43. data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +28 -0
  44. data/lib/talk_to_your_app/plugins/rake/plugin.rb +42 -0
  45. data/lib/talk_to_your_app/plugins/rake/tools/run.rb +56 -0
  46. data/lib/talk_to_your_app/railtie.rb +56 -0
  47. data/lib/talk_to_your_app/renderers/html_table.rb +27 -0
  48. data/lib/talk_to_your_app/tool.rb +204 -0
  49. data/lib/talk_to_your_app/transport/rails_mount.rb +46 -0
  50. data/lib/talk_to_your_app/version.rb +5 -0
  51. data/lib/talk_to_your_app.rb +124 -0
  52. metadata +140 -0
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "../../custom_tool"
5
+
6
+ module TalkToYourApp
7
+ module Plugins
8
+ module CustomTools
9
+ # Exposes every TalkToYourApp::CustomTool subclass over MCP. Unlike the
10
+ # bundled plugins, its tool list is dynamic — whatever subclasses of
11
+ # CustomTool the host app has defined. Tools generated by
12
+ # `rails g talk_to_your_app:custom_tool` live in
13
+ # app/talk_to_your_app/custom_tools/ and are loaded automatically here.
14
+ class Plugin < TalkToYourApp::Plugin
15
+ def self.tools
16
+ TalkToYourApp.require_app_dir("custom_tools")
17
+ # Dedup by MCP name (last wins) in case two files declare the same
18
+ # tool_name, or a class is registered more than once in a process.
19
+ TalkToYourApp::CustomTool.registry.each_with_object({}) { |t, acc| acc[t.tool_name] = t }.values
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ TalkToYourApp.register_plugin(:custom_tools, TalkToYourApp::Plugins::CustomTools::Plugin)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "tools/query"
5
+ require_relative "tools/tables"
6
+ require_relative "tools/schema"
7
+
8
+ module TalkToYourApp
9
+ module Plugins
10
+ module Db
11
+ # Default cap on rows returned by db.query. Override per app with
12
+ # `config.plugin :db, max_rows: 5000`, or disable the cap entirely with
13
+ # `max_rows: nil` (also accepts false or :unlimited).
14
+ DEFAULT_MAX_ROWS = 2000
15
+ UNLIMITED = [nil, false, :unlimited].freeze
16
+
17
+ module_function
18
+
19
+ # Returns the row cap as an Integer, or nil for "no cap".
20
+ def max_rows
21
+ options = TalkToYourApp.configuration.enabled_plugins[:db] || {}
22
+ return DEFAULT_MAX_ROWS unless options.key?(:max_rows)
23
+
24
+ value = options[:max_rows]
25
+ return nil if UNLIMITED.include?(value)
26
+
27
+ Integer(value)
28
+ end
29
+
30
+ # The DB plugin: read-only SQL plus schema introspection, all against a
31
+ # separately-configured read-only connection. Requires the operator to
32
+ # declare a :replica_readonly connection; boot fails otherwise.
33
+ class Plugin < TalkToYourApp::Plugin
34
+ requires_connection :replica_readonly
35
+ tools Tools::Query, Tools::Tables, Tools::Schema
36
+
37
+ # The read-only guarantee rests on the connection being declared with
38
+ # role: :reading (which enables Rails' write prevention). Enforce it at
39
+ # boot so a misconfigured :writing role can't silently expose a writable
40
+ # "read-only" SQL tool. (A missing connection is caught by the
41
+ # ConnectionRegistry validation that runs alongside this.)
42
+ def self.validate_enablement!(_options)
43
+ return unless TalkToYourApp::ConnectionRegistry.registered?(:replica_readonly)
44
+
45
+ spec = TalkToYourApp::ConnectionRegistry.fetch(:replica_readonly)
46
+ return if spec.reading?
47
+
48
+ raise TalkToYourApp::ConfigurationError,
49
+ "talk_to_your_app: the DB plugin's :replica_readonly connection must be declared with " \
50
+ "role: :reading (got role: #{spec.role.inspect}). A read-only tool must not run on a writable connection."
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ TalkToYourApp.register_plugin(:db, TalkToYourApp::Plugins::Db::Plugin)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+ require_relative "../../../connection_registry"
5
+ require_relative "../../../renderers/html_table"
6
+
7
+ module TalkToYourApp
8
+ module Plugins
9
+ module Db
10
+ module Tools
11
+ # Runs a read-only SQL query on the declared :replica_readonly connection
12
+ # and renders the rows as JSON, plain text, or an HTML table. The query
13
+ # runs inside a transaction with a per-query statement timeout, so a slow
14
+ # query cannot poison the connection pool. Writes are rejected by the
15
+ # underlying read-only database role — the gem does not parse SQL.
16
+ class Query < TalkToYourApp::Tool
17
+ DEFAULT_TIMEOUT_MS = 30_000
18
+ CONNECTION = :replica_readonly
19
+
20
+ name "db.query"
21
+ description "Run a read-only SQL query and return the rows."
22
+ connection CONNECTION
23
+ argument :sql, :string, required: true, description: "A read-only SQL statement."
24
+ argument :format, :string, enum: %w[json text html], default: "json",
25
+ description: "Output format for the result rows."
26
+
27
+ def call(args, ctx)
28
+ format = args[:format] || "json"
29
+ result = ctx.connection do |conn|
30
+ conn.transaction do
31
+ apply_statement_timeout(conn, timeout_ms)
32
+ begin
33
+ conn.exec_query(args[:sql])
34
+ ensure
35
+ clear_statement_timeout(conn)
36
+ end
37
+ end
38
+ end
39
+ render(result, format)
40
+ rescue ActiveRecord::QueryCanceled, ActiveRecord::StatementTimeout => e
41
+ error("Query exceeded the #{timeout_ms}ms statement timeout: #{e.message}")
42
+ rescue ActiveRecord::ReadOnlyError => e
43
+ # Rails' connected_to(role: :reading) blocks writes before they reach
44
+ # the database; the read-only DB role is the backstop behind it.
45
+ error("Write rejected: this connection is read-only. #{e.message}")
46
+ rescue ActiveRecord::StatementInvalid => e
47
+ error("Query failed: #{e.message}")
48
+ end
49
+
50
+ private
51
+
52
+ def timeout_ms
53
+ spec = TalkToYourApp::ConnectionRegistry.fetch(CONNECTION)
54
+ spec.statement_timeout || DEFAULT_TIMEOUT_MS
55
+ end
56
+
57
+ # The SQL that arms a per-query timeout for an adapter, or nil when the
58
+ # adapter has no per-statement timeout. Pure (no connection) so it is
59
+ # unit-testable without each database installed.
60
+ # Postgres -> transaction-local `statement_timeout` (unwound with the txn).
61
+ # MySQL -> session `max_execution_time` (ms), bounding read-only SELECTs.
62
+ # SQLite / MariaDB / others -> none (documented in the README).
63
+ def self.timeout_statement(adapter_name, ms)
64
+ case adapter_name
65
+ when /postgres/i then "SET LOCAL statement_timeout = #{ms.to_i}"
66
+ when /mysql|trilogy/i then "SET SESSION max_execution_time = #{ms.to_i}"
67
+ end
68
+ end
69
+
70
+ def apply_statement_timeout(conn, ms)
71
+ sql = self.class.timeout_statement(conn.adapter_name, ms)
72
+ return unless sql
73
+
74
+ conn.execute(sql)
75
+ rescue ActiveRecord::StatementInvalid
76
+ # The server does not support this timeout mechanism (e.g. MariaDB has
77
+ # no `max_execution_time`). Run without a per-statement timeout rather
78
+ # than failing every query; the read-only role still applies.
79
+ nil
80
+ end
81
+
82
+ # Postgres' SET LOCAL unwinds with the transaction, but MySQL's session
83
+ # variable persists on the pooled connection — clear it so a later
84
+ # borrower of the reader connection is not capped by a stale value.
85
+ def clear_statement_timeout(conn)
86
+ return unless conn.adapter_name.match?(/mysql|trilogy/i)
87
+
88
+ conn.execute("SET SESSION max_execution_time = 0")
89
+ rescue ActiveRecord::StatementInvalid
90
+ nil
91
+ end
92
+
93
+ def render(result, format)
94
+ columns = result.columns
95
+ max = TalkToYourApp::Plugins::Db.max_rows # nil means no cap
96
+ truncated = max && result.rows.length > max
97
+ rows = truncated ? result.rows.first(max) : result.rows
98
+
99
+ case format
100
+ when "html"
101
+ html = Renderers::HtmlTable.render(columns, rows)
102
+ text(truncated ? "#{html}<p>truncated to #{max} rows</p>" : html)
103
+ when "text"
104
+ body = render_text(columns, rows)
105
+ text(truncated ? "#{body}\n(truncated to #{max} rows)" : body)
106
+ else
107
+ payload = { columns: columns, rows: rows }
108
+ payload.merge!(truncated: true, max_rows: max) if truncated
109
+ text(JSON.generate(payload))
110
+ end
111
+ end
112
+
113
+ def render_text(columns, rows)
114
+ display = rows.map { |row| row.map { |cell| cell.nil? ? "NULL" : cell.to_s } }
115
+ widths = columns.each_index.map do |i|
116
+ ([columns[i].to_s] + display.map { |r| r[i].to_s }).map(&:length).max
117
+ end
118
+ header = columns.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(" ")
119
+ body = display.map { |r| r.each_with_index.map { |c, i| c.ljust(widths[i]) }.join(" ") }
120
+ ([header] + body).join("\n")
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Db
8
+ module Tools
9
+ # Describes a single table from the live read-only database: its columns,
10
+ # primary key, indexes, and foreign keys. Lets a client learn a table's
11
+ # shape without a schema dump.
12
+ class Schema < TalkToYourApp::Tool
13
+ CONNECTION = :replica_readonly
14
+
15
+ name "db.schema"
16
+ description "Describe a table: columns, primary key, indexes, and foreign keys."
17
+ connection CONNECTION
18
+ argument :table, :string, required: true, description: "Table name (see db.tables)."
19
+
20
+ def call(args, ctx)
21
+ table = args[:table].to_s
22
+ ctx.connection do |conn|
23
+ unless conn.tables.include?(table)
24
+ next error("Unknown table #{table.inspect}. Use db.tables to list available tables.")
25
+ end
26
+
27
+ json(describe(conn, table))
28
+ end
29
+ rescue ActiveRecord::ActiveRecordError => e
30
+ error("Could not describe #{args[:table].inspect}: #{e.message}")
31
+ end
32
+
33
+ private
34
+
35
+ def describe(conn, table)
36
+ {
37
+ table: table,
38
+ primary_key: conn.primary_key(table),
39
+ columns: conn.columns(table).map do |col|
40
+ { name: col.name, type: col.sql_type, null: col.null, default: col.default }
41
+ end,
42
+ indexes: conn.indexes(table).map do |idx|
43
+ { name: idx.name, columns: idx.columns, unique: idx.unique }
44
+ end,
45
+ foreign_keys: foreign_keys(conn, table),
46
+ }
47
+ end
48
+
49
+ def foreign_keys(conn, table)
50
+ return [] unless conn.supports_foreign_keys?
51
+
52
+ conn.foreign_keys(table).map do |fk|
53
+ { column: fk.column, references_table: fk.to_table, references_column: fk.primary_key }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Db
8
+ module Tools
9
+ # Lists the table names in the read-only database, so a client can
10
+ # discover the schema before querying.
11
+ class Tables < TalkToYourApp::Tool
12
+ CONNECTION = :replica_readonly
13
+
14
+ name "db.tables"
15
+ description "List the table names in the read-only database."
16
+ connection CONNECTION
17
+
18
+ def call(_args, ctx)
19
+ tables = ctx.connection { |conn| conn.tables.sort }
20
+ json(tables: tables)
21
+ rescue ActiveRecord::ActiveRecordError => e
22
+ error("Could not list tables: #{e.message}")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "tools/list_flags"
5
+ require_relative "tools/read_flag"
6
+ require_relative "tools/enable_flag"
7
+ require_relative "tools/disable_flag"
8
+ require_relative "tools/enabled_flags"
9
+
10
+ module TalkToYourApp
11
+ module Plugins
12
+ # The Flipper plugin: list flags, read a flag's state (globally or for an
13
+ # actor), and enable/disable flags globally or per actor. All operations run
14
+ # through the writer-capable :flipper_writer connection — deliberately
15
+ # separate from the DB plugin's read-only one.
16
+ module Flipper
17
+ # A minimal actor: Flipper only needs an object responding to #flipper_id.
18
+ # We reconstitute one from a class name + id rather than loading the host's
19
+ # real record, which avoids coupling to the host's user model and any
20
+ # privilege leak from instantiating it.
21
+ Actor = Struct.new(:flipper_id)
22
+
23
+ module_function
24
+
25
+ # Builds an actor from a class name + id, or nil when none was supplied.
26
+ def actor_for(actor_class, actor_id)
27
+ return nil if actor_class.nil? || actor_id.nil?
28
+
29
+ Actor.new("#{actor_class};#{actor_id}")
30
+ end
31
+
32
+ # True when a call names more than one gate dimension (actor, group,
33
+ # percentage). gate_from would silently pick one by precedence and drop the
34
+ # rest, so the tools reject the call instead.
35
+ def gate_conflict?(args)
36
+ selectors = []
37
+ selectors << :actor if args[:actor_class] && args[:actor_id]
38
+ selectors << :group if args[:group]
39
+ selectors << :percentage unless args[:percentage].nil?
40
+ selectors.size > 1
41
+ end
42
+
43
+ # Resolves which Flipper gate a tool call targets from its arguments. In
44
+ # precedence order: a specific actor, a named group, a percentage, else the
45
+ # global boolean gate.
46
+ def gate_from(args)
47
+ if (actor = actor_for(args[:actor_class], args[:actor_id]))
48
+ { type: :actor, actor: actor }
49
+ elsif args[:group]
50
+ { type: :group, group: args[:group].to_sym }
51
+ elsif !args[:percentage].nil?
52
+ type = args[:percentage_type] == "time" ? :percentage_of_time : :percentage_of_actors
53
+ { type: type, percentage: args[:percentage].to_i }
54
+ else
55
+ { type: :boolean }
56
+ end
57
+ end
58
+
59
+ # Applies :enable or :disable across the resolved gate. Every branch is an
60
+ # explicit named call so the dispatch is statically obvious and an unknown
61
+ # gate type fails fast rather than silently toggling the boolean gate.
62
+ def apply(operation, name, gate)
63
+ enabling = operation == :enable
64
+ case gate[:type]
65
+ when :boolean
66
+ enabling ? ::Flipper.enable(name) : ::Flipper.disable(name)
67
+ when :actor
68
+ enabling ? ::Flipper.enable(name, gate[:actor]) : ::Flipper.disable(name, gate[:actor])
69
+ when :group
70
+ enabling ? ::Flipper.enable_group(name, gate[:group]) : ::Flipper.disable_group(name, gate[:group])
71
+ when :percentage_of_actors
72
+ enabling ? ::Flipper.enable_percentage_of_actors(name, gate[:percentage]) : ::Flipper.disable_percentage_of_actors(name)
73
+ when :percentage_of_time
74
+ enabling ? ::Flipper.enable_percentage_of_time(name, gate[:percentage]) : ::Flipper.disable_percentage_of_time(name)
75
+ else
76
+ raise ArgumentError, "unknown Flipper gate type: #{gate[:type].inspect}"
77
+ end
78
+ end
79
+
80
+ # Reads a flag's effective state, globally or for an actor. Unknown flags
81
+ # read false.
82
+ def state(name, actor)
83
+ actor ? ::Flipper.enabled?(name, actor) : ::Flipper.enabled?(name)
84
+ end
85
+
86
+ # The full per-gate configuration of a flag, for read_flag.
87
+ def gate_values(name)
88
+ values = ::Flipper[name].gate_values
89
+ {
90
+ boolean: values.boolean,
91
+ actors: values.actors.to_a,
92
+ groups: values.groups.to_a,
93
+ percentage_of_actors: values.percentage_of_actors,
94
+ percentage_of_time: values.percentage_of_time,
95
+ }
96
+ end
97
+
98
+ # A flag is "enabled" if any gate is active.
99
+ def active?(gates)
100
+ gates[:boolean] ||
101
+ gates[:actors].any? ||
102
+ gates[:groups].any? ||
103
+ gates[:percentage_of_actors].to_i.positive? ||
104
+ gates[:percentage_of_time].to_i.positive?
105
+ end
106
+
107
+ # Last-change timestamps for many flags in a single query, keyed by flag
108
+ # name, read from the flipper_features table when the ActiveRecord adapter
109
+ # is in use. Flipper records no enable/disable history, so updated_at is
110
+ # the last time the feature row changed. Returns an empty hash (callers
111
+ # fall back to nil timestamps) for non-ActiveRecord adapters. Batched to
112
+ # avoid an N+1 across all flags.
113
+ def preload_timestamps(names)
114
+ return {} unless defined?(::Flipper::Adapters::ActiveRecord::Feature)
115
+
116
+ ::Flipper::Adapters::ActiveRecord::Feature.where(key: names.map(&:to_s)).each_with_object({}) do |row, acc|
117
+ acc[row.key] = { created_at: row.created_at&.utc&.iso8601, updated_at: row.updated_at&.utc&.iso8601 }
118
+ end
119
+ rescue StandardError
120
+ {}
121
+ end
122
+
123
+ class Plugin < TalkToYourApp::Plugin
124
+ requires_connection :flipper_writer
125
+ requires_gem "Flipper", gem_name: "flipper"
126
+ tools Tools::ListFlags, Tools::ReadFlag, Tools::EnableFlag, Tools::DisableFlag, Tools::EnabledFlags
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ TalkToYourApp.register_plugin(:flipper, TalkToYourApp::Plugins::Flipper::Plugin)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Flipper
8
+ module Tools
9
+ # Disables a flag. With no gate arguments it disables globally; supply
10
+ # actor_class+actor_id, group, or percentage (+ optional percentage_type)
11
+ # to clear that specific gate.
12
+ class DisableFlag < TalkToYourApp::Tool
13
+ name "flipper.disable_flag"
14
+ description "Disable a feature flag globally, for an actor, a group, or a percentage."
15
+ connection :flipper_writer
16
+ argument :name, :string, required: true, description: "Flag name."
17
+ argument :actor_class, :string, description: "Actor class name, e.g. \"User\"."
18
+ argument :actor_id, :string, description: "Actor id, e.g. \"42\"."
19
+ argument :group, :string, description: "A registered Flipper group name."
20
+ argument :percentage, :integer, minimum: 0, maximum: 100,
21
+ description: "Percentage gate to clear (0-100); the value is ignored on disable."
22
+ argument :percentage_type, :string, enum: %w[actors time], default: "actors",
23
+ description: "Whether percentage applies to actors or time."
24
+
25
+ def call(args, ctx)
26
+ return error("Specify exactly one gate: an actor, a group, or a percentage.") if Flipper.gate_conflict?(args)
27
+
28
+ gate = Flipper.gate_from(args)
29
+ result = ctx.connection do
30
+ Flipper.apply(:disable, args[:name], gate)
31
+ Flipper.gate_values(args[:name])
32
+ end
33
+ json(name: args[:name], enabled: Flipper.active?(result), gate_type: gate[:type], gates: result)
34
+ rescue StandardError => e
35
+ error("Flipper storage unavailable: #{e.class}: #{e.message}")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Flipper
8
+ module Tools
9
+ # Enables a flag. With no gate arguments it enables globally (boolean
10
+ # gate); supply actor_class+actor_id for a single actor, group for a
11
+ # registered group, or percentage (+ optional percentage_type) for a
12
+ # percentage rollout.
13
+ class EnableFlag < TalkToYourApp::Tool
14
+ name "flipper.enable_flag"
15
+ description "Enable a feature flag globally, for an actor, a group, or a percentage."
16
+ connection :flipper_writer
17
+ argument :name, :string, required: true, description: "Flag name."
18
+ argument :actor_class, :string, description: "Actor class name, e.g. \"User\"."
19
+ argument :actor_id, :string, description: "Actor id, e.g. \"42\"."
20
+ argument :group, :string, description: "A registered Flipper group name."
21
+ argument :percentage, :integer, minimum: 0, maximum: 100,
22
+ description: "Percentage rollout (0-100)."
23
+ argument :percentage_type, :string, enum: %w[actors time], default: "actors",
24
+ description: "Whether percentage applies to actors or time."
25
+
26
+ def call(args, ctx)
27
+ return error("Specify exactly one gate: an actor, a group, or a percentage.") if Flipper.gate_conflict?(args)
28
+
29
+ gate = Flipper.gate_from(args)
30
+ result = ctx.connection do
31
+ Flipper.apply(:enable, args[:name], gate)
32
+ Flipper.gate_values(args[:name])
33
+ end
34
+ json(name: args[:name], enabled: Flipper.active?(result), gate_type: gate[:type], gates: result)
35
+ rescue StandardError => e
36
+ error("Flipper storage unavailable: #{e.class}: #{e.message}")
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Flipper
8
+ module Tools
9
+ # Lists the currently-enabled feature flags (any active gate) with their
10
+ # per-gate configuration and last-change timestamps, for inspection.
11
+ # Flipper does not record full enable/disable history; `updated_at` is
12
+ # the last time the feature changed (ActiveRecord adapter only).
13
+ class EnabledFlags < TalkToYourApp::Tool
14
+ name "flipper.enabled_flags"
15
+ description "List currently-enabled feature flags with their gates and last-change timestamps."
16
+ connection :flipper_writer
17
+
18
+ def call(_args, ctx)
19
+ flags = ctx.connection do
20
+ features = ::Flipper.features.to_a
21
+ timestamps = Flipper.preload_timestamps(features.map(&:key))
22
+ no_timestamps = { created_at: nil, updated_at: nil }
23
+ features.filter_map do |feature|
24
+ gates = Flipper.gate_values(feature.key)
25
+ next unless Flipper.active?(gates)
26
+
27
+ { name: feature.key, enabled: true, gates: gates }.merge(timestamps.fetch(feature.key, no_timestamps))
28
+ end
29
+ end
30
+ json(
31
+ enabled_flags: flags.sort_by { |f| f[:name] },
32
+ note: "Flipper does not store enable/disable history; updated_at is the last feature change (ActiveRecord adapter only).",
33
+ )
34
+ rescue StandardError => e
35
+ error("Flipper storage unavailable: #{e.class}: #{e.message}")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Flipper
8
+ module Tools
9
+ # Lists the keys of all configured feature flags.
10
+ class ListFlags < TalkToYourApp::Tool
11
+ name "flipper.list_flags"
12
+ description "List all configured feature flag names."
13
+ connection :flipper_writer
14
+
15
+ def call(_args, ctx)
16
+ names = ctx.connection { ::Flipper.features.map(&:key) }
17
+ json(flags: names.sort)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Flipper
8
+ module Tools
9
+ # Reads a flag's effective state plus its full per-gate configuration.
10
+ # With an actor, `enabled` reflects that actor; otherwise it reflects the
11
+ # global state. Unknown flags read as disabled.
12
+ class ReadFlag < TalkToYourApp::Tool
13
+ name "flipper.read_flag"
14
+ description "Read a feature flag's state and per-gate configuration."
15
+ connection :flipper_writer
16
+ argument :name, :string, required: true, description: "Flag name."
17
+ argument :actor_class, :string, description: "Actor class name, e.g. \"User\"."
18
+ argument :actor_id, :string, description: "Actor id, e.g. \"42\"."
19
+
20
+ def call(args, ctx)
21
+ actor = Flipper.actor_for(args[:actor_class], args[:actor_id])
22
+ enabled, gates = ctx.connection do
23
+ [Flipper.state(args[:name], actor), Flipper.gate_values(args[:name])]
24
+ end
25
+ json(name: args[:name], enabled: enabled, actor: actor&.flipper_id, gates: gates)
26
+ rescue StandardError => e
27
+ error("Flipper storage unavailable: #{e.class}: #{e.message}")
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "registry"
5
+ require_relative "tools/list_checks"
6
+ require_relative "tools/run_check"
7
+
8
+ module TalkToYourApp
9
+ module Plugins
10
+ module Health
11
+ # The Health plugin exposes the operator-registered checks via two tools:
12
+ # list and run. No connection or soft-dependency gem is required — checks
13
+ # own whatever resources they touch.
14
+ class Plugin < TalkToYourApp::Plugin
15
+ tools Tools::ListChecks, Tools::RunCheck
16
+
17
+ # Load app/talk_to_your_app/health/ (one Health.register per file) when
18
+ # the tool list is read — once, at rack-app build / boot, mirroring the
19
+ # custom_tools plugin. This surfaces a broken health file at deploy
20
+ # rather than on the first health.run, and avoids re-globbing the
21
+ # directory (and re-logging a failed load) on every tool call.
22
+ def self.tools(*classes)
23
+ TalkToYourApp.require_app_dir("health") if classes.empty?
24
+ super
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ TalkToYourApp.register_plugin(:health, TalkToYourApp::Plugins::Health::Plugin)