diff --git a/package.json b/package.json index 397c027f..a356335a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "pnpm --filter agentation watch & pnpm --filter feedback-tool-example dev", "build": "pnpm --filter agentation build", + "build:rails": "pnpm --filter agentation build && pnpm --filter agentation-rails-build build", + "build:all": "pnpm build && pnpm --filter agentation-rails-build build", "example": "pnpm --filter feedback-tool-example dev", "pack": "cd package && pnpm pack", "mcp": "pnpm --filter agentation-mcp start", @@ -17,5 +19,6 @@ "better-sqlite3", "esbuild" ] - } + }, + "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72fe188e..d2b72414 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,40 @@ importers: specifier: ^5.0.0 version: 5.9.3 + rails: + dependencies: + agentation: + specifier: workspace:* + version: link:../package + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.2.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.2.0 + version: 18.3.7(@types/react@18.3.28) + postcss: + specifier: ^8.5.6 + version: 8.5.6 + postcss-modules: + specifier: ^6.0.1 + version: 6.0.1(postcss@8.5.6) + sass: + specifier: ^1.97.2 + version: 1.97.3 + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.6)(typescript@5.9.3) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages: '@asamuzakjp/css-color@3.2.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 645858de..fd0dd406 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'package' - 'package/example' - 'mcp' + - 'rails' diff --git a/rails/.gitignore b/rails/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/rails/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/rails/gem/LICENSE b/rails/gem/LICENSE new file mode 100644 index 00000000..4dc169a0 --- /dev/null +++ b/rails/gem/LICENSE @@ -0,0 +1,27 @@ +PolyForm Shield License 1.0.0 + +Copyright (c) 2026 Benji Taylor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to use, +copy, modify, and distribute the Software, subject to the following conditions: + +1. You may not use the Software to provide a product or service that competes + with the Software or any product or service offered by the Licensor that + includes the Software. + +2. You may not remove or obscure any licensing, copyright, or other notices + included in the Software. + +3. If you distribute the Software or any derivative works, you must include a + copy of this license. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For more information, see https://polyformproject.org/licenses/shield/1.0.0 diff --git a/rails/gem/README.md b/rails/gem/README.md new file mode 100644 index 00000000..cc6eb6e5 --- /dev/null +++ b/rails/gem/README.md @@ -0,0 +1,87 @@ +# agentation-rails + +Drop-in Rails engine that adds the [Agentation](https://github.com/benjitaylor/agentation) annotation toolbar to your app in development. One line in your Gemfile, zero configuration. + +## Installation + +```ruby +# Gemfile +gem "agentation-rails", group: :development +``` + +```bash +bundle install +``` + +The toolbar appears automatically in development. Nothing to configure. + +## Configuration (optional) + +Generate an initializer: + +```bash +rails generate agentation:install +``` + +Or add one manually: + +```ruby +# config/environments/development.rb +Agentation.configure do |config| + config.endpoint = "http://localhost:4747" # MCP sync server (default) + config.webhook_url = "https://example.com/hooks/agentation" + config.copy_to_clipboard = false # disable auto-copy +end +``` + +## JavaScript events + +The toolbar dispatches `CustomEvent`s on `document` for every annotation lifecycle event. Use these with Stimulus controllers or plain JS: + +```javascript +document.addEventListener("agentation:add", (e) => { + console.log("Annotation added:", e.detail); +}); + +document.addEventListener("agentation:delete", (e) => { + console.log("Annotation deleted:", e.detail); +}); + +document.addEventListener("agentation:update", (e) => { + console.log("Annotation updated:", e.detail); +}); + +document.addEventListener("agentation:clear", (e) => { + console.log("Annotations cleared:", e.detail); +}); + +document.addEventListener("agentation:copy", (e) => { + console.log("Copied markdown:", e.detail.markdown); +}); + +document.addEventListener("agentation:submit", (e) => { + console.log("Submitted:", e.detail.output, e.detail.annotations); +}); + +document.addEventListener("agentation:session", (e) => { + console.log("Session created:", e.detail.sessionId); +}); +``` + +## How it works + +The gem inserts a `) + end + + # Recomputed each request so config changes via console/reloader take effect + def body_tag + config = Agentation.configuration + attrs = [] + attrs << data_attr("endpoint", config.endpoint) + attrs << data_attr("session-id", config.session_id) + attrs << data_attr("webhook-url", config.webhook_url) + attrs << data_attr("copy-to-clipboard", config.copy_to_clipboard) unless config.copy_to_clipboard.nil? + attrs.compact! + + return nil if attrs.empty? + + %() + end + + def data_attr(name, value) + return nil unless value + + %( data-#{name}="#{ERB::Util.html_escape(value)}") + end + + def agentation_js + @agentation_js ||= File.read( + File.expand_path("../../app/assets/javascripts/agentation.js", __dir__) + ) + end + end +end diff --git a/rails/gem/lib/generators/agentation/install_generator.rb b/rails/gem/lib/generators/agentation/install_generator.rb new file mode 100644 index 00000000..83f637b2 --- /dev/null +++ b/rails/gem/lib/generators/agentation/install_generator.rb @@ -0,0 +1,12 @@ +module Agentation + module Generators + class InstallGenerator < Rails::Generators::Base + desc "Creates an Agentation initializer in config/initializers." + source_root File.expand_path("templates", __dir__) + + def copy_initializer + template "agentation.rb", "config/initializers/agentation.rb" + end + end + end +end diff --git a/rails/gem/lib/generators/agentation/templates/agentation.rb b/rails/gem/lib/generators/agentation/templates/agentation.rb new file mode 100644 index 00000000..5db431e4 --- /dev/null +++ b/rails/gem/lib/generators/agentation/templates/agentation.rb @@ -0,0 +1,18 @@ +# Agentation — visual annotation toolbar for AI coding agents. +# https://github.com/benjitaylor/agentation +# +# The toolbar appears automatically in development with no configuration. +# Uncomment lines below to customize. +Agentation.configure do |config| + # MCP sync server endpoint (default: "http://localhost:4747") + # config.endpoint = "http://localhost:4747" + + # Webhook URL for annotation events + # config.webhook_url = "https://example.com/hooks/agentation" + + # Disable copy-to-clipboard (default: enabled) + # config.copy_to_clipboard = false + + # Force enable/disable (default: auto-detects Rails.env.development?) + # config.enabled = true +end diff --git a/rails/package.json b/rails/package.json new file mode 100644 index 00000000..d2b7585a --- /dev/null +++ b/rails/package.json @@ -0,0 +1,24 @@ +{ + "name": "agentation-rails-build", + "version": "0.0.1", + "private": true, + "description": "Build pipeline for agentation-rails gem — bundles React + Agentation into a standalone IIFE", + "scripts": { + "build": "tsup", + "watch": "tsup --watch" + }, + "dependencies": { + "agentation": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "postcss": "^8.5.6", + "postcss-modules": "^6.0.1", + "sass": "^1.97.2", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + } +} diff --git a/rails/src/standalone.ts b/rails/src/standalone.ts new file mode 100644 index 00000000..db44023f --- /dev/null +++ b/rails/src/standalone.ts @@ -0,0 +1,106 @@ +/** + * Agentation Standalone Entry Point + * + * Bundles React + Agentation into a single self-executing script. + * Used by the agentation-rails gem — Rails developers never see React. + * + * Dispatches CustomEvents on document so Rails developers can listen + * with Stimulus controllers or plain JS: + * + * document.addEventListener("agentation:add", (e) => { ... }) + * document.addEventListener("agentation:delete", (e) => { ... }) + * document.addEventListener("agentation:update", (e) => { ... }) + * document.addEventListener("agentation:clear", (e) => { ... }) + * document.addEventListener("agentation:copy", (e) => { ... }) + * document.addEventListener("agentation:submit", (e) => { ... }) + * document.addEventListener("agentation:session", (e) => { ... }) + */ +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { PageFeedbackToolbarCSS } from "agentation"; +import type { Annotation } from "agentation"; + +let root: Root | null = null; +let container: HTMLElement | null = null; + +function dispatch(name: string, detail: unknown) { + document.dispatchEvent( + new CustomEvent(`agentation:${name}`, { detail, bubbles: true }) + ); +} + +function getConfig(): Record { + const config: Record = {}; + const el = document.getElementById("agentation-config"); + if (el) { + for (const attr of Array.from(el.attributes)) { + if (attr.name.startsWith("data-")) { + const key = attr.name + .slice(5) + .replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + config[key] = attr.value; + } + } + } + return config; +} + +function mount() { + if (container) return; + + const config = getConfig(); + + container = document.createElement("div"); + container.id = "agentation-root"; + document.body.appendChild(container); + + const props: Record = { + endpoint: config.endpoint || undefined, + sessionId: config.sessionId || undefined, + webhookUrl: config.webhookUrl || undefined, + + onAnnotationAdd: (a: Annotation) => dispatch("add", a), + onAnnotationDelete: (a: Annotation) => dispatch("delete", a), + onAnnotationUpdate: (a: Annotation) => dispatch("update", a), + onAnnotationsClear: (cleared: Annotation[]) => dispatch("clear", cleared), + onCopy: (markdown: string) => dispatch("copy", { markdown }), + onSubmit: (output: string, annotations: Annotation[]) => + dispatch("submit", { output, annotations }), + onSessionCreated: (sessionId: string) => + dispatch("session", { sessionId }), + }; + + if (config.copyToClipboard === "false") { + props.copyToClipboard = false; + } + + root = createRoot(container); + root.render(React.createElement(PageFeedbackToolbarCSS, props)); +} + +function unmount() { + if (root) { + root.unmount(); + root = null; + } + if (container) { + container.remove(); + container = null; + } +} + +// Auto-mount on DOM ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mount); +} else { + mount(); +} + +// Clean up before Turbo caches the page +document.addEventListener("turbo:before-cache", unmount); + +// Re-mount on Turbo navigation +document.addEventListener("turbo:load", mount); + +// Expose for manual control if needed +(window as any).__agentation = { mount, unmount }; diff --git a/rails/tsconfig.json b/rails/tsconfig.json new file mode 100644 index 00000000..54ca6ae2 --- /dev/null +++ b/rails/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/rails/tsup.config.ts b/rails/tsup.config.ts new file mode 100644 index 00000000..1e636f99 --- /dev/null +++ b/rails/tsup.config.ts @@ -0,0 +1,108 @@ +import { defineConfig } from "tsup"; +import * as sass from "sass"; +import postcss from "postcss"; +import postcssModules from "postcss-modules"; +import * as path from "path"; +import * as fs from "fs"; +import type { Plugin } from "esbuild"; + +// Read version from the main agentation package +const pkg = JSON.parse( + fs.readFileSync( + path.resolve(__dirname, "../package/package.json"), + "utf-8" + ) +); +const VERSION = pkg.version; + +// SCSS CSS Modules plugin — same as the main package build +function scssModulesPlugin(): Plugin { + return { + name: "scss-modules", + setup(build) { + build.onLoad({ filter: /\.scss$/ }, async (args) => { + const isModule = args.path.includes(".module."); + const parentDir = path.basename(path.dirname(args.path)); + const baseName = path.basename( + args.path, + isModule ? ".module.scss" : ".scss" + ); + const styleId = `${parentDir}-${baseName}`; + + const result = sass.compile(args.path); + let css = result.css; + + if (isModule) { + let classNames: Record = {}; + const postcssResult = await postcss([ + postcssModules({ + getJSON(cssFileName, json) { + classNames = json; + }, + generateScopedName: "[name]__[local]___[hash:base64:5]", + }), + ]).process(css, { from: args.path }); + + css = postcssResult.css; + + const contents = ` +const css = ${JSON.stringify(css)}; +const classNames = ${JSON.stringify(classNames)}; + +if (typeof document !== 'undefined') { + let style = document.getElementById('feedback-tool-styles-${styleId}'); + if (!style) { + style = document.createElement('style'); + style.id = 'feedback-tool-styles-${styleId}'; + style.textContent = css; + document.head.appendChild(style); + } +} + +export default classNames; +`; + return { contents, loader: "js" }; + } else { + const contents = ` +const css = ${JSON.stringify(css)}; +if (typeof document !== 'undefined') { + let style = document.getElementById('feedback-tool-styles-${styleId}'); + if (!style) { + style = document.createElement('style'); + style.id = 'feedback-tool-styles-${styleId}'; + style.textContent = css; + document.head.appendChild(style); + } +} +export default {}; +`; + return { contents, loader: "js" }; + } + }); + }, + }; +} + +export default defineConfig({ + entry: ["src/standalone.ts"], + format: ["iife"], + outDir: "gem/app/assets/javascripts", + globalName: "Agentation", + // Bundle everything — React, Agentation, all deps — into one file + noExternal: [/.*/], + platform: "browser", + target: "es2020", + splitting: false, + sourcemap: false, + clean: true, + minify: true, + esbuildPlugins: [scssModulesPlugin()], + define: { + "process.env.NODE_ENV": '"production"', + __VERSION__: JSON.stringify(VERSION), + }, + outExtension: () => ({ js: ".js" }), + esbuildOptions(options) { + options.entryNames = "agentation"; + }, +});