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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +304 -0
- data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
- data/app/controllers/rails_markup/annotations_controller.rb +244 -0
- data/app/controllers/rails_markup/application_controller.rb +7 -0
- data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
- data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
- data/app/models/rails_markup/annotation.rb +176 -0
- data/app/views/layouts/rails_markup/application.html.erb +158 -0
- data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
- data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
- data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
- data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
- data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
- data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
- data/app/views/rails_markup/dashboard/board.html.erb +135 -0
- data/app/views/rails_markup/dashboard/index.html.erb +38 -0
- data/app/views/rails_markup/dashboard/show.html.erb +129 -0
- data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
- data/bin/rails-markup +5 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
- data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
- data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
- data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
- data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
- data/lib/generators/rails_markup/install_generator.rb +167 -0
- data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
- data/lib/rails_markup/cli/base.rb +8 -0
- data/lib/rails_markup/cli/initializer_writer.rb +54 -0
- data/lib/rails_markup/cli/setup_wizard.rb +229 -0
- data/lib/rails_markup/cli.rb +831 -0
- data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
- data/lib/rails_markup/configuration.rb +160 -0
- data/lib/rails_markup/engine.rb +18 -0
- data/lib/rails_markup/http_server.rb +226 -0
- data/lib/rails_markup/http_store_proxy.rb +122 -0
- data/lib/rails_markup/mcp_config.rb +258 -0
- data/lib/rails_markup/mcp_server.rb +639 -0
- data/lib/rails_markup/server.rb +73 -0
- data/lib/rails_markup/store.rb +219 -0
- data/lib/rails_markup/version.rb +5 -0
- data/lib/rails_markup.rb +11 -0
- data/lib/tasks/rails_markup_client_uuids.rake +19 -0
- metadata +198 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lipgloss"
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "store"
|
|
7
|
+
require_relative "http_store_proxy"
|
|
8
|
+
require_relative "http_server"
|
|
9
|
+
require_relative "mcp_server"
|
|
10
|
+
require_relative "server"
|
|
11
|
+
require_relative "mcp_config"
|
|
12
|
+
require_relative "cli/base"
|
|
13
|
+
|
|
14
|
+
module RailsMarkup
|
|
15
|
+
class Cli
|
|
16
|
+
require_relative "cli/initializer_writer"
|
|
17
|
+
require_relative "cli/setup_wizard"
|
|
18
|
+
# ── Lipgloss styles ────────────────────────────────────────
|
|
19
|
+
HEADER_STYLE = Lipgloss::Style.new.bold(true).foreground("#FFFFFF").background("#5C4AE4").padding(0, 1)
|
|
20
|
+
ODD_STYLE = Lipgloss::Style.new.foreground("#E2E2E2").padding(0, 1)
|
|
21
|
+
EVEN_STYLE = Lipgloss::Style.new.foreground("#A0A0A0").padding(0, 1)
|
|
22
|
+
MASKED_STYLE = Lipgloss::Style.new.foreground("#6B7280").padding(0, 1)
|
|
23
|
+
LABEL_STYLE = Lipgloss::Style.new.bold(true).foreground("#FFFFFF")
|
|
24
|
+
HINT_STYLE = Lipgloss::Style.new.foreground("#6B7280")
|
|
25
|
+
SUCCESS_STYLE = Lipgloss::Style.new.bold(true).foreground("#22C55E")
|
|
26
|
+
ERROR_STYLE = Lipgloss::Style.new.bold(true).foreground("#EF4444")
|
|
27
|
+
|
|
28
|
+
desc "server", "Start HTTP + MCP server"
|
|
29
|
+
long_desc <<-DESC
|
|
30
|
+
Start the combined HTTP annotation server and MCP stdio bridge.
|
|
31
|
+
|
|
32
|
+
The HTTP server serves the annotation toolbar and API endpoints.
|
|
33
|
+
The MCP bridge lets AI editors communicate via JSON-RPC over stdio.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
|
|
37
|
+
bin/markup server # default port 4747
|
|
38
|
+
|
|
39
|
+
bin/markup server --port 5000 # custom port
|
|
40
|
+
DESC
|
|
41
|
+
method_option :port, type: :numeric, default: 4747, desc: "HTTP server port"
|
|
42
|
+
def server
|
|
43
|
+
srv = RailsMarkup::Server.new(port: options[:port])
|
|
44
|
+
srv.start
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc "init", "Interactive setup wizard"
|
|
48
|
+
long_desc <<-DESC
|
|
49
|
+
Launch the interactive TUI setup wizard.
|
|
50
|
+
|
|
51
|
+
Walks through toolbar accent, position, size, screenshots,
|
|
52
|
+
production URL, and MCP scope (local/global/codex).
|
|
53
|
+
Writes the Rails initializer and MCP config on confirm.
|
|
54
|
+
|
|
55
|
+
Requires a terminal with arrow key support.
|
|
56
|
+
DESC
|
|
57
|
+
def init
|
|
58
|
+
wizard = Cli::SetupWizard.new(dir: Dir.pwd)
|
|
59
|
+
Bubbletea.run(wizard)
|
|
60
|
+
if wizard.completed
|
|
61
|
+
say ""
|
|
62
|
+
say "Setup complete!", :green
|
|
63
|
+
else
|
|
64
|
+
say ""
|
|
65
|
+
say "Setup cancelled.", :yellow
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "mcp", "Start MCP-only server (stdio)"
|
|
70
|
+
long_desc <<-DESC
|
|
71
|
+
Start the MCP server in stdio-only mode (no HTTP server).
|
|
72
|
+
|
|
73
|
+
This is what AI editors (Claude Code, Codex CLI, Cursor) invoke
|
|
74
|
+
via .mcp.json or global config. Communicates over stdin/stdout
|
|
75
|
+
using the JSON-RPC MCP protocol.
|
|
76
|
+
|
|
77
|
+
You rarely need to run this manually — it's called by the editor.
|
|
78
|
+
DESC
|
|
79
|
+
method_option :port, type: :numeric, default: 4747, desc: "HTTP server port to proxy to"
|
|
80
|
+
def mcp
|
|
81
|
+
srv = RailsMarkup::Server.new(port: options[:port], mcp_only: true)
|
|
82
|
+
srv.start
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
desc "configure", "Set MCP env vars (local, global, or codex)"
|
|
86
|
+
long_desc <<-DESC
|
|
87
|
+
Write environment variables to the MCP config file for this project
|
|
88
|
+
or globally for all projects.
|
|
89
|
+
|
|
90
|
+
Scope flags:
|
|
91
|
+
|
|
92
|
+
(default) Write to .mcp.json in the current directory
|
|
93
|
+
|
|
94
|
+
--global Write to ~/.claude/settings.json (Claude Code)
|
|
95
|
+
|
|
96
|
+
--codex Write to ~/.codex/config.toml (OpenAI Codex CLI)
|
|
97
|
+
|
|
98
|
+
Examples:
|
|
99
|
+
|
|
100
|
+
bin/markup configure --dev-url http://localhost:3000
|
|
101
|
+
|
|
102
|
+
bin/markup configure --prod-url URL --prod-token TOKEN
|
|
103
|
+
|
|
104
|
+
bin/markup configure --prod-url URL --global
|
|
105
|
+
|
|
106
|
+
bin/markup configure --prod-url URL --codex
|
|
107
|
+
DESC
|
|
108
|
+
method_option :prod_url, type: :string, desc: "Production URL (RAILS_MARKUP_PROD_URL)"
|
|
109
|
+
method_option :prod_token, type: :string, desc: "Production API token (RAILS_MARKUP_PROD_TOKEN)"
|
|
110
|
+
method_option :dev_url, type: :string, desc: "Dev URL (RAILS_MARKUP_DEV_URL)"
|
|
111
|
+
method_option :mount_path, type: :string, desc: "Engine mount path (RAILS_MARKUP_MOUNT_PATH)"
|
|
112
|
+
method_option :global, type: :boolean, default: false, desc: "Write to ~/.claude/settings.json"
|
|
113
|
+
method_option :codex, type: :boolean, default: false, desc: "Write to ~/.codex/config.toml"
|
|
114
|
+
def configure
|
|
115
|
+
env_updates = McpConfig::ENV_KEYS.each_with_object({}) do |(opt, env_key), hash|
|
|
116
|
+
hash[env_key] = options[opt] if options[opt]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if env_updates.empty?
|
|
120
|
+
say "No options provided. Usage:", :yellow
|
|
121
|
+
say ""
|
|
122
|
+
say " bin/markup configure --dev-url http://localhost:3000"
|
|
123
|
+
say " bin/markup configure --prod-url URL --prod-token TOKEN"
|
|
124
|
+
say " bin/markup configure --prod-url URL --global # Claude Code global"
|
|
125
|
+
say " bin/markup configure --prod-url URL --codex # Codex CLI global"
|
|
126
|
+
say ""
|
|
127
|
+
say " Dev needs only a URL (no token). Production requires both."
|
|
128
|
+
say ""
|
|
129
|
+
say " Or run: bin/markup setup-production --url=https://yourapp.com"
|
|
130
|
+
say ""
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
config = McpConfig.new(scope: resolve_scope)
|
|
135
|
+
config.update_env(env_updates)
|
|
136
|
+
$stdout.puts "#{LABEL_STYLE.render("Updated #{config.scope_label}")} #{HINT_STYLE.render("(#{config.path})")}"
|
|
137
|
+
$stdout.puts env_table(config.display_env, label: config.scope_label)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
desc "status", "Show MCP config across all scopes"
|
|
141
|
+
long_desc <<-DESC
|
|
142
|
+
Display the current MCP configuration across all scopes.
|
|
143
|
+
|
|
144
|
+
Checks .mcp.json (local), ~/.claude/settings.json (Claude Code),
|
|
145
|
+
and ~/.codex/config.toml (Codex CLI). Tokens are masked.
|
|
146
|
+
DESC
|
|
147
|
+
def status
|
|
148
|
+
found = false
|
|
149
|
+
|
|
150
|
+
McpConfig::SCOPES.each do |scope|
|
|
151
|
+
config = McpConfig.new(scope: scope)
|
|
152
|
+
next unless config.exist?
|
|
153
|
+
|
|
154
|
+
env = config.display_env
|
|
155
|
+
next if env.empty?
|
|
156
|
+
|
|
157
|
+
found = true
|
|
158
|
+
say ""
|
|
159
|
+
$stdout.puts env_table(env, label: config.scope_label)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
unless found
|
|
163
|
+
say ""
|
|
164
|
+
say "No MCP config found. Run:", :yellow
|
|
165
|
+
say " rails-markup configure --prod-url URL --prod-token TOKEN"
|
|
166
|
+
say " rails-markup configure --prod-url URL --global # Claude Code global"
|
|
167
|
+
say " rails-markup configure --prod-url URL --codex # Codex CLI global"
|
|
168
|
+
end
|
|
169
|
+
say ""
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ── Unified commands (match MCP tool verbs) ──────────────
|
|
173
|
+
|
|
174
|
+
desc "sessions", "List active annotation sessions (MCP only)"
|
|
175
|
+
def sessions
|
|
176
|
+
say "Sessions are only available via MCP tools (in-memory store).", :yellow
|
|
177
|
+
say ""
|
|
178
|
+
say " Use the rails_markup_sessions MCP tool in your AI editor."
|
|
179
|
+
say " The CLI talks to the Rails API which has database-backed annotations,"
|
|
180
|
+
say " not session-based ones."
|
|
181
|
+
say ""
|
|
182
|
+
say " To view pending annotations: bin/markup pending"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
desc "session ID", "Get a session with annotations (MCP only)"
|
|
186
|
+
def session(id = nil)
|
|
187
|
+
say "Sessions are only available via MCP tools (in-memory store).", :yellow
|
|
188
|
+
say ""
|
|
189
|
+
say " Use the rails_markup_session MCP tool in your AI editor."
|
|
190
|
+
say ""
|
|
191
|
+
say " To view pending annotations: bin/markup pending"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
desc "watch", "Watch for new annotations (polls pending)"
|
|
195
|
+
long_desc <<-DESC
|
|
196
|
+
Poll for new annotations and print them as they arrive.
|
|
197
|
+
Press Ctrl+C to stop.
|
|
198
|
+
|
|
199
|
+
Examples:
|
|
200
|
+
|
|
201
|
+
bin/markup watch # local dev, 5s interval
|
|
202
|
+
|
|
203
|
+
bin/markup watch --production # production
|
|
204
|
+
|
|
205
|
+
bin/markup watch --interval 2 # poll every 2s
|
|
206
|
+
DESC
|
|
207
|
+
method_option :production, type: :boolean, default: false, desc: "Watch production"
|
|
208
|
+
method_option :url, type: :string, desc: "Override base URL"
|
|
209
|
+
method_option :token, type: :string, desc: "Override API token"
|
|
210
|
+
method_option :mount_path, type: :string, desc: "Engine mount path"
|
|
211
|
+
method_option :interval, type: :numeric, default: 5, desc: "Poll interval in seconds"
|
|
212
|
+
def watch
|
|
213
|
+
env = resolve_env(options[:production])
|
|
214
|
+
return unless env
|
|
215
|
+
|
|
216
|
+
env_label = options[:production] ? "Production" : "Development"
|
|
217
|
+
interval = [options[:interval], 1].max
|
|
218
|
+
|
|
219
|
+
$stdout.puts ""
|
|
220
|
+
$stdout.puts "#{LABEL_STYLE.render(" Watching #{env_label} ")} #{HINT_STYLE.render(env[:base_url])}"
|
|
221
|
+
$stdout.puts "#{HINT_STYLE.render(" Polling every #{interval}s. Press Ctrl+C to stop.")}"
|
|
222
|
+
$stdout.puts ""
|
|
223
|
+
|
|
224
|
+
seen_ids = Set.new
|
|
225
|
+
loop do
|
|
226
|
+
annotations = fetch_pending_from(env)
|
|
227
|
+
break unless annotations
|
|
228
|
+
|
|
229
|
+
new_annotations = annotations.reject { |ann| seen_ids.include?(ann["id"]) }
|
|
230
|
+
if new_annotations.any?
|
|
231
|
+
new_annotations.each do |ann|
|
|
232
|
+
seen_ids.add(ann["id"])
|
|
233
|
+
render_annotation(ann)
|
|
234
|
+
end
|
|
235
|
+
$stdout.puts "#{HINT_STYLE.render(" #{seen_ids.size} total seen, #{annotations.size} pending")}"
|
|
236
|
+
$stdout.puts ""
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
sleep interval
|
|
240
|
+
rescue Interrupt
|
|
241
|
+
say ""
|
|
242
|
+
say "Stopped watching.", :yellow
|
|
243
|
+
break
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
desc "pending", "Fetch pending annotations"
|
|
248
|
+
long_desc <<-DESC
|
|
249
|
+
Fetch all pending (unresolved) annotations from the Rails API.
|
|
250
|
+
|
|
251
|
+
Examples:
|
|
252
|
+
|
|
253
|
+
bin/markup pending # local dev
|
|
254
|
+
|
|
255
|
+
bin/markup pending --production # production
|
|
256
|
+
DESC
|
|
257
|
+
method_option :production, type: :boolean, default: false, desc: "Fetch from production"
|
|
258
|
+
method_option :url, type: :string, desc: "Override base URL"
|
|
259
|
+
method_option :token, type: :string, desc: "Override API token"
|
|
260
|
+
method_option :mount_path, type: :string, desc: "Engine mount path"
|
|
261
|
+
def pending
|
|
262
|
+
env = resolve_env(options[:production])
|
|
263
|
+
return unless env
|
|
264
|
+
|
|
265
|
+
annotations = fetch_pending_from(env)
|
|
266
|
+
return unless annotations
|
|
267
|
+
|
|
268
|
+
env_label = options[:production] ? "Production" : "Development"
|
|
269
|
+
$stdout.puts ""
|
|
270
|
+
$stdout.puts "#{LABEL_STYLE.render(" #{env_label} ")} #{HINT_STYLE.render(env[:base_url])}"
|
|
271
|
+
$stdout.puts ""
|
|
272
|
+
|
|
273
|
+
if annotations.empty?
|
|
274
|
+
say " No pending annotations.", :yellow
|
|
275
|
+
say ""
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
$stdout.puts annotation_table(annotations)
|
|
280
|
+
say ""
|
|
281
|
+
annotations.each { |ann| render_annotation(ann) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
desc "resolve-all", "Resolve all pending annotations"
|
|
285
|
+
long_desc <<-DESC
|
|
286
|
+
Batch-resolve every pending annotation.
|
|
287
|
+
|
|
288
|
+
Examples:
|
|
289
|
+
|
|
290
|
+
bin/markup resolve-all --summary "Shipped in v2.1"
|
|
291
|
+
|
|
292
|
+
bin/markup resolve-all --production --summary "Done"
|
|
293
|
+
DESC
|
|
294
|
+
method_option :summary, type: :string, desc: "Summary applied to all"
|
|
295
|
+
method_option :production, type: :boolean, default: false, desc: "Target production"
|
|
296
|
+
def resolve_all
|
|
297
|
+
env = resolve_env(options[:production])
|
|
298
|
+
return unless env
|
|
299
|
+
|
|
300
|
+
annotations = fetch_pending_from(env)
|
|
301
|
+
return unless annotations
|
|
302
|
+
|
|
303
|
+
if annotations.empty?
|
|
304
|
+
say "No pending annotations.", :yellow
|
|
305
|
+
return
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
resolved = 0
|
|
309
|
+
annotations.each do |ann|
|
|
310
|
+
result = patch_annotation(env, ann["id"], "resolve", summary: options[:summary])
|
|
311
|
+
if result
|
|
312
|
+
$stdout.puts "#{SUCCESS_STYLE.render("Resolved")} ##{ann["id"]} #{HINT_STYLE.render(ann["content"].to_s[0, 50])}"
|
|
313
|
+
resolved += 1
|
|
314
|
+
else
|
|
315
|
+
$stdout.puts "#{ERROR_STYLE.render("Failed")} ##{ann["id"]}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
say ""
|
|
320
|
+
say "#{resolved}/#{annotations.size} annotations resolved.", :green
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
desc "resolve ID", "Resolve an annotation with a summary"
|
|
324
|
+
long_desc <<-DESC
|
|
325
|
+
Mark a single annotation as resolved.
|
|
326
|
+
|
|
327
|
+
Examples:
|
|
328
|
+
|
|
329
|
+
bin/markup resolve 42 --summary "Fixed padding"
|
|
330
|
+
|
|
331
|
+
bin/markup resolve 42 --production
|
|
332
|
+
DESC
|
|
333
|
+
method_option :summary, type: :string, desc: "Summary of how it was resolved"
|
|
334
|
+
method_option :production, type: :boolean, default: false, desc: "Target production"
|
|
335
|
+
def resolve(id = nil)
|
|
336
|
+
return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
|
|
337
|
+
|
|
338
|
+
env = resolve_env(options[:production])
|
|
339
|
+
return unless env
|
|
340
|
+
|
|
341
|
+
result = patch_annotation(env, id, "resolve", summary: options[:summary])
|
|
342
|
+
return unless result
|
|
343
|
+
|
|
344
|
+
$stdout.puts "#{SUCCESS_STYLE.render("Resolved")} ##{id}"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
desc "dismiss ID", "Dismiss an annotation with a reason"
|
|
348
|
+
long_desc <<-DESC
|
|
349
|
+
Dismiss an annotation (won't fix, not applicable, etc).
|
|
350
|
+
|
|
351
|
+
Examples:
|
|
352
|
+
|
|
353
|
+
bin/markup dismiss 42 --reason "By design"
|
|
354
|
+
|
|
355
|
+
bin/markup dismiss 42 --production --reason "Duplicate"
|
|
356
|
+
DESC
|
|
357
|
+
method_option :reason, type: :string, desc: "Reason for dismissing"
|
|
358
|
+
method_option :production, type: :boolean, default: false, desc: "Target production"
|
|
359
|
+
def dismiss(id = nil)
|
|
360
|
+
return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
|
|
361
|
+
|
|
362
|
+
env = resolve_env(options[:production])
|
|
363
|
+
return unless env
|
|
364
|
+
|
|
365
|
+
result = patch_annotation(env, id, "dismiss", reason: options[:reason])
|
|
366
|
+
return unless result
|
|
367
|
+
|
|
368
|
+
$stdout.puts "#{SUCCESS_STYLE.render("Dismissed")} ##{id}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
desc "reply ID MESSAGE", "Reply to an annotation thread"
|
|
372
|
+
long_desc <<-DESC
|
|
373
|
+
Add a reply to an annotation's discussion thread.
|
|
374
|
+
|
|
375
|
+
Examples:
|
|
376
|
+
|
|
377
|
+
bin/markup reply 42 "Fixed in commit abc123"
|
|
378
|
+
|
|
379
|
+
bin/markup reply 42 "Deployed" --production
|
|
380
|
+
DESC
|
|
381
|
+
method_option :production, type: :boolean, default: false, desc: "Target production"
|
|
382
|
+
def reply(id = nil, message = nil)
|
|
383
|
+
return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
|
|
384
|
+
return say("#{ERROR_STYLE.render("Error:")} message is required") unless message
|
|
385
|
+
|
|
386
|
+
env = resolve_env(options[:production])
|
|
387
|
+
return unless env
|
|
388
|
+
|
|
389
|
+
result = patch_annotation(env, id, "reply", message: message)
|
|
390
|
+
return unless result
|
|
391
|
+
|
|
392
|
+
$stdout.puts "#{SUCCESS_STYLE.render("Reply sent")} to ##{id}"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
desc "acknowledge ID", "Mark an annotation as acknowledged"
|
|
396
|
+
long_desc <<-DESC
|
|
397
|
+
Mark an annotation as seen/acknowledged without resolving it.
|
|
398
|
+
|
|
399
|
+
Examples:
|
|
400
|
+
|
|
401
|
+
bin/markup acknowledge 42
|
|
402
|
+
|
|
403
|
+
bin/markup acknowledge 42 --production
|
|
404
|
+
DESC
|
|
405
|
+
method_option :production, type: :boolean, default: false, desc: "Target production"
|
|
406
|
+
def acknowledge(id = nil)
|
|
407
|
+
return say("#{ERROR_STYLE.render("Error:")} annotation ID is required") unless id
|
|
408
|
+
|
|
409
|
+
env = resolve_env(options[:production])
|
|
410
|
+
return unless env
|
|
411
|
+
|
|
412
|
+
result = patch_annotation(env, id, "acknowledge")
|
|
413
|
+
return unless result
|
|
414
|
+
|
|
415
|
+
$stdout.puts "#{SUCCESS_STYLE.render("Acknowledged")} ##{id}"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# ── Shorthand aliases ─────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
desc "fetch [ENVIRONMENT]", "Fetch pending annotations (default: local dev)"
|
|
421
|
+
long_desc <<-DESC
|
|
422
|
+
Fetch pending annotations from local dev or production.
|
|
423
|
+
|
|
424
|
+
Examples:
|
|
425
|
+
bin/markup fetch # local dev
|
|
426
|
+
bin/markup fetch production # production
|
|
427
|
+
bin/markup fetch -e production # also production
|
|
428
|
+
DESC
|
|
429
|
+
method_option :production, type: :boolean, default: false, desc: "Fetch from production"
|
|
430
|
+
method_option :environment, type: :string, aliases: "-e", enum: %w[dev production],
|
|
431
|
+
desc: "Environment to fetch from"
|
|
432
|
+
method_option :url, type: :string, desc: "Override base URL"
|
|
433
|
+
method_option :token, type: :string, desc: "Override API token"
|
|
434
|
+
method_option :mount_path, type: :string, desc: "Engine mount path (default: /admin/annotations)"
|
|
435
|
+
def fetch(env_arg = nil)
|
|
436
|
+
production = env_arg == "production" || options[:production] || options[:environment] == "production"
|
|
437
|
+
env = resolve_env(production)
|
|
438
|
+
return unless env
|
|
439
|
+
|
|
440
|
+
annotations = fetch_pending_from(env)
|
|
441
|
+
return unless annotations
|
|
442
|
+
|
|
443
|
+
env_label = production ? "Production" : "Development"
|
|
444
|
+
$stdout.puts ""
|
|
445
|
+
$stdout.puts "#{LABEL_STYLE.render(" #{env_label} ")} #{HINT_STYLE.render(env[:base_url])}"
|
|
446
|
+
$stdout.puts ""
|
|
447
|
+
|
|
448
|
+
if annotations.empty?
|
|
449
|
+
say " No pending annotations.", :yellow
|
|
450
|
+
say ""
|
|
451
|
+
return
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
$stdout.puts annotation_table(annotations)
|
|
455
|
+
say ""
|
|
456
|
+
annotations.each { |ann| render_annotation(ann) }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
desc "setup-production", "Generate a token and configure production access"
|
|
460
|
+
long_desc <<-DESC
|
|
461
|
+
Generate a secure API token, save it to MCP config, and print
|
|
462
|
+
instructions for adding it to your Rails credentials.
|
|
463
|
+
|
|
464
|
+
Examples:
|
|
465
|
+
|
|
466
|
+
bin/markup setup-production --url=https://yourapp.com
|
|
467
|
+
|
|
468
|
+
bin/markup setup-production --url=https://yourapp.com --global
|
|
469
|
+
|
|
470
|
+
bin/markup setup-production --url=https://yourapp.com --codex
|
|
471
|
+
DESC
|
|
472
|
+
method_option :url, type: :string, desc: "Production URL (e.g. https://yourapp.com)"
|
|
473
|
+
method_option :global, type: :boolean, default: false, desc: "Write to ~/.claude/settings.json"
|
|
474
|
+
method_option :codex, type: :boolean, default: false, desc: "Write to ~/.codex/config.toml"
|
|
475
|
+
def setup_production
|
|
476
|
+
require "securerandom"
|
|
477
|
+
|
|
478
|
+
prod_url = options[:url]
|
|
479
|
+
unless prod_url
|
|
480
|
+
say "Usage: bin/markup setup-production --url=https://yourapp.com", :red
|
|
481
|
+
return
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
token = SecureRandom.hex(24)
|
|
485
|
+
|
|
486
|
+
config = McpConfig.new(scope: resolve_scope)
|
|
487
|
+
config.update_env({
|
|
488
|
+
"RAILS_MARKUP_PROD_URL" => prod_url,
|
|
489
|
+
"RAILS_MARKUP_PROD_TOKEN" => token
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
say ""
|
|
493
|
+
say "Production token generated!", :green
|
|
494
|
+
say ""
|
|
495
|
+
say "Token: #{token}"
|
|
496
|
+
say ""
|
|
497
|
+
say "Saved to #{config.scope_label} — CLI and MCP tools are ready."
|
|
498
|
+
say ""
|
|
499
|
+
say "Now add the same token to your Rails app:"
|
|
500
|
+
say ""
|
|
501
|
+
say " Option A: Rails credentials (recommended)"
|
|
502
|
+
say " rails credentials:edit"
|
|
503
|
+
say " # Add:"
|
|
504
|
+
say " # rails_markup:"
|
|
505
|
+
say " # api_token: #{token}"
|
|
506
|
+
say ""
|
|
507
|
+
say " Option B: Environment variable"
|
|
508
|
+
say " RAILS_MARKUP_API_TOKEN=#{token}"
|
|
509
|
+
say ""
|
|
510
|
+
say "Then in config/initializers/rails_markup.rb:"
|
|
511
|
+
say ' config.api_token = Rails.application.credentials.dig(:rails_markup, :api_token)'
|
|
512
|
+
say " # or"
|
|
513
|
+
say ' config.api_token = ENV["RAILS_MARKUP_API_TOKEN"]'
|
|
514
|
+
say ""
|
|
515
|
+
say "Deploy, then verify:"
|
|
516
|
+
say " bin/markup pending --production"
|
|
517
|
+
say ""
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
desc "man", "Full command reference with examples"
|
|
521
|
+
def man
|
|
522
|
+
require_relative "version"
|
|
523
|
+
$stdout.puts render_man_page
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
desc "version", "Print version"
|
|
527
|
+
def version
|
|
528
|
+
require_relative "version"
|
|
529
|
+
say "rails-markup #{RailsMarkup::VERSION}"
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def self.exit_on_failure?
|
|
533
|
+
true
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
private
|
|
537
|
+
|
|
538
|
+
def render_man_page
|
|
539
|
+
require_relative "version"
|
|
540
|
+
sections = []
|
|
541
|
+
|
|
542
|
+
sections << HEADER_STYLE.render(" RAILS-MARKUP(1) — v#{RailsMarkup::VERSION} ")
|
|
543
|
+
sections << ""
|
|
544
|
+
sections << " Point-and-click annotation tool for AI agents."
|
|
545
|
+
sections << " Annotate Rails views in the browser; AI editors read and act on your feedback."
|
|
546
|
+
sections << ""
|
|
547
|
+
|
|
548
|
+
sections << LABEL_STYLE.render(" GETTING STARTED ")
|
|
549
|
+
sections << ""
|
|
550
|
+
sections << " #{HINT_STYLE.render("# 1. Add to Gemfile")}"
|
|
551
|
+
sections << ' gem "rails-markup"'
|
|
552
|
+
sections << ""
|
|
553
|
+
sections << " #{HINT_STYLE.render("# 2. Run install generator")}"
|
|
554
|
+
sections << " rails generate rails_markup:install"
|
|
555
|
+
sections << " rails db:migrate"
|
|
556
|
+
sections << ""
|
|
557
|
+
sections << " #{HINT_STYLE.render("# 3. Interactive setup (toolbar + MCP config)")}"
|
|
558
|
+
sections << " bin/markup init"
|
|
559
|
+
sections << ""
|
|
560
|
+
sections << " #{HINT_STYLE.render("# 4. Or configure manually")}"
|
|
561
|
+
sections << " bin/markup configure --dev-url http://localhost:3000"
|
|
562
|
+
sections << " bin/markup setup-production --url https://yourapp.com"
|
|
563
|
+
sections << ""
|
|
564
|
+
|
|
565
|
+
sections << LABEL_STYLE.render(" SETUP ")
|
|
566
|
+
sections << ""
|
|
567
|
+
sections << " init Interactive TUI wizard"
|
|
568
|
+
sections << " configure [OPTIONS] Set MCP env vars"
|
|
569
|
+
sections << " --prod-url URL Production URL"
|
|
570
|
+
sections << " --prod-token TOKEN Production API token"
|
|
571
|
+
sections << " --dev-url URL Development URL"
|
|
572
|
+
sections << " --mount-path PATH Engine mount path"
|
|
573
|
+
sections << " --global Write to ~/.claude/settings.json"
|
|
574
|
+
sections << " --codex Write to ~/.codex/config.toml"
|
|
575
|
+
sections << " setup-production --url URL Generate token + configure production"
|
|
576
|
+
sections << " --global / --codex Same scope flags as configure"
|
|
577
|
+
sections << " status Show config across all scopes"
|
|
578
|
+
sections << ""
|
|
579
|
+
|
|
580
|
+
sections << LABEL_STYLE.render(" SERVERS ")
|
|
581
|
+
sections << ""
|
|
582
|
+
sections << " server [--port N] Start HTTP + MCP server (default: 4747)"
|
|
583
|
+
sections << " mcp [--port N] Start MCP-only server (stdio, for editors)"
|
|
584
|
+
sections << ""
|
|
585
|
+
|
|
586
|
+
sections << LABEL_STYLE.render(" ANNOTATIONS ")
|
|
587
|
+
sections << ""
|
|
588
|
+
sections << " pending [--production] Fetch pending annotations"
|
|
589
|
+
sections << " fetch [ENV] Alias for pending (accepts 'production' arg)"
|
|
590
|
+
sections << " watch [--production] Poll and print new annotations (Ctrl+C to stop)"
|
|
591
|
+
sections << " --interval N Seconds between polls (default: 5)"
|
|
592
|
+
sections << " resolve ID [--summary S] Resolve an annotation"
|
|
593
|
+
sections << " resolve-all [--summary S] Batch-resolve all pending"
|
|
594
|
+
sections << " dismiss ID [--reason R] Dismiss an annotation"
|
|
595
|
+
sections << " reply ID MESSAGE Reply to an annotation thread"
|
|
596
|
+
sections << " acknowledge ID Mark as seen"
|
|
597
|
+
sections << ""
|
|
598
|
+
sections << " #{HINT_STYLE.render("All annotation commands accept --production to target prod.")}"
|
|
599
|
+
sections << ""
|
|
600
|
+
|
|
601
|
+
sections << LABEL_STYLE.render(" MCP CONFIG SCOPES ")
|
|
602
|
+
sections << ""
|
|
603
|
+
sections << " local .mcp.json This project only"
|
|
604
|
+
sections << " global ~/.claude/settings.json Claude Code (all projects)"
|
|
605
|
+
sections << " codex ~/.codex/config.toml Codex CLI (all projects)"
|
|
606
|
+
sections << ""
|
|
607
|
+
sections << " #{HINT_STYLE.render("Commands fall back through local → global → codex when resolving env.")}"
|
|
608
|
+
sections << ""
|
|
609
|
+
|
|
610
|
+
sections << LABEL_STYLE.render(" QUICK RECIPES ")
|
|
611
|
+
sections << ""
|
|
612
|
+
sections << " #{HINT_STYLE.render("# Dev setup")}"
|
|
613
|
+
sections << " bin/markup configure --dev-url http://localhost:3000"
|
|
614
|
+
sections << ""
|
|
615
|
+
sections << " #{HINT_STYLE.render("# Production setup (writes to global Claude Code config)")}"
|
|
616
|
+
sections << " bin/markup setup-production --url https://myapp.com --global"
|
|
617
|
+
sections << ""
|
|
618
|
+
sections << " #{HINT_STYLE.render("# Watch for annotations in real-time")}"
|
|
619
|
+
sections << " bin/markup watch --production --interval 2"
|
|
620
|
+
sections << ""
|
|
621
|
+
sections << " #{HINT_STYLE.render("# Resolve everything after a deploy")}"
|
|
622
|
+
sections << " bin/markup resolve-all --production --summary \"Shipped in v2.1\""
|
|
623
|
+
sections << ""
|
|
624
|
+
|
|
625
|
+
sections << LABEL_STYLE.render(" INFO ")
|
|
626
|
+
sections << ""
|
|
627
|
+
sections << " man This reference page"
|
|
628
|
+
sections << " version Print version"
|
|
629
|
+
sections << " help [COMMAND] Thor help for a specific command"
|
|
630
|
+
sections << ""
|
|
631
|
+
|
|
632
|
+
sections.join("\n")
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# ── Environment resolution ────────────────────────────────
|
|
636
|
+
|
|
637
|
+
def resolve_scope
|
|
638
|
+
return "global" if options[:global]
|
|
639
|
+
return "codex" if options[:codex]
|
|
640
|
+
|
|
641
|
+
"local"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Check local first, then fall back to global/codex configs.
|
|
645
|
+
def resolve_mcp_env
|
|
646
|
+
McpConfig::SCOPES.each do |scope|
|
|
647
|
+
config = McpConfig.new(scope: scope)
|
|
648
|
+
next unless config.exist?
|
|
649
|
+
|
|
650
|
+
env = config.raw_env
|
|
651
|
+
return env unless env.empty?
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
{}
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def resolve_env(production)
|
|
658
|
+
mcp_env = resolve_mcp_env
|
|
659
|
+
|
|
660
|
+
if production
|
|
661
|
+
base_url = options[:url] || mcp_env["RAILS_MARKUP_PROD_URL"]
|
|
662
|
+
token = options[:token] || mcp_env["RAILS_MARKUP_PROD_TOKEN"]
|
|
663
|
+
mount = options[:mount_path] || mcp_env["RAILS_MARKUP_MOUNT_PATH"] || "/admin/annotations"
|
|
664
|
+
|
|
665
|
+
unless base_url
|
|
666
|
+
say "No production URL. Set it via:", :red
|
|
667
|
+
say " bin/markup configure --prod-url URL"
|
|
668
|
+
return nil
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
unless token
|
|
672
|
+
say "No production token configured.", :red
|
|
673
|
+
say " bin/markup configure --prod-token TOKEN"
|
|
674
|
+
return nil
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
{ base_url: base_url, token: token, mount_path: mount }
|
|
678
|
+
else
|
|
679
|
+
base_url = options[:url] || mcp_env["RAILS_MARKUP_DEV_URL"]
|
|
680
|
+
token = options[:token] || mcp_env["RAILS_MARKUP_DEV_TOKEN"]
|
|
681
|
+
mount = options[:mount_path] || mcp_env["RAILS_MARKUP_MOUNT_PATH"] || "/admin/annotations"
|
|
682
|
+
|
|
683
|
+
unless base_url
|
|
684
|
+
say "No dev URL. Set it via:", :red
|
|
685
|
+
say " bin/markup configure --dev-url URL"
|
|
686
|
+
return nil
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
{ base_url: base_url, token: token, mount_path: mount }
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def api_base(env)
|
|
694
|
+
"#{env[:base_url]}#{env[:mount_path]}/external"
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# ── HTTP helpers ──────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
def fetch_pending_from(env)
|
|
700
|
+
require "net/http"
|
|
701
|
+
require "uri"
|
|
702
|
+
require "json"
|
|
703
|
+
|
|
704
|
+
url = "#{api_base(env)}/pending"
|
|
705
|
+
resp = http_get(url, token: env[:token])
|
|
706
|
+
|
|
707
|
+
unless resp.is_a?(Net::HTTPSuccess)
|
|
708
|
+
say "API error: #{resp.code} #{resp.body}", :red
|
|
709
|
+
return nil
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
data = JSON.parse(resp.body)
|
|
713
|
+
data["annotations"] || []
|
|
714
|
+
rescue => e
|
|
715
|
+
say "Connection error: #{e.message}", :red
|
|
716
|
+
nil
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def patch_annotation(env, id, action, **params)
|
|
720
|
+
require "net/http"
|
|
721
|
+
require "uri"
|
|
722
|
+
require "json"
|
|
723
|
+
|
|
724
|
+
url = "#{api_base(env)}/#{id}/#{action}"
|
|
725
|
+
resp = http_patch(url, token: env[:token], params: params.compact)
|
|
726
|
+
|
|
727
|
+
unless resp.is_a?(Net::HTTPSuccess)
|
|
728
|
+
say "API error: #{resp.code} #{resp.body}", :red
|
|
729
|
+
return nil
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
JSON.parse(resp.body)
|
|
733
|
+
rescue => e
|
|
734
|
+
say "Connection error: #{e.message}", :red
|
|
735
|
+
nil
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def http_get(url, token: nil)
|
|
739
|
+
uri = URI.parse(url)
|
|
740
|
+
req = Net::HTTP::Get.new(uri)
|
|
741
|
+
req["Authorization"] = "Bearer #{token}" if token
|
|
742
|
+
req["Accept"] = "application/json"
|
|
743
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
744
|
+
http.use_ssl = uri.scheme == "https"
|
|
745
|
+
http.open_timeout = 10
|
|
746
|
+
http.read_timeout = 15
|
|
747
|
+
http.request(req)
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def http_patch(url, token: nil, params: {})
|
|
751
|
+
uri = URI.parse(url)
|
|
752
|
+
req = Net::HTTP::Patch.new(uri)
|
|
753
|
+
req["Authorization"] = "Bearer #{token}" if token
|
|
754
|
+
req["Content-Type"] = "application/json"
|
|
755
|
+
req["Accept"] = "application/json"
|
|
756
|
+
req.body = params.to_json unless params.empty?
|
|
757
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
758
|
+
http.use_ssl = uri.scheme == "https"
|
|
759
|
+
http.open_timeout = 10
|
|
760
|
+
http.read_timeout = 15
|
|
761
|
+
http.request(req)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# ── Rendering ─────────────────────────────────────────────
|
|
765
|
+
|
|
766
|
+
def annotation_table(annotations)
|
|
767
|
+
rows = annotations.map do |ann|
|
|
768
|
+
page = URI.parse(ann["pageUrl"]).path rescue ann["pageUrl"]
|
|
769
|
+
["##{ann["id"]}", ann["intent"], ann["severity"], truncate(page, 30)]
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
Lipgloss::Table.new
|
|
773
|
+
.headers(["ID", "Intent", "Severity", "Page"])
|
|
774
|
+
.rows(rows)
|
|
775
|
+
.border(:rounded)
|
|
776
|
+
.style_func(rows: rows.size, columns: 4) do |row, _col|
|
|
777
|
+
if row == Lipgloss::Table::HEADER_ROW
|
|
778
|
+
HEADER_STYLE
|
|
779
|
+
else
|
|
780
|
+
row.odd? ? MASKED_STYLE : ODD_STYLE
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
.render
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def render_annotation(ann)
|
|
787
|
+
target = ann["target"] || {}
|
|
788
|
+
page = URI.parse(ann["pageUrl"]).path rescue ann["pageUrl"]
|
|
789
|
+
nearby = target["nearbyText"]&.strip&.gsub(/\s+/, " ")&.slice(0, 80)
|
|
790
|
+
|
|
791
|
+
say "─" * 60
|
|
792
|
+
$stdout.puts "#{LABEL_STYLE.render(" ##{ann["id"]} ")} #{ann["intent"]} | #{ann["severity"]} #{HINT_STYLE.render(page)}"
|
|
793
|
+
say ""
|
|
794
|
+
say " #{ann["content"]}"
|
|
795
|
+
say ""
|
|
796
|
+
say " Author: #{ann["authorName"]}" if ann["authorName"]
|
|
797
|
+
say " CSS path: #{target["cssPath"]}" if target["cssPath"]
|
|
798
|
+
say " Selector: #{target["selector"]}" if target["selector"]
|
|
799
|
+
say " Text near: \"#{nearby}\"" if nearby
|
|
800
|
+
say " Selected: \"#{ann["selectedText"]}\"" if ann["selectedText"]
|
|
801
|
+
say " Created: #{ann["createdAt"]}"
|
|
802
|
+
say ""
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def env_table(env_hash, label: ".mcp.json")
|
|
806
|
+
rows = env_hash.map { |k, v| [k, v] }
|
|
807
|
+
table = Lipgloss::Table.new
|
|
808
|
+
.headers(["Variable", "Value"])
|
|
809
|
+
.rows(rows)
|
|
810
|
+
.border(:rounded)
|
|
811
|
+
.style_func(rows: rows.size, columns: 2) do |row, _col|
|
|
812
|
+
if row == Lipgloss::Table::HEADER_ROW
|
|
813
|
+
HEADER_STYLE
|
|
814
|
+
else
|
|
815
|
+
row.odd? ? MASKED_STYLE : ODD_STYLE
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
.render
|
|
819
|
+
|
|
820
|
+
title = LABEL_STYLE.render(" Rails Markup ")
|
|
821
|
+
path = HINT_STYLE.render(label)
|
|
822
|
+
"#{title} #{path}\n\n#{table}"
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def truncate(str, limit)
|
|
826
|
+
return str if str.length <= limit
|
|
827
|
+
|
|
828
|
+
"#{str[0, limit - 3]}..."
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
end
|