esp-modkit 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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/LICENSE +21 -0
  4. data/README.md +117 -0
  5. data/docs/architecture.md +125 -0
  6. data/docs/authoring-guide.md +206 -0
  7. data/docs/getting-started.md +183 -0
  8. data/docs/reference/api/active-project.md +22 -0
  9. data/docs/reference/api/agent.md +24 -0
  10. data/docs/reference/api/docs-generator.md +20 -0
  11. data/docs/reference/api/http-server.md +46 -0
  12. data/docs/reference/api/index.md +38 -0
  13. data/docs/reference/api/introspection.md +17 -0
  14. data/docs/reference/api/mcp-installer.md +26 -0
  15. data/docs/reference/api/mcp-server.md +27 -0
  16. data/docs/reference/api/mw-builder.md +14 -0
  17. data/docs/reference/api/mw-data-files.md +20 -0
  18. data/docs/reference/api/mw-dialogue-dsl.md +58 -0
  19. data/docs/reference/api/mw-i18n.md +20 -0
  20. data/docs/reference/api/mw-linter.md +18 -0
  21. data/docs/reference/api/mw-loader.md +26 -0
  22. data/docs/reference/api/mw-openmw-config.md +15 -0
  23. data/docs/reference/api/mw-operations.md +24 -0
  24. data/docs/reference/api/mw-preflight.md +17 -0
  25. data/docs/reference/api/mw-reference-index.md +21 -0
  26. data/docs/reference/api/mw-scaffolder.md +13 -0
  27. data/docs/reference/api/mw-script-blob.md +31 -0
  28. data/docs/reference/api/mw-script-extractor.md +17 -0
  29. data/docs/reference/api/operations.md +25 -0
  30. data/docs/reference/api/plugins.md +24 -0
  31. data/docs/reference/api/preferences.md +13 -0
  32. data/docs/reference/api/project-marker.md +23 -0
  33. data/docs/reference/api/providers.md +22 -0
  34. data/docs/reference/api/recents.md +17 -0
  35. data/docs/reference/api/ui.md +21 -0
  36. data/docs/reference/api/vcs.md +17 -0
  37. data/docs/reference/api/watcher.md +11 -0
  38. data/docs/reference/commands.md +271 -0
  39. data/docs/walkthrough.md +193 -0
  40. data/exe/esp +10 -0
  41. data/lib/esp/active_project.rb +71 -0
  42. data/lib/esp/agent.rb +104 -0
  43. data/lib/esp/cli/docs.rb +44 -0
  44. data/lib/esp/cli/i18n.rb +67 -0
  45. data/lib/esp/cli/mcp.rb +52 -0
  46. data/lib/esp/cli/plugins.rb +42 -0
  47. data/lib/esp/cli/refs.rb +137 -0
  48. data/lib/esp/cli/support.rb +87 -0
  49. data/lib/esp/cli.rb +317 -0
  50. data/lib/esp/docs_generator.rb +148 -0
  51. data/lib/esp/http_server.rb +232 -0
  52. data/lib/esp/introspection.rb +151 -0
  53. data/lib/esp/mcp_installer.rb +122 -0
  54. data/lib/esp/mcp_server.rb +465 -0
  55. data/lib/esp/mw/builder.rb +71 -0
  56. data/lib/esp/mw/data_files.rb +67 -0
  57. data/lib/esp/mw/dialogue_dsl.rb +209 -0
  58. data/lib/esp/mw/i18n.rb +113 -0
  59. data/lib/esp/mw/linter.rb +103 -0
  60. data/lib/esp/mw/loader.rb +130 -0
  61. data/lib/esp/mw/openmw_config.rb +138 -0
  62. data/lib/esp/mw/operations.rb +374 -0
  63. data/lib/esp/mw/preflight.rb +161 -0
  64. data/lib/esp/mw/reference_index.rb +182 -0
  65. data/lib/esp/mw/scaffolder.rb +197 -0
  66. data/lib/esp/mw/script_blob.rb +87 -0
  67. data/lib/esp/mw/script_extractor.rb +85 -0
  68. data/lib/esp/mw/tes3conv.rb +38 -0
  69. data/lib/esp/operations.rb +285 -0
  70. data/lib/esp/plugins.rb +75 -0
  71. data/lib/esp/preferences.rb +63 -0
  72. data/lib/esp/project_marker.rb +99 -0
  73. data/lib/esp/providers/anthropic.rb +74 -0
  74. data/lib/esp/providers/ollama.rb +102 -0
  75. data/lib/esp/providers/openai.rb +91 -0
  76. data/lib/esp/providers.rb +76 -0
  77. data/lib/esp/recents.rb +74 -0
  78. data/lib/esp/ui.rb +144 -0
  79. data/lib/esp/vcs.rb +112 -0
  80. data/lib/esp/version.rb +11 -0
  81. data/lib/esp/watcher.rb +55 -0
  82. data/lib/esp.rb +85 -0
  83. data/locales/en.yml +164 -0
  84. data/locales/fr.yml +10 -0
  85. metadata +241 -0
