rails-informant 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/generators/rails_informant/skill/templates/informant-alerts.sh +87 -41
- data/lib/generators/rails_informant/skill_generator.rb +27 -22
- data/lib/rails_informant/claude_integration_content.rb +77 -0
- data/lib/rails_informant/doctor.rb +53 -0
- data/lib/rails_informant/engine.rb +27 -0
- data/lib/rails_informant/integration.rb +202 -0
- data/lib/rails_informant.rb +3 -0
- data/lib/tasks/rails_informant.rake +5 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef3e66d24feee02958d2d57db85411ec1e19db17fa0cc440936c3f2072016d7c
|
|
4
|
+
data.tar.gz: a43101bb80bffbad9d1a940d06e2a2d35bc5a8853a2978d5ada0dfdcbd87063d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 626124766b399453b846fb37ea492a9cc971206dafcbe5019e050b8adde854f20a55944c84f8906d03c5a55255530480c7599dd3aaf143ac37bc4aca743ae83a
|
|
7
|
+
data.tar.gz: 8fb18f7ab56d4044601224d6c7c20ffcc77944517738d2a7fded15470fc3a18bb83ae963b6784305145519166d54f887b39e84f34c7ff3b5e2623581fd85096c
|
|
@@ -1,61 +1,107 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Informant:
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# Informant: on the first Claude Code prompt of a session, surface two things —
|
|
3
|
+
# an out-of-date Claude Code integration (drift), and unresolved production
|
|
4
|
+
# errors — unless that first prompt is the /informant command. Either channel
|
|
5
|
+
# may fire on its own; when both fire, they share one combined message with a
|
|
6
|
+
# single pause instruction.
|
|
7
|
+
# Requires: jq (and curl, for the production check)
|
|
5
8
|
# Env vars: INFORMANT_PRODUCTION_URL, INFORMANT_PRODUCTION_TOKEN
|
|
6
9
|
# INFORMANT_PRODUCTION_PATH_PREFIX (optional, default: /informant)
|
|
7
10
|
|
|
8
11
|
set -euo pipefail
|
|
9
12
|
|
|
10
|
-
# Silent exit if
|
|
11
|
-
[[ -z "${INFORMANT_PRODUCTION_URL:-}" ]] && exit 0
|
|
12
|
-
[[ -z "${INFORMANT_PRODUCTION_TOKEN:-}" ]] && exit 0
|
|
13
|
-
[[ "$INFORMANT_PRODUCTION_URL" == https://* ]] || exit 0
|
|
14
|
-
|
|
15
|
-
# Silent exit if jq is not installed (needed to parse the hook payload)
|
|
13
|
+
# Silent exit if jq is not installed (needed to parse the hook payload).
|
|
16
14
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
17
15
|
|
|
18
16
|
# UserPromptSubmit delivers a JSON payload on stdin carrying session_id and prompt.
|
|
19
17
|
payload=$(cat)
|
|
20
18
|
session_id=$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null || true)
|
|
19
|
+
prompt=$(printf '%s' "$payload" | jq -r '.prompt // empty' 2>/dev/null || true)
|
|
21
20
|
|
|
22
|
-
#
|
|
23
|
-
# records that this session was handled, so later prompts short-circuit here before
|
|
24
|
-
# parsing the prompt or doing network work. Require a token-shaped session_id (it is
|
|
25
|
-
# interpolated into the path below) and stay silent when we can't record the marker,
|
|
26
|
-
# rather than re-alerting on every later prompt of the session.
|
|
21
|
+
# Require a token-shaped session_id (it is interpolated into the marker path below).
|
|
27
22
|
[[ "$session_id" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0
|
|
23
|
+
|
|
24
|
+
# Is the committed Claude Code integration out of date? The Ruby channels (dev
|
|
25
|
+
# boot warning, informant:doctor) write this flag under Rails.root/tmp. This hook
|
|
26
|
+
# reads it relative to the session's working directory, assuming that cwd is the
|
|
27
|
+
# Rails app root. In a monorepo where the Rails root is a subdirectory of the
|
|
28
|
+
# session cwd, this channel stays silent — the boot warning and informant:doctor
|
|
29
|
+
# still cover that case.
|
|
30
|
+
drift=0
|
|
31
|
+
[[ -e "tmp/rails-informant-drift" ]] && drift=1
|
|
32
|
+
|
|
33
|
+
# Can we check production errors? Needs both env vars and an HTTPS URL.
|
|
34
|
+
production=1
|
|
35
|
+
[[ -z "${INFORMANT_PRODUCTION_URL:-}" ]] && production=0
|
|
36
|
+
[[ -z "${INFORMANT_PRODUCTION_TOKEN:-}" ]] && production=0
|
|
37
|
+
[[ "${INFORMANT_PRODUCTION_URL:-}" == https://* ]] || production=0
|
|
38
|
+
|
|
39
|
+
# Nothing either channel could say → exit without claiming the session, so a
|
|
40
|
+
# later prompt (e.g. once env vars are set) still gets its chance. This also
|
|
41
|
+
# keeps a drift-free, production-unconfigured app a true silent no-op.
|
|
42
|
+
[[ "$drift" -eq 0 && "$production" -eq 0 ]] && exit 0
|
|
43
|
+
|
|
44
|
+
# Run at most once per session, on the first prompt: a marker keyed by session_id
|
|
45
|
+
# records that this session was handled, so later prompts short-circuit here.
|
|
46
|
+
# Stay silent when we can't record the marker, rather than re-firing every prompt.
|
|
28
47
|
marker="${TMPDIR:-/tmp}/rails-informant-alert-${session_id}"
|
|
29
48
|
[[ -e "$marker" ]] && exit 0
|
|
30
49
|
: > "$marker" 2>/dev/null || exit 0
|
|
31
50
|
|
|
32
51
|
# Stay quiet for the whole session when the first prompt is the /informant command —
|
|
33
|
-
# the user is already triaging
|
|
34
|
-
prompt=$(printf '%s' "$payload" | jq -r '.prompt // empty' 2>/dev/null || true)
|
|
52
|
+
# the user is already triaging, so these startup nudges would be redundant.
|
|
35
53
|
[[ "$prompt" =~ ^/informant($|[[:space:]]) ]] && exit 0
|
|
36
54
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
# Assemble the data blocks first (the instruction comes last, on purpose).
|
|
56
|
+
body=""
|
|
57
|
+
|
|
58
|
+
if [[ "$drift" -eq 1 ]]; then
|
|
59
|
+
body+="🧭 Informant: this app's Claude Code integration is out of date.
|
|
60
|
+
The installed rails-informant gem would now generate different .claude/ files than the ones committed here.
|
|
61
|
+
Update it by running: bin/rails g rails_informant:skill
|
|
62
|
+
"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
errors=0
|
|
66
|
+
if [[ "$production" -eq 1 ]]; then
|
|
67
|
+
path_prefix="${INFORMANT_PRODUCTION_PATH_PREFIX:-/informant}"
|
|
68
|
+
url="${INFORMANT_PRODUCTION_URL}${path_prefix}/api/v1/status"
|
|
69
|
+
|
|
70
|
+
# Fetch status. A failure here must not suppress a pending drift nudge, so
|
|
71
|
+
# swallow the error into an empty response rather than exiting.
|
|
72
|
+
response=$(curl -s -f \
|
|
73
|
+
--connect-timeout 3 \
|
|
74
|
+
--max-time 5 \
|
|
75
|
+
-H @- \
|
|
76
|
+
"$url" <<< "Authorization: Bearer ${INFORMANT_PRODUCTION_TOKEN}" \
|
|
77
|
+
2>/dev/null) || response=""
|
|
78
|
+
|
|
79
|
+
if [[ -n "$response" ]]; then
|
|
80
|
+
unresolved=$(printf '%s' "$response" | jq -r '.unresolved_count // 0' 2>/dev/null || echo 0)
|
|
81
|
+
if [[ "$unresolved" =~ ^[0-9]+$ && "$unresolved" -gt 0 ]]; then
|
|
82
|
+
label="error"
|
|
83
|
+
[[ "$unresolved" -gt 1 ]] && label="errors"
|
|
84
|
+
[[ -n "$body" ]] && body+="
|
|
85
|
+
"
|
|
86
|
+
body+="🚨 Informant: ${unresolved} unresolved ${label} in production
|
|
87
|
+
$(printf '%s' "$response" | jq -r '.top_errors[]? | " - \(.error_class) (\(.total_occurrences) \(if .total_occurrences == 1 then "occurrence" else "occurrences" end))"' 2>/dev/null || true)
|
|
88
|
+
"
|
|
89
|
+
errors=1
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# Nothing to report (e.g. drift flag absent and zero unresolved errors).
|
|
95
|
+
[[ -z "$body" ]] && exit 0
|
|
96
|
+
|
|
97
|
+
# One conversational instruction, last — with a specific behavior, a concrete
|
|
98
|
+
# question, and a single explicit pause (see the prompt-engineering notes).
|
|
99
|
+
if [[ "$drift" -eq 1 && "$errors" -eq 1 ]]; then
|
|
100
|
+
instruction="START your response by casually mentioning both the out-of-date integration (show the one-command fix) and these production errors, then ask the user how they want to proceed — update, investigate, or continue. Do NOT proceed with their request until they answer."
|
|
101
|
+
elif [[ "$drift" -eq 1 ]]; then
|
|
102
|
+
instruction="START your response by casually mentioning that the integration is out of date and showing the one-command fix, then ask the user whether to update it now or continue with their request. Do NOT proceed with their request until they answer."
|
|
103
|
+
else
|
|
104
|
+
instruction="START your response by casually mentioning these errors, then ask the user if they want to investigate or continue with their request. Do NOT proceed with the user's request until they answer."
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
printf '%s\n%s\n' "$body" "$instruction"
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "rails/generators"
|
|
3
|
+
require "rails_informant/claude_integration_content"
|
|
3
4
|
|
|
4
5
|
module RailsInformant
|
|
5
6
|
class SkillGenerator < Rails::Generators::Base
|
|
7
|
+
Content = ClaudeIntegrationContent
|
|
8
|
+
|
|
6
9
|
source_root File.expand_path("skill/templates", __dir__)
|
|
7
10
|
|
|
8
11
|
def copy_skill_file
|
|
@@ -16,16 +19,15 @@ module RailsInformant
|
|
|
16
19
|
|
|
17
20
|
def create_or_update_mcp_json
|
|
18
21
|
mcp_path = File.join(destination_root, ".mcp.json")
|
|
19
|
-
informant_entry = { "command" => "informant-mcp" }
|
|
20
22
|
|
|
21
23
|
if File.exist?(mcp_path)
|
|
22
24
|
existing = JSON.parse(File.read(mcp_path))
|
|
23
25
|
existing["mcpServers"] ||= {}
|
|
24
|
-
existing["mcpServers"]["informant"] =
|
|
26
|
+
existing["mcpServers"]["informant"] = Content.mcp_entry
|
|
25
27
|
create_file ".mcp.json", JSON.pretty_generate(existing) + "\n", force: true
|
|
26
28
|
else
|
|
27
29
|
create_file ".mcp.json", JSON.pretty_generate(
|
|
28
|
-
"mcpServers" => { "informant" =>
|
|
30
|
+
"mcpServers" => { "informant" => Content.mcp_entry }
|
|
29
31
|
) + "\n"
|
|
30
32
|
end
|
|
31
33
|
rescue JSON::ParserError
|
|
@@ -34,31 +36,28 @@ module RailsInformant
|
|
|
34
36
|
|
|
35
37
|
def create_or_update_settings_json
|
|
36
38
|
settings_path = File.join(destination_root, ".claude", "settings.json")
|
|
37
|
-
hook_command = ".claude/hooks/informant-alerts.sh"
|
|
38
39
|
|
|
39
40
|
if File.exist?(settings_path)
|
|
40
41
|
existing = JSON.parse(File.read(settings_path))
|
|
41
|
-
existing["hooks"]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
unless already_registered
|
|
49
|
-
existing["hooks"]["UserPromptSubmit"] << user_prompt_submit_hook(hook_command)
|
|
50
|
-
end
|
|
51
|
-
|
|
42
|
+
existing["hooks"] = migrate_informant_hooks(existing["hooks"])
|
|
43
|
+
# Coerce a hand-written non-array event value (a lone hook object, a string)
|
|
44
|
+
# to an array so registering never raises NoMethodError on <<.
|
|
45
|
+
existing["hooks"][Content::HOOK_EVENT] = [] unless existing["hooks"][Content::HOOK_EVENT].is_a?(Array)
|
|
46
|
+
existing["hooks"][Content::HOOK_EVENT] << Content.hook_registration
|
|
52
47
|
create_file ".claude/settings.json", JSON.pretty_generate(existing) + "\n", force: true
|
|
53
48
|
else
|
|
54
49
|
create_file ".claude/settings.json", JSON.pretty_generate(
|
|
55
|
-
"hooks" =>
|
|
50
|
+
"hooks" => Content.expected_registrations
|
|
56
51
|
) + "\n"
|
|
57
52
|
end
|
|
58
53
|
rescue JSON::ParserError
|
|
59
54
|
say "Could not parse existing .claude/settings.json — skipping hook setup.", :red
|
|
60
55
|
end
|
|
61
56
|
|
|
57
|
+
def clear_drift_flag
|
|
58
|
+
RailsInformant::Integration.new(app_root: destination_root).write_drift_flag stale: false
|
|
59
|
+
end
|
|
60
|
+
|
|
62
61
|
def print_next_steps
|
|
63
62
|
say ""
|
|
64
63
|
say "Claude Code integration installed!", :green
|
|
@@ -82,12 +81,18 @@ module RailsInformant
|
|
|
82
81
|
|
|
83
82
|
private
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
# Remove every prior informant registration (matched by script path) from all
|
|
85
|
+
# event keys and drop keys left empty, so a re-run migrates a stale
|
|
86
|
+
# registration (e.g. a leftover SessionStart from an earlier gem version)
|
|
87
|
+
# instead of adding a second one alongside it. Unrelated hooks are preserved.
|
|
88
|
+
# Self-heals across future event-key changes rather than hardcoding an event.
|
|
89
|
+
def migrate_informant_hooks(hooks)
|
|
90
|
+
hooks = {} unless hooks.is_a?(Hash)
|
|
91
|
+
hooks.each_value do |entries|
|
|
92
|
+
entries.reject! { |entry| Content.informant_hook_entry?(entry) } if entries.is_a?(Array)
|
|
93
|
+
end
|
|
94
|
+
hooks.reject! { |_event, entries| entries.is_a?(Array) && entries.empty? }
|
|
95
|
+
hooks
|
|
91
96
|
end
|
|
92
97
|
end
|
|
93
98
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module RailsInformant
|
|
4
|
+
# Single source of truth for the generated Claude Code integration content.
|
|
5
|
+
#
|
|
6
|
+
# Both SkillGenerator (generation) and Integration (drift detection) build
|
|
7
|
+
# their fragments here so the two can never silently diverge. Intentionally
|
|
8
|
+
# free of any rails/generators (Thor) dependency: Integration runs on the
|
|
9
|
+
# host-app boot path via the engine initializer, which must not load Thor.
|
|
10
|
+
module ClaudeIntegrationContent
|
|
11
|
+
# Paths, relative to the host app root, of the files the generator writes.
|
|
12
|
+
HOOK_SCRIPT_PATH = ".claude/hooks/informant-alerts.sh"
|
|
13
|
+
SKILL_PATH = ".claude/skills/informant/SKILL.md"
|
|
14
|
+
SETTINGS_PATH = ".claude/settings.json"
|
|
15
|
+
MCP_PATH = ".mcp.json"
|
|
16
|
+
|
|
17
|
+
# The command string a settings.json hook entry uses to invoke the script.
|
|
18
|
+
# Match-by-path detection keys on this value across every event key.
|
|
19
|
+
HOOK_COMMAND = HOOK_SCRIPT_PATH
|
|
20
|
+
|
|
21
|
+
# The event key the current gem registers the hook under.
|
|
22
|
+
HOOK_EVENT = "UserPromptSubmit"
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Directory of the installed gem, resolved from RubyGems rather than a
|
|
27
|
+
# generator's source_root so it works on the host-app boot path. Never
|
|
28
|
+
# RailsInformant::VERSION — that constant is env-driven and resolves to the
|
|
29
|
+
# 0.0.0.dev fallback in host apps.
|
|
30
|
+
def gem_dir
|
|
31
|
+
Gem.loaded_specs["rails-informant"].gem_dir
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def templates_dir
|
|
35
|
+
File.join gem_dir, "lib", "generators", "rails_informant", "skill", "templates"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def hook_script
|
|
39
|
+
File.read File.join(templates_dir, "informant-alerts.sh")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def skill_markdown
|
|
43
|
+
File.read File.join(templates_dir, "SKILL.md")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The informant server entry inside .mcp.json's "mcpServers".
|
|
47
|
+
def mcp_entry
|
|
48
|
+
{ "command" => "informant-mcp" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A single UserPromptSubmit hook registration for settings.json.
|
|
52
|
+
def hook_registration
|
|
53
|
+
{
|
|
54
|
+
"hooks" => [
|
|
55
|
+
{ "type" => "command", "command" => HOOK_COMMAND, "timeout" => 10 }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The informant-owned hooks map the generator produces: the registration
|
|
61
|
+
# keyed by its event. Detection compares the host app's extracted informant
|
|
62
|
+
# registrations against this, so a leftover entry under a different event key
|
|
63
|
+
# (e.g. a stale SessionStart) reads as drift.
|
|
64
|
+
def expected_registrations
|
|
65
|
+
{ HOOK_EVENT => [ hook_registration ] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether a settings.json hook entry targets the informant script — the
|
|
69
|
+
# shared match-by-path predicate used by both generation (to sweep stale
|
|
70
|
+
# registrations) and detection (to extract the informant fragment).
|
|
71
|
+
def informant_hook_entry?(entry)
|
|
72
|
+
entry.is_a?(Hash) && Array(entry["hooks"]).any? do |hook|
|
|
73
|
+
hook.is_a?(Hash) && hook["command"] == HOOK_COMMAND
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "rails_informant/integration"
|
|
2
|
+
|
|
3
|
+
module RailsInformant
|
|
4
|
+
# The informant:doctor channel: reports the integration state, prints the fix,
|
|
5
|
+
# refreshes the drift flag, and returns a process exit code (nonzero on stale
|
|
6
|
+
# or error) so it doubles as a CI signal. A thin wrapper over Integration that
|
|
7
|
+
# never raises out — a diagnostic must not crash the run it reports on.
|
|
8
|
+
class Doctor
|
|
9
|
+
def initialize(integration: Integration.new, io: $stdout)
|
|
10
|
+
@integration = integration
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
status = @integration.status
|
|
16
|
+
@integration.write_drift_flag stale: status == :stale
|
|
17
|
+
@io.puts report_for(status)
|
|
18
|
+
exit_code_for status
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
# An unexpected failure must exit nonzero, not 0 — a doctor wired into CI
|
|
21
|
+
# to gate drift must never report a false green when it could not check.
|
|
22
|
+
@io.puts "[Informant] doctor could not complete: #{e.message}"
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def report_for(status)
|
|
29
|
+
case status
|
|
30
|
+
when :current
|
|
31
|
+
"[Informant] Claude Code integration is up to date."
|
|
32
|
+
when :not_installed
|
|
33
|
+
"[Informant] Claude Code integration is not installed — nothing to check."
|
|
34
|
+
when :stale
|
|
35
|
+
<<~REPORT.chomp
|
|
36
|
+
[Informant] Claude Code integration is OUT OF DATE.
|
|
37
|
+
The installed gem would generate different .claude/ files than this app has committed.
|
|
38
|
+
Fix: bin/rails g rails_informant:skill
|
|
39
|
+
REPORT
|
|
40
|
+
when :error
|
|
41
|
+
<<~REPORT.chomp
|
|
42
|
+
[Informant] Claude Code integration could not be verified.
|
|
43
|
+
.claude/settings.json or .mcp.json is present but is not valid JSON.
|
|
44
|
+
Fix that file by hand — re-running the generator skips unparseable files.
|
|
45
|
+
REPORT
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def exit_code_for(status)
|
|
50
|
+
status == :stale || status == :error ? 1 : 0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -56,6 +56,33 @@ module RailsInformant
|
|
|
56
56
|
config.after_initialize { RailsInformant::Engine.validate_api_token! }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
initializer "rails_informant.check_integration_drift" do
|
|
60
|
+
config.after_initialize { RailsInformant::Engine.check_integration_drift! }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Dev-only, warn-only nudge: when the committed .claude/ integration has
|
|
64
|
+
# drifted from what the installed gem would generate now, log the one-command
|
|
65
|
+
# fix and refresh the drift flag the hook reads. Silent when current,
|
|
66
|
+
# not_installed, error, or in production; never raises out (a drift check must
|
|
67
|
+
# not break boot).
|
|
68
|
+
def self.check_integration_drift!
|
|
69
|
+
return unless Rails.env.development? && (RailsInformant.server_mode? || RailsInformant.console_mode?)
|
|
70
|
+
|
|
71
|
+
integration = RailsInformant::Integration.new
|
|
72
|
+
status = integration.status
|
|
73
|
+
integration.write_drift_flag stale: status == :stale
|
|
74
|
+
|
|
75
|
+
return unless status == :stale
|
|
76
|
+
|
|
77
|
+
Rails.logger&.warn <<~MSG.squish
|
|
78
|
+
[Informant] Your Claude Code integration is out of date — the installed
|
|
79
|
+
gem would generate different .claude/ files than this app has committed.
|
|
80
|
+
Run `bin/rails g rails_informant:skill` to update it.
|
|
81
|
+
MSG
|
|
82
|
+
rescue StandardError
|
|
83
|
+
# Never break boot over a drift check.
|
|
84
|
+
end
|
|
85
|
+
|
|
59
86
|
MINIMUM_TOKEN_LENGTH = 32
|
|
60
87
|
|
|
61
88
|
def self.validate_api_token!
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "rails_informant/claude_integration_content"
|
|
6
|
+
|
|
7
|
+
module RailsInformant
|
|
8
|
+
# Classifies the host app's committed Claude Code integration by comparing its
|
|
9
|
+
# live .claude/ files against what the installed gem would generate now. The
|
|
10
|
+
# detection primitive behind the three drift channels (boot warning, doctor,
|
|
11
|
+
# hook nudge); Rails-boot-independent and unit-testable in isolation.
|
|
12
|
+
#
|
|
13
|
+
# Internals may raise (missing gem spec, unreadable templates); each channel
|
|
14
|
+
# wraps a single top-level rescue so a drift check never breaks boot or CI.
|
|
15
|
+
# The one exception is #write_drift_flag, which is best-effort by nature and
|
|
16
|
+
# swallows its own IO failures.
|
|
17
|
+
class Integration
|
|
18
|
+
Content = ClaudeIntegrationContent
|
|
19
|
+
|
|
20
|
+
DRIFT_FLAG = "rails-informant-drift"
|
|
21
|
+
|
|
22
|
+
# Fixed order so the digest is deterministic across runs.
|
|
23
|
+
COMPONENTS = %w[hook mcp settings skill].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(app_root: Rails.root)
|
|
26
|
+
@app_root = Pathname(app_root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# :not_installed | :current | :stale | :error
|
|
30
|
+
#
|
|
31
|
+
# not_installed wins first so apps that use the gem only for error capture
|
|
32
|
+
# are never nagged. error (a present-but-unparseable settings.json/.mcp.json)
|
|
33
|
+
# is distinct from stale because re-running the generator skips unparseable
|
|
34
|
+
# files — it could never clear a stale reported on one.
|
|
35
|
+
def status
|
|
36
|
+
return :not_installed unless installed?
|
|
37
|
+
return :error if json_error?
|
|
38
|
+
|
|
39
|
+
live_digest == expected_digest ? :current : :stale
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def installed?
|
|
43
|
+
hook_script_present? || settings_informant_present? || mcp_informant_present?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stale?
|
|
47
|
+
status == :stale
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Display only — the drift decision uses the digest, not the version. Harmless
|
|
51
|
+
# that local path/git installs report 0.0.0.dev here; only published installs
|
|
52
|
+
# show a real number.
|
|
53
|
+
def gem_version
|
|
54
|
+
Gem.loaded_specs["rails-informant"]&.version&.to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def drift_flag_path
|
|
58
|
+
@app_root.join "tmp", DRIFT_FLAG
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Best-effort: the Ruby channels refresh this flag so the bash hook can read
|
|
62
|
+
# drift without loading Ruby. Never raises out — a read-only tmp/ must not
|
|
63
|
+
# break a dev boot or a doctor run.
|
|
64
|
+
def write_drift_flag(stale:)
|
|
65
|
+
if stale
|
|
66
|
+
FileUtils.mkdir_p drift_flag_path.dirname
|
|
67
|
+
drift_flag_path.write "The Claude Code integration is out of date. " \
|
|
68
|
+
"Run `bin/rails g rails_informant:skill` to update it.\n"
|
|
69
|
+
elsif drift_flag_path.exist?
|
|
70
|
+
drift_flag_path.delete
|
|
71
|
+
end
|
|
72
|
+
rescue SystemCallError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def hook_path = @app_root.join(Content::HOOK_SCRIPT_PATH)
|
|
79
|
+
def skill_path = @app_root.join(Content::SKILL_PATH)
|
|
80
|
+
def settings_path = @app_root.join(Content::SETTINGS_PATH)
|
|
81
|
+
def mcp_path = @app_root.join(Content::MCP_PATH)
|
|
82
|
+
|
|
83
|
+
def hook_script_present? = hook_path.exist?
|
|
84
|
+
|
|
85
|
+
def settings_informant_present?
|
|
86
|
+
registrations = live_settings_registrations
|
|
87
|
+
registrations && !registrations.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def mcp_informant_present?
|
|
91
|
+
!live_mcp_entry.nil?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def json_error?
|
|
95
|
+
parse_failed?(settings_path) || parse_failed?(mcp_path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# --- digest -------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def expected_digest
|
|
101
|
+
digest_of(
|
|
102
|
+
"hook" => normalize_text(Content.hook_script),
|
|
103
|
+
"skill" => normalize_text(Content.skill_markdown),
|
|
104
|
+
"settings" => canonical_json(Content.expected_registrations),
|
|
105
|
+
"mcp" => canonical_json(Content.mcp_entry)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def live_digest
|
|
110
|
+
digest_of(
|
|
111
|
+
"hook" => normalize_text(read_or_empty(hook_path)),
|
|
112
|
+
"skill" => normalize_text(read_or_empty(skill_path)),
|
|
113
|
+
"settings" => canonical_json(live_settings_registrations || {}),
|
|
114
|
+
"mcp" => canonical_json(live_mcp_entry || {})
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def digest_of(components)
|
|
119
|
+
material = COMPONENTS.map { |name| "#{name}:#{components[name]}" }.join("\n")
|
|
120
|
+
Digest::SHA256.hexdigest material
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- live extraction ----------------------------------------------------
|
|
124
|
+
|
|
125
|
+
# The informant-owned hook registrations, keyed by event, swept from every
|
|
126
|
+
# event key by script path. nil when settings.json is absent or unparseable.
|
|
127
|
+
def live_settings_registrations
|
|
128
|
+
settings = parsed_json(settings_path) or return nil
|
|
129
|
+
hooks = settings["hooks"]
|
|
130
|
+
return {} unless hooks.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
hooks.each_with_object({}) do |(event, entries), result|
|
|
133
|
+
next unless entries.is_a?(Array)
|
|
134
|
+
|
|
135
|
+
informant = entries.select { |entry| Content.informant_hook_entry?(entry) }
|
|
136
|
+
result[event] = informant unless informant.empty?
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The informant server entry from .mcp.json, or nil when absent/unparseable.
|
|
141
|
+
def live_mcp_entry
|
|
142
|
+
mcp = parsed_json(mcp_path) or return nil
|
|
143
|
+
mcp.dig "mcpServers", "informant"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# --- json / text helpers ------------------------------------------------
|
|
147
|
+
|
|
148
|
+
# Read and parse each JSON file at most once per instance. Instances are
|
|
149
|
+
# created fresh per channel call, so this both removes the redundant reads
|
|
150
|
+
# and lets installed?/json_error?/the digest classify from one consistent
|
|
151
|
+
# snapshot (no split read where one path sees the file valid and another a
|
|
152
|
+
# concurrent edit).
|
|
153
|
+
def json_state(path)
|
|
154
|
+
@_json_states ||= {}
|
|
155
|
+
@_json_states[path] ||= compute_json_state(path)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def compute_json_state(path)
|
|
159
|
+
return [ :absent, nil ] unless path.exist?
|
|
160
|
+
|
|
161
|
+
[ :ok, JSON.parse(path.read) ]
|
|
162
|
+
rescue JSON::ParserError
|
|
163
|
+
[ :error, nil ]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parsed_json(path)
|
|
167
|
+
state, data = json_state(path)
|
|
168
|
+
state == :ok ? data : nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def parse_failed?(path)
|
|
172
|
+
json_state(path).first == :error
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def read_or_empty(path)
|
|
176
|
+
path.exist? ? path.read : ""
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Canonical JSON: recursively sort hash keys so a host-side key reorder in a
|
|
180
|
+
# JSON fragment does not read as drift.
|
|
181
|
+
def canonical_json(object)
|
|
182
|
+
JSON.generate deep_sort(object)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def deep_sort(object)
|
|
186
|
+
case object
|
|
187
|
+
when Hash then object.keys.sort.each_with_object({}) { |key, sorted| sorted[key] = deep_sort(object[key]) }
|
|
188
|
+
when Array then object.map { |element| deep_sort(element) }
|
|
189
|
+
else object
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Strip a leading BOM, normalize line endings, and trim trailing whitespace so
|
|
194
|
+
# host-side encoding noise (CRLF via .gitattributes, an EditorConfig
|
|
195
|
+
# trim/final-newline rule, a BOM-prepending editor) does not byte-diverge into
|
|
196
|
+
# a permanent stale. Content reformatting (reindentation, blank-line collapse)
|
|
197
|
+
# is intentionally out of scope — it would risk masking real drift.
|
|
198
|
+
def normalize_text(text)
|
|
199
|
+
text.delete_prefix("\uFEFF").gsub(/\r\n?/, "\n").split("\n", -1).map(&:rstrip).join("\n").sub(/\n+\z/, "")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
data/lib/rails_informant.rb
CHANGED
|
@@ -25,13 +25,16 @@ module RailsInformant
|
|
|
25
25
|
|
|
26
26
|
autoload :BreadcrumbBuffer, "rails_informant/breadcrumb_buffer"
|
|
27
27
|
autoload :BreadcrumbSubscriber, "rails_informant/breadcrumb_subscriber"
|
|
28
|
+
autoload :ClaudeIntegrationContent, "rails_informant/claude_integration_content"
|
|
28
29
|
autoload :ContextBuilder, "rails_informant/context_builder"
|
|
29
30
|
autoload :ContextFilter, "rails_informant/context_filter"
|
|
30
31
|
autoload :Current, "rails_informant/current"
|
|
32
|
+
autoload :Doctor, "rails_informant/doctor"
|
|
31
33
|
autoload :ErrorRecorder, "rails_informant/error_recorder"
|
|
32
34
|
autoload :ErrorSubscriber, "rails_informant/error_subscriber"
|
|
33
35
|
autoload :Event, "rails_informant/event"
|
|
34
36
|
autoload :Fingerprint, "rails_informant/fingerprint"
|
|
37
|
+
autoload :Integration, "rails_informant/integration"
|
|
35
38
|
autoload :StructuredEventSubscriber, "rails_informant/structured_event_subscriber"
|
|
36
39
|
|
|
37
40
|
module Middleware
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
namespace :informant do
|
|
2
|
+
desc "Report whether the Claude Code integration is current, stale, or not installed"
|
|
3
|
+
task doctor: :environment do
|
|
4
|
+
exit RailsInformant::Doctor.new.run
|
|
5
|
+
end
|
|
6
|
+
|
|
2
7
|
desc "Purge resolved errors older than retention_days"
|
|
3
8
|
task purge: :environment do
|
|
4
9
|
RailsInformant::PurgeJob.perform_now
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-informant
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel López Prat
|
|
@@ -136,15 +136,18 @@ files:
|
|
|
136
136
|
- lib/rails_informant.rb
|
|
137
137
|
- lib/rails_informant/breadcrumb_buffer.rb
|
|
138
138
|
- lib/rails_informant/breadcrumb_subscriber.rb
|
|
139
|
+
- lib/rails_informant/claude_integration_content.rb
|
|
139
140
|
- lib/rails_informant/configuration.rb
|
|
140
141
|
- lib/rails_informant/context_builder.rb
|
|
141
142
|
- lib/rails_informant/context_filter.rb
|
|
142
143
|
- lib/rails_informant/current.rb
|
|
144
|
+
- lib/rails_informant/doctor.rb
|
|
143
145
|
- lib/rails_informant/engine.rb
|
|
144
146
|
- lib/rails_informant/error_recorder.rb
|
|
145
147
|
- lib/rails_informant/error_subscriber.rb
|
|
146
148
|
- lib/rails_informant/event.rb
|
|
147
149
|
- lib/rails_informant/fingerprint.rb
|
|
150
|
+
- lib/rails_informant/integration.rb
|
|
148
151
|
- lib/rails_informant/mcp.rb
|
|
149
152
|
- lib/rails_informant/mcp/base_tool.rb
|
|
150
153
|
- lib/rails_informant/mcp/client.rb
|