rails-markup 1.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +304 -0
  4. data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
  5. data/app/controllers/rails_markup/annotations_controller.rb +244 -0
  6. data/app/controllers/rails_markup/application_controller.rb +7 -0
  7. data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
  8. data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
  9. data/app/models/rails_markup/annotation.rb +176 -0
  10. data/app/views/layouts/rails_markup/application.html.erb +158 -0
  11. data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
  12. data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
  13. data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
  14. data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
  15. data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
  16. data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
  17. data/app/views/rails_markup/dashboard/board.html.erb +135 -0
  18. data/app/views/rails_markup/dashboard/index.html.erb +38 -0
  19. data/app/views/rails_markup/dashboard/show.html.erb +129 -0
  20. data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
  21. data/bin/rails-markup +5 -0
  22. data/config/routes.rb +45 -0
  23. data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
  24. data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
  25. data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
  26. data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
  27. data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
  28. data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
  29. data/lib/generators/rails_markup/install_generator.rb +167 -0
  30. data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
  31. data/lib/rails_markup/cli/base.rb +8 -0
  32. data/lib/rails_markup/cli/initializer_writer.rb +54 -0
  33. data/lib/rails_markup/cli/setup_wizard.rb +229 -0
  34. data/lib/rails_markup/cli.rb +831 -0
  35. data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
  36. data/lib/rails_markup/configuration.rb +160 -0
  37. data/lib/rails_markup/engine.rb +18 -0
  38. data/lib/rails_markup/http_server.rb +226 -0
  39. data/lib/rails_markup/http_store_proxy.rb +122 -0
  40. data/lib/rails_markup/mcp_config.rb +258 -0
  41. data/lib/rails_markup/mcp_server.rb +639 -0
  42. data/lib/rails_markup/server.rb +73 -0
  43. data/lib/rails_markup/store.rb +219 -0
  44. data/lib/rails_markup/version.rb +5 -0
  45. data/lib/rails_markup.rb +11 -0
  46. data/lib/tasks/rails_markup_client_uuids.rake +19 -0
  47. metadata +198 -0
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module RailsMarkup
6
+ # Main entry point — starts HTTP server and optionally MCP server.
7
+ # HTTP and MCP share the same Store instance so annotations flow both ways.
8
+ # If the HTTP port is already taken, MCP proxies to the existing server.
9
+ class Server
10
+ attr_reader :store
11
+
12
+ def initialize(port: 4747, mcp_only: false)
13
+ @port = port
14
+ @mcp_only = mcp_only
15
+ @store = Store.new
16
+ end
17
+
18
+ def start
19
+ if @mcp_only
20
+ start_mcp_only
21
+ else
22
+ start_http_and_mcp
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def start_mcp(store)
29
+ mcp = McpServer.new(store: store)
30
+ mcp.start
31
+ end
32
+
33
+ # MCP-only mode: always proxy to the HTTP server.
34
+ # The user runs `rails-markup server` separately for HTTP.
35
+ def start_mcp_only
36
+ $stderr.puts "[rails-markup] MCP server listening on stdio (proxying to HTTP on port #{@port})"
37
+ proxy = HttpStoreProxy.new(base_url: "http://localhost:#{@port}")
38
+ start_mcp(proxy)
39
+ rescue IOError
40
+ # stdin closed — MCP client disconnected
41
+ end
42
+
43
+ def start_http_and_mcp
44
+ if port_available?(@port)
45
+ # We own the port — start HTTP + MCP with shared in-memory store
46
+ http_thread = Thread.new do
47
+ http = HttpServer.new(store: @store, port: @port)
48
+ $stderr.puts "[rails-markup] HTTP server listening on port #{@port}"
49
+ http.start
50
+ end
51
+
52
+ $stderr.puts "[rails-markup] MCP server listening on stdio"
53
+ start_mcp(@store)
54
+ else
55
+ # Port is taken — proxy MCP reads/writes to the existing HTTP server
56
+ $stderr.puts "[rails-markup] Port #{@port} in use — MCP proxying to existing server"
57
+ $stderr.puts "[rails-markup] MCP server listening on stdio"
58
+ proxy = HttpStoreProxy.new(base_url: "http://localhost:#{@port}")
59
+ start_mcp(proxy)
60
+ end
61
+ rescue IOError
62
+ # stdin closed — MCP client disconnected
63
+ end
64
+
65
+ def port_available?(port)
66
+ server = TCPServer.new("0.0.0.0", port)
67
+ server.close
68
+ true
69
+ rescue Errno::EADDRINUSE
70
+ false
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module RailsMarkup
7
+ # In-memory store for sessions and annotations.
8
+ # Ephemeral by design — data lives for one coding session.
9
+ class Store
10
+ Session = Struct.new(:id, :url, :metadata, :created_at, :annotations, keyword_init: true)
11
+ Annotation = Struct.new(:id, :session_id, :target, :content, :intent, :severity, :status,
12
+ :selected_text, :metadata, :created_at, :thread, keyword_init: true)
13
+
14
+ MAX_SESSIONS = 100
15
+ SESSION_TTL = 4 * 3600 # 4 hours
16
+
17
+ attr_reader :sessions
18
+
19
+ def initialize
20
+ @sessions = {}
21
+ @annotations_index = {} # id -> annotation (O(1) lookup)
22
+ @subscribers = [] # SSE callbacks: [session_id, callback]
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # --- Sessions ---
27
+
28
+ def create_session(url:, metadata: {})
29
+ id = SecureRandom.hex(8)
30
+ session = Session.new(
31
+ id: id,
32
+ url: url,
33
+ metadata: metadata || {},
34
+ created_at: Time.now.iso8601,
35
+ annotations: []
36
+ )
37
+ @mutex.synchronize do
38
+ evict_stale_sessions
39
+ @sessions[id] = session
40
+ end
41
+ session
42
+ end
43
+
44
+ def get_session(id)
45
+ @mutex.synchronize { @sessions[id] }
46
+ end
47
+
48
+ def list_sessions
49
+ @mutex.synchronize { @sessions.values }
50
+ end
51
+
52
+ # --- Annotations ---
53
+
54
+ def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion",
55
+ selected_text: nil, metadata: {})
56
+ id = SecureRandom.hex(8)
57
+ annotation = Annotation.new(
58
+ id: id,
59
+ session_id: session_id,
60
+ target: target,
61
+ content: content,
62
+ intent: intent,
63
+ severity: severity,
64
+ status: "pending",
65
+ selected_text: selected_text,
66
+ metadata: metadata || {},
67
+ created_at: Time.now.iso8601,
68
+ thread: []
69
+ )
70
+
71
+ # Single mutex block — no TOCTOU gap
72
+ @mutex.synchronize do
73
+ session = @sessions[session_id]
74
+ return nil unless session
75
+
76
+ session.annotations << annotation
77
+ @annotations_index[id] = annotation
78
+ end
79
+
80
+ notify(session_id, type: "annotation_created", annotation: serialize_annotation(annotation))
81
+ annotation
82
+ end
83
+
84
+ def get_annotation(annotation_id)
85
+ @mutex.synchronize { @annotations_index[annotation_id] }
86
+ end
87
+
88
+ def pending_for_session(session_id)
89
+ session = get_session(session_id)
90
+ return [] unless session
91
+
92
+ @mutex.synchronize { session.annotations.select { |a| a.status == "pending" } }
93
+ end
94
+
95
+ def all_pending
96
+ @mutex.synchronize do
97
+ @sessions.values.flat_map { |s| s.annotations.select { |a| a.status == "pending" } }
98
+ end
99
+ end
100
+
101
+ # --- Status transitions ---
102
+
103
+ def acknowledge(annotation_id)
104
+ update_status(annotation_id, "acknowledged")
105
+ end
106
+
107
+ def resolve(annotation_id, summary: nil)
108
+ ann = update_status(annotation_id, "resolved")
109
+ return nil unless ann
110
+
111
+ ann.thread << { role: "agent", message: summary, timestamp: Time.now.iso8601 } if summary
112
+ notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
113
+ status: "resolved", summary: summary)
114
+ ann
115
+ end
116
+
117
+ def dismiss(annotation_id, reason: nil)
118
+ ann = update_status(annotation_id, "dismissed")
119
+ return nil unless ann
120
+
121
+ ann.thread << { role: "agent", message: reason, timestamp: Time.now.iso8601 } if reason
122
+ notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
123
+ status: "dismissed", reason: reason)
124
+ ann
125
+ end
126
+
127
+ def reply(annotation_id, message:)
128
+ ann = get_annotation(annotation_id)
129
+ return nil unless ann
130
+
131
+ @mutex.synchronize do
132
+ ann.thread << { role: "agent", message: message, timestamp: Time.now.iso8601 }
133
+ end
134
+ notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
135
+ status: ann.status, message: message)
136
+ ann
137
+ end
138
+
139
+ # --- SSE subscriptions ---
140
+
141
+ def subscribe(session_id, &callback)
142
+ sub = [session_id, callback]
143
+ @mutex.synchronize { @subscribers << sub }
144
+ sub
145
+ end
146
+
147
+ def unsubscribe(sub)
148
+ @mutex.synchronize { @subscribers.delete(sub) }
149
+ end
150
+
151
+ # --- Serialization ---
152
+
153
+ def serialize_session(session)
154
+ {
155
+ id: session.id,
156
+ url: session.url,
157
+ metadata: session.metadata,
158
+ createdAt: session.created_at,
159
+ annotations: session.annotations.map { |a| serialize_annotation(a) }
160
+ }
161
+ end
162
+
163
+ def serialize_annotation(ann)
164
+ {
165
+ id: ann.id,
166
+ sessionId: ann.session_id,
167
+ target: ann.target,
168
+ content: ann.content,
169
+ intent: ann.intent,
170
+ severity: ann.severity,
171
+ status: ann.status,
172
+ selectedText: ann.selected_text,
173
+ authorName: ann.metadata&.dig("author"),
174
+ metadata: ann.metadata,
175
+ createdAt: ann.created_at,
176
+ thread: ann.thread
177
+ }
178
+ end
179
+
180
+ private
181
+
182
+ def update_status(annotation_id, new_status)
183
+ ann = get_annotation(annotation_id)
184
+ return nil unless ann
185
+
186
+ @mutex.synchronize { ann.status = new_status }
187
+ ann
188
+ end
189
+
190
+ def notify(session_id, data)
191
+ dead = []
192
+ @mutex.synchronize do
193
+ @subscribers.each do |sub|
194
+ sid, callback = sub
195
+ next unless sid.nil? || sid == session_id
196
+
197
+ callback.call(data)
198
+ rescue StandardError
199
+ dead << sub
200
+ end
201
+ dead.each { |s| @subscribers.delete(s) }
202
+ end
203
+ end
204
+
205
+ def evict_stale_sessions
206
+ return if @sessions.size < MAX_SESSIONS
207
+
208
+ cutoff = (Time.now - SESSION_TTL).iso8601
209
+ @sessions.delete_if do |_id, session|
210
+ if session.created_at < cutoff
211
+ session.annotations.each { |a| @annotations_index.delete(a.id) }
212
+ true
213
+ else
214
+ false
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMarkup
4
+ VERSION = "1.2.0"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_markup/version"
4
+ require_relative "rails_markup/configuration"
5
+ require_relative "rails_markup/store"
6
+ require_relative "rails_markup/http_store_proxy"
7
+ require_relative "rails_markup/http_server"
8
+ require_relative "rails_markup/mcp_server"
9
+ require_relative "rails_markup/server"
10
+
11
+ require_relative "rails_markup/engine" if defined?(Rails::Engine)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_markup/client_uuid_maintenance"
4
+
5
+ namespace :rails_markup do
6
+ namespace :client_uuids do
7
+ desc "Repair blank or noncanonical Rails Markup client UUIDs (safe to repeat)"
8
+ task repair: :environment do
9
+ repaired = RailsMarkup::ClientUuidMaintenance.repair!
10
+ puts "Repaired #{repaired} Rails Markup client UUID(s)."
11
+ end
12
+
13
+ desc "Fail unless every Rails Markup annotation has a canonical client UUID"
14
+ task verify: :environment do
15
+ RailsMarkup::ClientUuidMaintenance.verify!
16
+ puts "All Rails Markup client UUIDs are canonical."
17
+ end
18
+ end
19
+ end
metadata ADDED
@@ -0,0 +1,198 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-markup
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.2.0
5
+ platform: ruby
6
+ authors:
7
+ - InventList
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: webrick
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activerecord
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: thor
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.3'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.3'
68
+ - !ruby/object:Gem::Dependency
69
+ name: bubbletea
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: lipgloss
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.2'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.2'
96
+ - !ruby/object:Gem::Dependency
97
+ name: csv
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.3'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '3.3'
110
+ description: An MCP server that lets you annotate Rails views in the browser and have
111
+ AI agents read and act on your feedback.
112
+ email:
113
+ - hello@inventlist.com
114
+ executables:
115
+ - rails-markup
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - LICENSE
120
+ - README.md
121
+ - app/assets/javascripts/rails_markup/toolbar.js
122
+ - app/controllers/rails_markup/annotations_controller.rb
123
+ - app/controllers/rails_markup/application_controller.rb
124
+ - app/controllers/rails_markup/dashboard_controller.rb
125
+ - app/controllers/rails_markup/external/annotations_controller.rb
126
+ - app/models/rails_markup/annotation.rb
127
+ - app/views/layouts/rails_markup/application.html.erb
128
+ - app/views/rails_markup/dashboard/_annotation_card.html.erb
129
+ - app/views/rails_markup/dashboard/_annotation_list.html.erb
130
+ - app/views/rails_markup/dashboard/_annotation_page.html.erb
131
+ - app/views/rails_markup/dashboard/_filters.html.erb
132
+ - app/views/rails_markup/dashboard/_stats.html.erb
133
+ - app/views/rails_markup/dashboard/_styles.html.erb
134
+ - app/views/rails_markup/dashboard/board.html.erb
135
+ - app/views/rails_markup/dashboard/index.html.erb
136
+ - app/views/rails_markup/dashboard/show.html.erb
137
+ - app/views/rails_markup/shared/_toolbar.html.erb
138
+ - bin/rails-markup
139
+ - config/routes.rb
140
+ - db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb
141
+ - db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb
142
+ - lib/generators/rails_markup/install/templates/auth_controller.rb.erb
143
+ - lib/generators/rails_markup/install/templates/bin_markup.erb
144
+ - lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb
145
+ - lib/generators/rails_markup/install/templates/initializer.rb.erb
146
+ - lib/generators/rails_markup/install_generator.rb
147
+ - lib/generators/rails_markup/uninstall_generator.rb
148
+ - lib/rails_markup.rb
149
+ - lib/rails_markup/cli.rb
150
+ - lib/rails_markup/cli/base.rb
151
+ - lib/rails_markup/cli/initializer_writer.rb
152
+ - lib/rails_markup/cli/setup_wizard.rb
153
+ - lib/rails_markup/client_uuid_maintenance.rb
154
+ - lib/rails_markup/configuration.rb
155
+ - lib/rails_markup/engine.rb
156
+ - lib/rails_markup/http_server.rb
157
+ - lib/rails_markup/http_store_proxy.rb
158
+ - lib/rails_markup/mcp_config.rb
159
+ - lib/rails_markup/mcp_server.rb
160
+ - lib/rails_markup/server.rb
161
+ - lib/rails_markup/store.rb
162
+ - lib/rails_markup/version.rb
163
+ - lib/tasks/rails_markup_client_uuids.rake
164
+ homepage: https://github.com/inventlist/rails-markup
165
+ licenses:
166
+ - MIT
167
+ metadata:
168
+ homepage_uri: https://github.com/inventlist/rails-markup
169
+ source_code_uri: https://github.com/inventlist/rails-markup
170
+ changelog_uri: https://github.com/inventlist/rails-markup/blob/main/CHANGELOG.md
171
+ post_install_message: |
172
+ Rails Markup installed! Run the generator to set up:
173
+
174
+ rails generate rails_markup:install
175
+ rails db:migrate
176
+
177
+ Options:
178
+ --mount-path=/admin/annotations (engine route)
179
+ --base-controller=AdminController (auth parent class)
180
+ --layout=application (toolbar injection)
181
+ rdoc_options: []
182
+ require_paths:
183
+ - lib
184
+ required_ruby_version: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - ">="
187
+ - !ruby/object:Gem::Version
188
+ version: '3.2'
189
+ required_rubygems_version: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - ">="
192
+ - !ruby/object:Gem::Version
193
+ version: '0'
194
+ requirements: []
195
+ rubygems_version: 3.6.9
196
+ specification_version: 4
197
+ summary: Point-and-click annotation tool for AI agents
198
+ test_files: []