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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +21 -0
- data/README.md +117 -0
- data/docs/architecture.md +125 -0
- data/docs/authoring-guide.md +206 -0
- data/docs/getting-started.md +183 -0
- data/docs/reference/api/active-project.md +22 -0
- data/docs/reference/api/agent.md +24 -0
- data/docs/reference/api/docs-generator.md +20 -0
- data/docs/reference/api/http-server.md +46 -0
- data/docs/reference/api/index.md +38 -0
- data/docs/reference/api/introspection.md +17 -0
- data/docs/reference/api/mcp-installer.md +26 -0
- data/docs/reference/api/mcp-server.md +27 -0
- data/docs/reference/api/mw-builder.md +14 -0
- data/docs/reference/api/mw-data-files.md +20 -0
- data/docs/reference/api/mw-dialogue-dsl.md +58 -0
- data/docs/reference/api/mw-i18n.md +20 -0
- data/docs/reference/api/mw-linter.md +18 -0
- data/docs/reference/api/mw-loader.md +26 -0
- data/docs/reference/api/mw-openmw-config.md +15 -0
- data/docs/reference/api/mw-operations.md +24 -0
- data/docs/reference/api/mw-preflight.md +17 -0
- data/docs/reference/api/mw-reference-index.md +21 -0
- data/docs/reference/api/mw-scaffolder.md +13 -0
- data/docs/reference/api/mw-script-blob.md +31 -0
- data/docs/reference/api/mw-script-extractor.md +17 -0
- data/docs/reference/api/operations.md +25 -0
- data/docs/reference/api/plugins.md +24 -0
- data/docs/reference/api/preferences.md +13 -0
- data/docs/reference/api/project-marker.md +23 -0
- data/docs/reference/api/providers.md +22 -0
- data/docs/reference/api/recents.md +17 -0
- data/docs/reference/api/ui.md +21 -0
- data/docs/reference/api/vcs.md +17 -0
- data/docs/reference/api/watcher.md +11 -0
- data/docs/reference/commands.md +271 -0
- data/docs/walkthrough.md +193 -0
- data/exe/esp +10 -0
- data/lib/esp/active_project.rb +71 -0
- data/lib/esp/agent.rb +104 -0
- data/lib/esp/cli/docs.rb +44 -0
- data/lib/esp/cli/i18n.rb +67 -0
- data/lib/esp/cli/mcp.rb +52 -0
- data/lib/esp/cli/plugins.rb +42 -0
- data/lib/esp/cli/refs.rb +137 -0
- data/lib/esp/cli/support.rb +87 -0
- data/lib/esp/cli.rb +317 -0
- data/lib/esp/docs_generator.rb +148 -0
- data/lib/esp/http_server.rb +232 -0
- data/lib/esp/introspection.rb +151 -0
- data/lib/esp/mcp_installer.rb +122 -0
- data/lib/esp/mcp_server.rb +465 -0
- data/lib/esp/mw/builder.rb +71 -0
- data/lib/esp/mw/data_files.rb +67 -0
- data/lib/esp/mw/dialogue_dsl.rb +209 -0
- data/lib/esp/mw/i18n.rb +113 -0
- data/lib/esp/mw/linter.rb +103 -0
- data/lib/esp/mw/loader.rb +130 -0
- data/lib/esp/mw/openmw_config.rb +138 -0
- data/lib/esp/mw/operations.rb +374 -0
- data/lib/esp/mw/preflight.rb +161 -0
- data/lib/esp/mw/reference_index.rb +182 -0
- data/lib/esp/mw/scaffolder.rb +197 -0
- data/lib/esp/mw/script_blob.rb +87 -0
- data/lib/esp/mw/script_extractor.rb +85 -0
- data/lib/esp/mw/tes3conv.rb +38 -0
- data/lib/esp/operations.rb +285 -0
- data/lib/esp/plugins.rb +75 -0
- data/lib/esp/preferences.rb +63 -0
- data/lib/esp/project_marker.rb +99 -0
- data/lib/esp/providers/anthropic.rb +74 -0
- data/lib/esp/providers/ollama.rb +102 -0
- data/lib/esp/providers/openai.rb +91 -0
- data/lib/esp/providers.rb +76 -0
- data/lib/esp/recents.rb +74 -0
- data/lib/esp/ui.rb +144 -0
- data/lib/esp/vcs.rb +112 -0
- data/lib/esp/version.rb +11 -0
- data/lib/esp/watcher.rb +55 -0
- data/lib/esp.rb +85 -0
- data/locales/en.yml +164 -0
- data/locales/fr.yml +10 -0
- 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
|