pinmark 0.1.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/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/app/controllers/concerns/pinmark/session.rb +32 -0
- data/app/controllers/pinmark/annotations_controller.rb +102 -0
- data/app/controllers/pinmark/application_controller.rb +19 -0
- data/app/javascript/pinmark/pinmark_controller.js +857 -0
- data/app/views/pinmark/_activator.html.erb +14 -0
- data/app/views/pinmark/_overlay.html.erb +24 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +11 -0
- data/lib/generators/pinmark/install/install_generator.rb +82 -0
- data/lib/pinmark/engine.rb +38 -0
- data/lib/pinmark/hooks/erb_partial.rb +39 -0
- data/lib/pinmark/hooks/view_component.rb +21 -0
- data/lib/pinmark/mcp/queue.rb +90 -0
- data/lib/pinmark/mcp/rack_app.rb +36 -0
- data/lib/pinmark/mcp/server.rb +29 -0
- data/lib/pinmark/mcp/tools/clear_addressed.rb +28 -0
- data/lib/pinmark/mcp/tools/list_pending.rb +44 -0
- data/lib/pinmark/mcp/tools/list_resolved.rb +41 -0
- data/lib/pinmark/mcp/tools/mark_addressed.rb +41 -0
- data/lib/pinmark/phlex.rb +44 -0
- data/lib/pinmark/source_locator.rb +17 -0
- data/lib/pinmark/stylesheets.rb +83 -0
- data/lib/pinmark/tracker.rb +41 -0
- data/lib/pinmark/version.rb +5 -0
- data/lib/pinmark/wrapper.rb +40 -0
- data/lib/pinmark.rb +36 -0
- data/package.json +14 -0
- metadata +147 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%
|
|
2
|
+
enabled = Pinmark.tracker.present?
|
|
3
|
+
css_class = enabled ? "pinmark-activator is-on" : "pinmark-activator"
|
|
4
|
+
label = enabled ? "Disable annotations" : "Enable annotations"
|
|
5
|
+
onclick = "var on=document.cookie.split('; ').some(function(c){return c.indexOf('pinmark=1')===0;});" \
|
|
6
|
+
"document.cookie=on?'pinmark=; path=/; max-age=0':'pinmark=1; path=/; max-age=2592000';" \
|
|
7
|
+
"window.location.assign(window.location.pathname+window.location.search);return false;"
|
|
8
|
+
%>
|
|
9
|
+
<button type="button"
|
|
10
|
+
id="pinmark-activator"
|
|
11
|
+
class="<%= css_class %>"
|
|
12
|
+
data-turbo="false"
|
|
13
|
+
onclick="<%= onclick.html_safe %>"><%= label %></button>
|
|
14
|
+
<style><%= Pinmark::Stylesheets::ACTIVATOR.html_safe %></style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<% tracker = Pinmark.tracker %>
|
|
2
|
+
<% if tracker %>
|
|
3
|
+
<div data-controller="pinmark">
|
|
4
|
+
<script type="application/json" id="pinmark-tree"><%= ERB::Util.json_escape(tracker.tree.to_json).html_safe %></script>
|
|
5
|
+
<button type="button" data-action="click->pinmark#toggle" class="pinmark-toggle">Annotate</button>
|
|
6
|
+
<button type="button" data-action="click->pinmark#cycleTargetMode" data-pinmark-target="modeButton" class="pinmark-mode">Mode: Component</button>
|
|
7
|
+
<div class="pinmark-panel" data-pinmark-target="panel"></div>
|
|
8
|
+
<template data-pinmark-target="popoverTemplate">
|
|
9
|
+
<div class="pinmark-popover">
|
|
10
|
+
<div class="pinmark-popover-header" data-pinmark-target="popoverHeader"></div>
|
|
11
|
+
<textarea data-pinmark-target="popoverInput" placeholder="Comment…"></textarea>
|
|
12
|
+
<details class="pinmark-popover-debug">
|
|
13
|
+
<summary>Debug</summary>
|
|
14
|
+
<div data-pinmark-target="popoverDebug"></div>
|
|
15
|
+
</details>
|
|
16
|
+
<div class="pinmark-popover-actions">
|
|
17
|
+
<button type="button" data-action="click->pinmark#savePopover">Save</button>
|
|
18
|
+
<button type="button" data-action="click->pinmark#cancelPopover">Cancel</button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
<style><%= Pinmark::Stylesheets::OVERLAY.html_safe %></style>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
data/config/importmap.rb
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Pinmark::Engine.routes.draw do
|
|
4
|
+
if Rails.env.local?
|
|
5
|
+
resources :annotations, only: [:index, :create, :destroy]
|
|
6
|
+
|
|
7
|
+
# MCP server mounted in-process so Claude Code can speak MCP directly
|
|
8
|
+
# to the host Rails app. No separate Foreman/Overmind worker required.
|
|
9
|
+
mount Pinmark::Mcp::RackApp.new, at: "annotations/mcp"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Pinmark
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Wire Pinmark into the host app: routes, importmap, layout reminders."
|
|
11
|
+
|
|
12
|
+
def mount_engine
|
|
13
|
+
route 'mount Pinmark::Engine, at: "/dev/pinmark" if Rails.env.local?'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_importmap_pin
|
|
17
|
+
return unless File.exist?("config/importmap.rb")
|
|
18
|
+
|
|
19
|
+
append_to_file "config/importmap.rb" do
|
|
20
|
+
<<~RB
|
|
21
|
+
|
|
22
|
+
# pinmark engine — Stimulus controller for the dev annotation overlay
|
|
23
|
+
pin "controllers/pinmark_controller", to: "pinmark/pinmark_controller.js"
|
|
24
|
+
RB
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show_post_install_notes
|
|
29
|
+
say "\nPinmark installed.", :green
|
|
30
|
+
say <<~NOTES
|
|
31
|
+
|
|
32
|
+
Next steps (manual):
|
|
33
|
+
|
|
34
|
+
1. If you use Phlex, include the concern in your component base class
|
|
35
|
+
so `<!-- pinmark:begin/end -->` markers wrap each render:
|
|
36
|
+
|
|
37
|
+
class Components::Base < Phlex::HTML
|
|
38
|
+
include Pinmark::Phlex if Rails.env.development?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
2. Render the activator + overlay partials in your dev-only layout
|
|
42
|
+
sections (works in any Rails view — ERB, Phlex, or ViewComponent):
|
|
43
|
+
|
|
44
|
+
<%= render "pinmark/activator" %>
|
|
45
|
+
<%= render "pinmark/overlay" %>
|
|
46
|
+
|
|
47
|
+
Wrap with the usual dev-only / tracker guard if desired:
|
|
48
|
+
|
|
49
|
+
if Rails.env.development? && Current.pinmark.present?
|
|
50
|
+
render "pinmark/activator"
|
|
51
|
+
render "pinmark/overlay"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
3. Add `attribute :pinmark` to your `Current` model and include
|
|
55
|
+
`Pinmark::Session` in the controllers whose responses should
|
|
56
|
+
support annotations (typically StorefrontController).
|
|
57
|
+
|
|
58
|
+
4. Register the in-process MCP server with Claude Code:
|
|
59
|
+
|
|
60
|
+
claude mcp add pinmark --transport http \\
|
|
61
|
+
http://localhost:PORT/dev/pinmark/annotations/mcp
|
|
62
|
+
|
|
63
|
+
5. Webpack/yarn-based hosts (no importmap): add the engine as a
|
|
64
|
+
`file:` dependency in your host's package.json, then import the
|
|
65
|
+
Stimulus controller directly from the gem — do NOT copy the JS
|
|
66
|
+
into the host. Example:
|
|
67
|
+
|
|
68
|
+
# package.json
|
|
69
|
+
"pinmark": "file:#{Pinmark::Engine.root}"
|
|
70
|
+
|
|
71
|
+
# app/javascript/packs/your_pack.js
|
|
72
|
+
import PinmarkController from 'pinmark/pinmark_controller'
|
|
73
|
+
window.Stimulus.register('pinmark', PinmarkController)
|
|
74
|
+
|
|
75
|
+
Run `yarn install` after adding the dependency. The engine is the
|
|
76
|
+
single source of truth; updates flow through the symlink.
|
|
77
|
+
|
|
78
|
+
NOTES
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Pinmark
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Pinmark
|
|
8
|
+
|
|
9
|
+
initializer "pinmark.hooks" do
|
|
10
|
+
ActiveSupport.on_load(:action_view) do
|
|
11
|
+
require "pinmark/hooks/erb_partial"
|
|
12
|
+
ActionView::PartialRenderer.prepend(Pinmark::Hooks::ErbPartial)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
ActiveSupport.on_load(:view_component) do
|
|
16
|
+
require "pinmark/hooks/view_component"
|
|
17
|
+
ViewComponent::Base.prepend(Pinmark::Hooks::ViewComponent)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Make the engine's Stimulus controller available to host apps that use
|
|
22
|
+
# `pin_all_from "app/javascript/controllers"`. We expose it under
|
|
23
|
+
# `pinmark/` so the host can pin it explicitly without naming collisions.
|
|
24
|
+
initializer "pinmark.assets" do |app|
|
|
25
|
+
next unless app.config.respond_to?(:assets)
|
|
26
|
+
|
|
27
|
+
app.config.assets.paths << root.join("app/javascript")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Importmap-rails integration: register the engine's JS so importmap can
|
|
31
|
+
# serve it under `pinmark/...`. The host pins it.
|
|
32
|
+
initializer "pinmark.importmap", before: "importmap" do |app|
|
|
33
|
+
next unless defined?(Importmap)
|
|
34
|
+
|
|
35
|
+
app.config.importmap.paths << root.join("config/importmap.rb") if app.config.respond_to?(:importmap)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinmark
|
|
4
|
+
module Hooks
|
|
5
|
+
# Prepended into ActionView::PartialRenderer.
|
|
6
|
+
#
|
|
7
|
+
# Rails 8.1 `render_partial_template(view, locals, template, layout, block)`
|
|
8
|
+
# is private and returns an `ActionView::AbstractRenderer::RenderedTemplate`
|
|
9
|
+
# whose `body` is the rendered HTML string. To preserve the contract for
|
|
10
|
+
# callers (collection assembly, layout wrapping) the override re-wraps the
|
|
11
|
+
# body and returns a fresh RenderedTemplate.
|
|
12
|
+
module ErbPartial
|
|
13
|
+
def render_partial_template(view, locals, template, layout, block)
|
|
14
|
+
rendered = super
|
|
15
|
+
return rendered unless Pinmark.active?
|
|
16
|
+
return rendered unless rendered.respond_to?(:body) && rendered.respond_to?(:template)
|
|
17
|
+
|
|
18
|
+
identifier = template.respond_to?(:identifier) ? template.identifier.to_s : template.to_s
|
|
19
|
+
relative = relative_to_root(identifier)
|
|
20
|
+
component = "partial:#{File.basename(relative)}"
|
|
21
|
+
source = "#{relative}:1"
|
|
22
|
+
|
|
23
|
+
wrapped_body = Pinmark::Wrapper.wrap(component:, source:) do
|
|
24
|
+
rendered.body.to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
::ActionView::AbstractRenderer::RenderedTemplate.new(wrapped_body, rendered.template)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def relative_to_root(identifier)
|
|
33
|
+
Pathname.new(identifier).relative_path_from(Rails.root).to_s
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
identifier
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinmark
|
|
4
|
+
module Hooks
|
|
5
|
+
# Prepended into ViewComponent::Base when the gem is present. Inert until
|
|
6
|
+
# then. Mirrors the Phlex `Components::Base#around_template` hook by wrapping
|
|
7
|
+
# `render_in`'s output in pinmark markers.
|
|
8
|
+
module ViewComponent
|
|
9
|
+
def render_in(view_context, &)
|
|
10
|
+
return super unless Pinmark.active?
|
|
11
|
+
|
|
12
|
+
component = self.class.name || "anonymous"
|
|
13
|
+
source = Pinmark::SourceLocator.for(self.class) || "unknown"
|
|
14
|
+
|
|
15
|
+
Pinmark::Wrapper.wrap(component:, source:) do
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Pinmark
|
|
8
|
+
module Mcp
|
|
9
|
+
# Persists the pinmark queue file with atomic writes so concurrent
|
|
10
|
+
# readers (the Rails endpoint and the MCP tools) never see a half-written
|
|
11
|
+
# JSON document.
|
|
12
|
+
class Queue
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
def self.default_path
|
|
16
|
+
Rails.root.join("tmp/pinmark/queue.json")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(path = nil)
|
|
20
|
+
@path = Pathname.new(path || self.class.default_path).expand_path
|
|
21
|
+
ensure_file
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ensure_file
|
|
25
|
+
FileUtils.mkdir_p(@path.dirname)
|
|
26
|
+
return if @path.exist?
|
|
27
|
+
|
|
28
|
+
write({ "annotations" => [] })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read
|
|
32
|
+
ensure_file
|
|
33
|
+
parsed = JSON.parse(@path.read)
|
|
34
|
+
parsed["annotations"] = [] unless parsed["annotations"].is_a?(Array)
|
|
35
|
+
parsed
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
{ "annotations" => [] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write(data)
|
|
41
|
+
tmp = Pathname.new("#{@path}.tmp")
|
|
42
|
+
tmp.write(JSON.pretty_generate(data))
|
|
43
|
+
File.rename(tmp, @path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pending
|
|
47
|
+
read.fetch("annotations", []).select { |entry| entry["status"] == "pending" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def addressed
|
|
51
|
+
read.fetch("annotations", []).select { |entry| entry["status"] == "addressed" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append(entries)
|
|
55
|
+
data = read
|
|
56
|
+
data["annotations"] = data.fetch("annotations", []) + Array(entries)
|
|
57
|
+
write(data)
|
|
58
|
+
data["annotations"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mark_addressed(id)
|
|
62
|
+
data = read
|
|
63
|
+
found = false
|
|
64
|
+
already_addressed = false
|
|
65
|
+
|
|
66
|
+
data["annotations"] = data.fetch("annotations", []).map do |entry|
|
|
67
|
+
next entry unless entry["id"] == id
|
|
68
|
+
|
|
69
|
+
found = true
|
|
70
|
+
already_addressed = true if entry["status"] == "addressed"
|
|
71
|
+
entry.merge("status" => "addressed", "addressed_at" => Time.now.utc.iso8601)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return { found: false } unless found
|
|
75
|
+
|
|
76
|
+
write(data)
|
|
77
|
+
{ found: true, already_addressed: }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def clear_addressed
|
|
81
|
+
data = read
|
|
82
|
+
before = data.fetch("annotations", []).size
|
|
83
|
+
data["annotations"] = data.fetch("annotations", []).reject { |entry| entry["status"] == "addressed" }
|
|
84
|
+
removed = before - data["annotations"].size
|
|
85
|
+
write(data)
|
|
86
|
+
{ removed:, remaining: data["annotations"].size }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "mcp/server/transports/streamable_http_transport"
|
|
5
|
+
|
|
6
|
+
module Pinmark
|
|
7
|
+
module Mcp
|
|
8
|
+
# Rack-mountable adapter that exposes the pinmark server over the
|
|
9
|
+
# official `mcp` gem's Streamable HTTP transport. Designed to be
|
|
10
|
+
# mounted inside a Rails router (or any Rack stack), so the standalone
|
|
11
|
+
# Puma process the gem ships is unnecessary.
|
|
12
|
+
#
|
|
13
|
+
# This class deliberately avoids any Rails-app-specific references
|
|
14
|
+
# (controllers, Current attributes, etc.) so the entire Pinmark::Mcp
|
|
15
|
+
# namespace can later be extracted as a Rails engine / standalone gem.
|
|
16
|
+
class RackApp
|
|
17
|
+
# Run the transport in stateless mode: every request stands on its own,
|
|
18
|
+
# no per-session state is held in memory. This makes the mount
|
|
19
|
+
# resilient to Rails' dev-mode code reloading (which can rebuild the
|
|
20
|
+
# mounted Rack instance) and keeps the in-process server cheap to
|
|
21
|
+
# operate. The annotation tools are simple request/response —
|
|
22
|
+
# they do not need streaming notifications.
|
|
23
|
+
def initialize(queue: Queue.new)
|
|
24
|
+
@queue = queue
|
|
25
|
+
@server = Server.build(queue: @queue)
|
|
26
|
+
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
27
|
+
@server,
|
|
28
|
+
stateless: true,
|
|
29
|
+
enable_json_response: true
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
delegate :call, to: :@transport
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module Pinmark
|
|
6
|
+
module Mcp
|
|
7
|
+
# Builds an MCP::Server instance with the annotation tools registered.
|
|
8
|
+
# The server is configured via a Queue instance passed through
|
|
9
|
+
# server_context so tools share a single atomic on-disk queue.
|
|
10
|
+
class Server
|
|
11
|
+
NAME = "pinmark"
|
|
12
|
+
VERSION = "0.1.0"
|
|
13
|
+
|
|
14
|
+
def self.build(queue:)
|
|
15
|
+
::MCP::Server.new(
|
|
16
|
+
name: NAME,
|
|
17
|
+
version: VERSION,
|
|
18
|
+
tools: [
|
|
19
|
+
Tools::ListPending,
|
|
20
|
+
Tools::ListResolved,
|
|
21
|
+
Tools::MarkAddressed,
|
|
22
|
+
Tools::ClearAddressed
|
|
23
|
+
],
|
|
24
|
+
server_context: { queue: }
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module Pinmark
|
|
6
|
+
module Mcp
|
|
7
|
+
module Tools
|
|
8
|
+
class ClearAddressed < ::MCP::Tool
|
|
9
|
+
tool_name "clear_addressed"
|
|
10
|
+
description "Drop every entry whose status='addressed' from the queue file. Returns the " \
|
|
11
|
+
"number removed."
|
|
12
|
+
input_schema(properties: {})
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def call(server_context:)
|
|
16
|
+
queue = server_context.fetch(:queue)
|
|
17
|
+
result = queue.clear_addressed
|
|
18
|
+
Tools.text_response({
|
|
19
|
+
"ok" => true,
|
|
20
|
+
"removed" => result[:removed],
|
|
21
|
+
"remaining" => result[:remaining]
|
|
22
|
+
})
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
|
|
6
|
+
module Pinmark
|
|
7
|
+
module Mcp
|
|
8
|
+
module Tools
|
|
9
|
+
def self.text_response(payload)
|
|
10
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class ListPending < ::MCP::Tool
|
|
14
|
+
tool_name "list_pending_annotations"
|
|
15
|
+
description "Return every annotation in the queue with status='pending'. Each entry " \
|
|
16
|
+
"includes the source file:line, component class, page path, relative DOM " \
|
|
17
|
+
"selector (if any), the user's comment, and the entry id needed for " \
|
|
18
|
+
"mark_addressed."
|
|
19
|
+
input_schema(properties: {})
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def call(server_context:)
|
|
23
|
+
queue = server_context.fetch(:queue)
|
|
24
|
+
pending = queue.pending.map do |entry|
|
|
25
|
+
{
|
|
26
|
+
"id" => entry["id"],
|
|
27
|
+
"node_id" => entry["node_id"],
|
|
28
|
+
"component" => entry["component"],
|
|
29
|
+
"source" => entry["source"],
|
|
30
|
+
"selector" => entry["selector"],
|
|
31
|
+
"text_excerpt" => entry["text_excerpt"],
|
|
32
|
+
"comment" => entry["comment"],
|
|
33
|
+
"page_path" => entry["page_path"],
|
|
34
|
+
"captured_at" => entry["captured_at"],
|
|
35
|
+
"ancestry" => entry["ancestry"]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
Tools.text_response({ "count" => pending.size, "annotations" => pending })
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
|
|
6
|
+
module Pinmark
|
|
7
|
+
module Mcp
|
|
8
|
+
module Tools
|
|
9
|
+
class ListResolved < ::MCP::Tool
|
|
10
|
+
tool_name "list_resolved_annotations"
|
|
11
|
+
description "Return every annotation in the queue with status='addressed'. Use this " \
|
|
12
|
+
"to audit annotations that have already been handled, e.g. for a " \
|
|
13
|
+
"post-pass review or to undo. Same shape as list_pending_annotations, " \
|
|
14
|
+
"plus addressed_at."
|
|
15
|
+
input_schema(properties: {})
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(server_context:)
|
|
19
|
+
queue = server_context.fetch(:queue)
|
|
20
|
+
resolved = queue.addressed.map do |entry|
|
|
21
|
+
{
|
|
22
|
+
"id" => entry["id"],
|
|
23
|
+
"node_id" => entry["node_id"],
|
|
24
|
+
"component" => entry["component"],
|
|
25
|
+
"source" => entry["source"],
|
|
26
|
+
"selector" => entry["selector"],
|
|
27
|
+
"text_excerpt" => entry["text_excerpt"],
|
|
28
|
+
"comment" => entry["comment"],
|
|
29
|
+
"page_path" => entry["page_path"],
|
|
30
|
+
"captured_at" => entry["captured_at"],
|
|
31
|
+
"addressed_at" => entry["addressed_at"],
|
|
32
|
+
"ancestry" => entry["ancestry"]
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
Tools.text_response({ "count" => resolved.size, "annotations" => resolved })
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module Pinmark
|
|
6
|
+
module Mcp
|
|
7
|
+
module Tools
|
|
8
|
+
class MarkAddressed < ::MCP::Tool
|
|
9
|
+
tool_name "mark_addressed"
|
|
10
|
+
description "Mark a single annotation as addressed by id. Idempotent: calling on an " \
|
|
11
|
+
"already-addressed id is a no-op."
|
|
12
|
+
input_schema(
|
|
13
|
+
properties: {
|
|
14
|
+
id: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Annotation id returned by list_pending_annotations"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
required: ["id"]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def call(id:, server_context:)
|
|
24
|
+
queue = server_context.fetch(:queue)
|
|
25
|
+
result = queue.mark_addressed(id)
|
|
26
|
+
|
|
27
|
+
unless result[:found]
|
|
28
|
+
return Tools.text_response({ "ok" => false, "error" => "No annotation with id=#{id}" })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Tools.text_response({
|
|
32
|
+
"ok" => true,
|
|
33
|
+
"id" => id,
|
|
34
|
+
"already_addressed" => result[:already_addressed]
|
|
35
|
+
})
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module Pinmark
|
|
7
|
+
# Concern intended to be `include`d into the host application's Phlex
|
|
8
|
+
# component base class (typically `Components::Base`). Provides an
|
|
9
|
+
# `around_template` override that pushes/pops the per-request tracker and
|
|
10
|
+
# emits `<!-- pinmark:begin/end -->` markers around the component's
|
|
11
|
+
# rendered HTML.
|
|
12
|
+
#
|
|
13
|
+
# Inert outside of development mode and when no tracker is set on the
|
|
14
|
+
# current request, so it is safe to include unconditionally.
|
|
15
|
+
module Phlex
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
attr_reader :pinmark_id
|
|
20
|
+
|
|
21
|
+
def around_template(&)
|
|
22
|
+
@pinmark_id = nil
|
|
23
|
+
tracker = Rails.env.development? ? Pinmark.tracker : nil
|
|
24
|
+
return super unless tracker
|
|
25
|
+
|
|
26
|
+
source = Pinmark::SourceLocator.for(self.class) || "unknown"
|
|
27
|
+
component_name = self.class.name || "anonymous"
|
|
28
|
+
@pinmark_id = tracker.push(component: component_name, source:)
|
|
29
|
+
parent_id = tracker.nodes.last[:parent_id]
|
|
30
|
+
|
|
31
|
+
id_attr = Pinmark::Wrapper.escape(@pinmark_id)
|
|
32
|
+
class_attr = Pinmark::Wrapper.escape(component_name)
|
|
33
|
+
src_attr = Pinmark::Wrapper.escape(source)
|
|
34
|
+
parent_attr = Pinmark::Wrapper.escape(parent_id)
|
|
35
|
+
|
|
36
|
+
raw safe(%(<!-- pinmark:begin id="#{id_attr}" class="#{class_attr}" src="#{src_attr}" parent="#{parent_attr}" -->))
|
|
37
|
+
super
|
|
38
|
+
raw safe(%(<!-- pinmark:end id="#{id_attr}" -->))
|
|
39
|
+
ensure
|
|
40
|
+
tracker&.pop if @pinmark_id
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinmark
|
|
4
|
+
module SourceLocator
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def for(klass)
|
|
8
|
+
return nil unless klass.name
|
|
9
|
+
|
|
10
|
+
file, line = Module.const_source_location(klass.name)
|
|
11
|
+
return nil unless file
|
|
12
|
+
|
|
13
|
+
relative = Pathname.new(file).relative_path_from(Rails.root)
|
|
14
|
+
"#{relative}:#{line}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|