@@ -0,0 +1,232 @@
1
+ require 'json'
2
+ require 'webrick'
3
+
4
+ # WEBrick's mount_proc registers a ProcHandler whose do_GET / do_POST /
5
+ # do_HEAD / do_PUT all route through the supplied proc — but do_OPTIONS is
6
+ # inherited from AbstractServlet, which short-circuits with an `Allow:`
7
+ # response and **never calls the proc**. That means our dispatch's CORS
8
+ # headers don't get attached to the preflight, and any cross-origin POST
9
+ # with a JSON body (everything from the ESPresso webview) is blocked by
10
+ # the browser. Patch ProcHandler so OPTIONS flows through the proc like
11
+ # every other method.
12
+ WEBrick::HTTPServlet::ProcHandler.class_eval do
13
+ def do_OPTIONS(req, res)
14
+ @proc.call(req, res)
15
+ end
16
+ end
17
+
18
+ module Esp
19
+ # Localhost HTTP API mirroring the CLI surface. A thin WEBrick shell over
20
+ # Esp::Operations — same operations as `bin/esp` and `esp mcp serve`, same
21
+ # payload shapes, different transport. Intended as the backing store for
22
+ # the GUI. WEBrick is thread-per-request, so the long-lived /agent stream
23
+ # doesn't block other routes.
24
+ #
25
+ # Routes:
26
+ # POST /agent — body: { prompt, provider?, model? } → text/event-stream
27
+ # GET /up — { status, version } (liveness)
28
+ # GET /version — { version }
29
+ # GET /commands — full Esp::Introspection.command_tree
30
+ # GET /providers — { providers:[{id,default_model,configured}], default }
31
+ # GET /active-project — { root: String|null }
32
+ # POST /open-project — body: { root } → { root } (sets active project)
33
+ # GET /projects/recent — { projects: [{root,name,opened_at}, ...] }
34
+ # POST /projects/new — body: { project, mod, format?, author? } → { root, mod, source }
35
+ # GET /preferences — { mods_home, ... } (merged over defaults)
36
+ # POST /preferences — body: { mods_home?, ... } → resolved prefs
37
+ # POST /build — body: { mod, locale? }
38
+ # POST /build-all — body: { locale? }
39
+ # POST /unpack — body: { plugin, name? }
40
+ # POST /install — body: { mod }
41
+ # POST /lint — body: { mod }
42
+ # POST /i18n/check — body: { mod }
43
+ # POST /scaffold — body: { mod, format?, author?, description?, force? }
44
+ # POST /extract-scripts — body: { mod }
45
+ # POST /records/read — body: { mod }
46
+ # POST /records/write — body: { mod, record }
47
+ # POST /dialogue/write — body: { mod, spec }
48
+ # GET /plugins — query: config?
49
+ # GET /refs/find — query: q, type, like, limit, show
50
+ # POST /refs/resolve — body: { ids: [String] } → { types: {id => type} }
51
+ # GET /ollama/models — { models: [String] } from Ollama's /api/tags
52
+ # GET /diff — query: scope?, root? (working-tree changes + diffs)
53
+ # POST /approve — body: { paths, root? } (git add)
54
+ # POST /reject — body: { paths, root? } (restore/delete — destructive)
55
+ #
56
+ # CORS is wide open (Access-Control-Allow-Origin: *) — dev-only tool.
57
+ class HttpServer
58
+ DEFAULT_PORT = 4567
59
+
60
+ # [verb, path, Operations method]. Every route is a thin pass of the
61
+ # parsed input (query for GET, JSON body for POST) to one operation, so
62
+ # adding an endpoint is a one-line row here.
63
+ ROUTES = [
64
+ [:get, '/up', :health],
65
+ [:get, '/version', :version],
66
+ [:get, '/commands', :commands],
67
+ [:get, '/providers', :providers],
68
+ [:get, '/active-project', :active_project],
69
+ [:post, '/open-project', :open_project],
70
+ [:get, '/projects/recent', :projects_recent],
71
+ [:post, '/projects/new', :projects_new],
72
+ [:get, '/preferences', :preferences],
73
+ [:post, '/preferences', :preferences_update],
74
+ [:get, '/plugins', :plugins_list],
75
+ [:get, '/refs/find', :refs_find],
76
+ [:post, '/refs/resolve', :refs_resolve],
77
+ [:get, '/ollama/models', :ollama_models],
78
+ [:get, '/diff', :diff],
79
+ [:post, '/approve', :approve],
80
+ [:post, '/reject', :reject],
81
+ [:post, '/build', :build],
82
+ [:post, '/build-all', :build_all],
83
+ [:post, '/unpack', :unpack],
84
+ [:post, '/install', :install],
85
+ [:post, '/lint', :lint],
86
+ [:post, '/i18n/check', :i18n_check],
87
+ [:post, '/scaffold', :scaffold],
88
+ [:post, '/extract-scripts', :extract_scripts],
89
+ [:post, '/records/read', :records_read],
90
+ [:post, '/records/write', :record_write],
91
+ [:post, '/dialogue/write', :dialogue_write]
92
+ ].freeze
93
+
94
+ def initialize(port: DEFAULT_PORT, logger: nil, agent: nil)
95
+ @port = port
96
+ # An injected agent (tests) is used as-is; otherwise each /agent request
97
+ # builds one for its chosen provider/model.
98
+ @agent = agent
99
+ @server = WEBrick::HTTPServer.new(
100
+ Port: port,
101
+ Logger: logger || WEBrick::Log.new($stdout, WEBrick::Log::WARN),
102
+ AccessLog: []
103
+ )
104
+ register_routes
105
+ end
106
+
107
+ def start
108
+ trap('INT') { stop }
109
+ trap('TERM') { stop }
110
+ @server.start
111
+ end
112
+
113
+ def stop
114
+ @server.shutdown
115
+ end
116
+
117
+ private
118
+
119
+ def register_routes
120
+ ROUTES.each do |verb, path, op|
121
+ mount(verb.to_s.upcase, path) { |input| Esp::Operations.dispatch(op, input) }
122
+ end
123
+ @server.mount_proc('/agent') { |req, res| stream_agent(req, res) }
124
+ end
125
+
126
+ # The agent loop streamed as Server-Sent Events. Runs in a WEBrick
127
+ # request thread; the agent's events are piped out as they arrive so the
128
+ # GUI renders the trace live. The API key lives in the backend's env,
129
+ # never the webview.
130
+ def stream_agent(req, res)
131
+ cors_headers(res)
132
+ return cors_preflight(res) if req.request_method == 'OPTIONS'
133
+ unless req.request_method == 'POST'
134
+ return send_error(res, 405, 'method not allowed (expected POST)', error_code: 'method_not_allowed')
135
+ end
136
+
137
+ body = parse_input(req, 'POST')
138
+ # Build the chosen provider before opening the stream, so a bad
139
+ # provider/model is a clean 400 rather than a half-open event stream.
140
+ agent = @agent || build_agent(body)
141
+ res.status = 200
142
+ res['Content-Type'] = 'text/event-stream'
143
+ res['Cache-Control'] = 'no-cache'
144
+ res.chunked = true
145
+ reader, writer = IO.pipe
146
+ Thread.new { pump_agent(agent, body['prompt'].to_s, writer) }
147
+ res.body = reader
148
+ rescue *Esp::Operations.caller_errors => e
149
+ send_error(res, 400, e.message, error_code: Esp::Operations.error_code(e))
150
+ end
151
+
152
+ def build_agent(body)
153
+ provider = Esp::Providers.build(body['provider'] || Esp::Providers.default_id, model: body['model'])
154
+ Esp::Agent.new(provider: provider)
155
+ end
156
+
157
+ def pump_agent(agent, prompt, writer)
158
+ agent.run(prompt) { |event| writer.write(sse(event)) }
159
+ writer.write(sse([:done]))
160
+ rescue StandardError => e
161
+ writer.write(sse([:error, e.message]))
162
+ ensure
163
+ writer.close
164
+ end
165
+
166
+ # One agent event tuple -> one SSE `data:` frame.
167
+ def sse(event)
168
+ type, *rest = event
169
+ data = case type
170
+ when :text then { type: 'text', text: rest[0] }
171
+ when :tool_use then { type: 'tool_use', name: rest[0], input: rest[1] }
172
+ when :tool_result then { type: 'tool_result', name: rest[0], result: rest[1] }
173
+ when :tool_error then { type: 'tool_error', name: rest[0], error: rest[1][:error] }
174
+ when :done then { type: 'done' }
175
+ else { type: 'error', message: rest[0] }
176
+ end
177
+ "data: #{JSON.generate(data)}\n\n"
178
+ end
179
+
180
+ def mount(verb, path, &handler)
181
+ @server.mount_proc(path) { |req, res| dispatch(req, res, verb, handler) }
182
+ end
183
+
184
+ def dispatch(req, res, verb, handler)
185
+ cors_headers(res)
186
+ return cors_preflight(res) if req.request_method == 'OPTIONS'
187
+ unless req.request_method == verb
188
+ return send_error(res, 405, "method not allowed (expected #{verb})",
189
+ error_code: 'method_not_allowed')
190
+ end
191
+
192
+ send_ok(res, handler.call(parse_input(req, verb)))
193
+ rescue *Esp::Operations.caller_errors => e
194
+ send_error(res, 400, e.message, error_code: Esp::Operations.error_code(e))
195
+ rescue StandardError => e
196
+ send_error(res, 500, e.message, error_code: 'internal_error')
197
+ end
198
+
199
+ def cors_headers(res)
200
+ res['Access-Control-Allow-Origin'] = '*'
201
+ res['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
202
+ res['Access-Control-Allow-Headers'] = 'content-type'
203
+ end
204
+
205
+ def parse_input(req, method)
206
+ return req.query if method == 'GET'
207
+
208
+ body = req.body.to_s
209
+ body.empty? ? {} : JSON.parse(body)
210
+ rescue JSON::ParserError => e
211
+ raise Esp::Operations::InputError, Esp.t('errors.http.invalid_body', message: e.message)
212
+ end
213
+
214
+ def cors_preflight(res)
215
+ res.status = 204
216
+ end
217
+
218
+ def send_ok(res, payload)
219
+ res.status = 200
220
+ res['Content-Type'] = 'application/json'
221
+ res.body = JSON.generate(payload)
222
+ end
223
+
224
+ def send_error(res, status, message, error_code: nil)
225
+ res.status = status
226
+ res['Content-Type'] = 'application/json'
227
+ body = { error: message }
228
+ body[:code] = error_code if error_code
229
+ res.body = JSON.generate(body)
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,151 @@
1
+ module Esp
2
+ # Builds a structured snapshot of the CLI command tree and the core
3
+ # library modules. Two consumers:
4
+ #
5
+ # - `esp docs build` renders this into markdown under docs/reference/.
6
+ # - `esp docs introspect` emits it as JSON so AI tools, ESPresso,
7
+ # and the MCP server can discover capabilities at runtime.
8
+ #
9
+ # The introspection is read-only and cheap — it just walks Thor's
10
+ # metadata and reads each lib/esp/{,mw/}*.rb header comment block.
11
+ module Introspection
12
+ HIDDEN_COMMANDS = %w[help tree].freeze
13
+
14
+ class << self
15
+ def command_tree
16
+ walk(Esp::CLI, [])
17
+ end
18
+
19
+ def walk(thor_class, path)
20
+ reverse_map = build_reverse_map(thor_class)
21
+ subcommand_names = Array(thor_class.subcommands)
22
+
23
+ commands = thor_class.commands.filter_map do |name, cmd|
24
+ next if HIDDEN_COMMANDS.include?(name) || subcommand_names.include?(name)
25
+
26
+ display = reverse_map[name.to_s] || name.to_s
27
+ command_entry(display, path, cmd, thor_class)
28
+ end
29
+
30
+ subcommands = subcommand_names.map do |sub_name|
31
+ sub_class = thor_class.subcommand_classes[sub_name]
32
+ sub_walk = walk(sub_class, path + [sub_name])
33
+ {
34
+ name: sub_name,
35
+ path: path + [sub_name],
36
+ description: thor_class.commands[sub_name]&.description,
37
+ commands: sub_walk[:commands],
38
+ subcommands: sub_walk[:subcommands]
39
+ }
40
+ end
41
+
42
+ { commands: commands, subcommands: subcommands }
43
+ end
44
+
45
+ # Modules documented in docs/reference/api/. Walks both the
46
+ # engine-agnostic shell (lib/esp/*.rb → Esp::Foo) and the active Mw
47
+ # plugin (lib/esp/mw/*.rb → Esp::Mw::Foo). Skips cli.rb (the command
48
+ # reference covers it) and version.rb (trivial). The cli/ and
49
+ # providers/ subdirs are intentionally not walked here — they are
50
+ # subordinate to their parent modules and don't warrant their own
51
+ # docs pages.
52
+ def module_docs
53
+ shell = Dir.glob(File.join(Esp::ROOT, 'lib/esp/*.rb'))
54
+ plugin = Dir.glob(File.join(Esp::ROOT, 'lib/esp/mw/*.rb'))
55
+ entries_for(shell, namespace: 'Esp') + entries_for(plugin, namespace: 'Esp::Mw')
56
+ end
57
+
58
+ # Comment block immediately preceding the primary class/module
59
+ # declaration in `path`. Strips leading `# ` from each line.
60
+ def parse_header_comment(path)
61
+ lines = File.readlines(path, chomp: true)
62
+ target_idx = primary_declaration_index(lines)
63
+ return nil unless target_idx
64
+
65
+ collect_comment_above(lines, target_idx)
66
+ end
67
+
68
+ private
69
+
70
+ def entries_for(files, namespace:)
71
+ files.filter_map do |file|
72
+ basename = File.basename(file)
73
+ next if %w[cli.rb version.rb].include?(basename)
74
+
75
+ comment = parse_header_comment(file)
76
+ next if comment.nil? || comment.empty?
77
+
78
+ {
79
+ name: derive_module_name(basename, namespace: namespace),
80
+ source: file.sub("#{Esp::ROOT}/", ''),
81
+ description: comment
82
+ }
83
+ end
84
+ end
85
+
86
+ def command_entry(display, path, cmd, thor_class)
87
+ class_opts = Array(thor_class.class_options&.values).map { |o| option_data(o) }
88
+ cmd_opts = Array(cmd.options.values).map { |o| option_data(o) }
89
+ {
90
+ name: display,
91
+ path: path + [display],
92
+ description: cmd.description,
93
+ usage: cmd.usage,
94
+ options: cmd_opts + class_opts
95
+ }
96
+ end
97
+
98
+ def build_reverse_map(thor_class)
99
+ Hash(thor_class.map).each_with_object({}) do |(alias_name, target), h|
100
+ h[target.to_s] = alias_name
101
+ end
102
+ end
103
+
104
+ def option_data(opt)
105
+ {
106
+ name: opt.name,
107
+ type: opt.type,
108
+ description: opt.description,
109
+ default: opt.default,
110
+ required: opt.required?,
111
+ aliases: Array(opt.aliases)
112
+ }
113
+ end
114
+
115
+ def primary_declaration_index(lines)
116
+ lines.each_with_index do |line, idx|
117
+ stripped = line.strip
118
+ next unless stripped.match?(/\A(class|module)\s+\w/)
119
+ # Skip the outer wrappers (`module Esp`, `module Esp::Mw`,
120
+ # legacy `module Mw`) to find the inner class/module that
121
+ # documents the file.
122
+ next if stripped.match?(/\A(class|module)\s+(Esp|Mw)(::|\b)/)
123
+
124
+ return idx
125
+ end
126
+ nil
127
+ end
128
+
129
+ def collect_comment_above(lines, target_idx)
130
+ out = []
131
+ (target_idx - 1).downto(0) do |i|
132
+ line = lines[i].strip
133
+ if line.start_with?('#')
134
+ out.unshift(line.sub(/\A#\s?/, ''))
135
+ elsif out.empty? && line.empty?
136
+ next
137
+ else
138
+ break
139
+ end
140
+ end
141
+ out.join("\n").strip
142
+ end
143
+
144
+ def derive_module_name(basename, namespace: 'Esp')
145
+ stem = File.basename(basename, '.rb')
146
+ camel = stem.split('_').map { |part| part.sub(/\A./, &:upcase) }.join
147
+ "#{namespace}::#{camel}"
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,122 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+
4
+ module Esp
5
+ # Registers `esp mcp serve` with a local MCP client by merging a stdio
6
+ # server entry into that client's config JSON, preserving any other servers
7
+ # and top-level keys already there.
8
+ #
9
+ # Deliberately kept out of Esp::Operations: rewriting a user's AI-client
10
+ # config is a CLI/operator action, never something an agent should be able
11
+ # to drive over MCP itself.
12
+ #
13
+ # The two clients share the `mcpServers` / stdio shape and an identical
14
+ # server entry; they differ only in where the config lives. The command is
15
+ # an absolute path to `bin/esp` — note `${CLAUDE_PROJECT_DIR}` looks tempting
16
+ # for a portable Claude Code entry but is unset when `.mcp.json` is parsed
17
+ # (it's a hooks-only variable), so the server silently fails to launch. The
18
+ # cost is a machine-specific path in the config.
19
+ #
20
+ # Step-22.5 migration: the install action quietly drops any existing entry
21
+ # named `mw` (the historical pre-rename name) when it writes the new `esp`
22
+ # entry, so a re-install after the rename leaves a single, current entry.
23
+ module McpInstaller
24
+ SERVER_NAME = 'esp'.freeze
25
+ LEGACY_NAME = 'mw'.freeze
26
+
27
+ Result = Struct.new(:client, :label, :config_path, :name, :action, :entry, :migrated,
28
+ keyword_init: true)
29
+
30
+ CLIENTS = {
31
+ 'claude-code' => {
32
+ label: 'Claude Code',
33
+ path: -> { File.join(Esp::ROOT, '.mcp.json') }
34
+ },
35
+ 'claude-desktop' => {
36
+ label: 'Claude Desktop',
37
+ path: -> { File.expand_path('~/Library/Application Support/Claude/claude_desktop_config.json') }
38
+ }
39
+ }.freeze
40
+
41
+ class << self
42
+ def clients
43
+ CLIENTS.keys
44
+ end
45
+
46
+ def entry_for(client)
47
+ client_meta(client) # validate
48
+ { 'type' => 'stdio', 'command' => File.join(Esp::ROOT, 'bin', 'esp'), 'args' => %w[mcp serve] }
49
+ end
50
+
51
+ # Merge the entry into the client's config. +path+ overrides the default
52
+ # location (tests point it at a tmpfile). Returns a Result whose :action
53
+ # is :created / :updated / :unchanged. The :migrated flag is true when
54
+ # this install removed a legacy `mw` entry pointing at the same install.
55
+ def install(client, name: SERVER_NAME, path: nil)
56
+ meta = client_meta(client)
57
+ config_path = path || meta[:path].call
58
+ entry = entry_for(client)
59
+ config = read_config(config_path)
60
+ servers = (config['mcpServers'] ||= {})
61
+ migrated = migrate_legacy_entry(servers, name)
62
+ action = action_for(servers[name], entry)
63
+ servers[name] = entry
64
+ write_config(config_path, config) unless action == :unchanged && !migrated
65
+ Result.new(client: client, label: meta[:label], config_path: config_path,
66
+ name: name, action: action, entry: entry, migrated: migrated)
67
+ end
68
+
69
+ private
70
+
71
+ # Drop the legacy `mw` entry when installing the new `esp` entry under a
72
+ # different name, so a user doesn't end up with both. We only remove
73
+ # when the legacy entry looks like a previous install of this same tool
74
+ # (its command resolves to a `bin/mw` path) — never blindly delete an
75
+ # entry the user might have wired by hand. Skipped when the caller asks
76
+ # for `name: LEGACY_NAME` (an explicit re-pin to the old name).
77
+ def migrate_legacy_entry(servers, name)
78
+ return false if name == LEGACY_NAME
79
+ return false unless (legacy = servers[LEGACY_NAME])
80
+ return false unless legacy.is_a?(Hash) && legacy['command'].to_s.end_with?('/bin/mw')
81
+
82
+ servers.delete(LEGACY_NAME)
83
+ true
84
+ end
85
+
86
+ def client_meta(client)
87
+ CLIENTS.fetch(client) do
88
+ raise ArgumentError, Esp.t('errors.mcp_installer.unknown_client',
89
+ client: client, clients: clients.join(', '))
90
+ end
91
+ end
92
+
93
+ def read_config(path)
94
+ return {} unless File.exist?(path)
95
+
96
+ content = File.read(path).strip
97
+ return {} if content.empty?
98
+
99
+ parsed = JSON.parse(content)
100
+ unless parsed.is_a?(Hash)
101
+ raise ArgumentError, Esp.t('errors.mcp_installer.not_json_object', path: path)
102
+ end
103
+
104
+ parsed
105
+ rescue JSON::ParserError => e
106
+ raise ArgumentError, Esp.t('errors.mcp_installer.invalid_json', path: path, message: e.message)
107
+ end
108
+
109
+ def write_config(path, config)
110
+ FileUtils.mkdir_p(File.dirname(path))
111
+ File.write(path, "#{JSON.pretty_generate(config)}\n")
112
+ end
113
+
114
+ def action_for(existing, entry)
115
+ return :created if existing.nil?
116
+ return :unchanged if existing == entry
117
+
118
+ :updated
119
+ end
120
+ end
121
+ end
122
+ end