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,465 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Esp
|
|
4
|
+
# Model Context Protocol server over stdio, so AI assistants (Claude Code,
|
|
5
|
+
# Claude Desktop, …) can author, build, lint, and query a mod project as
|
|
6
|
+
# native tools. It is a thin shell over Esp::Operations — the same service
|
|
7
|
+
# layer the HTTP API uses — so every tool returns the same payload the CLI
|
|
8
|
+
# `--json` mode and `esp serve` already emit.
|
|
9
|
+
#
|
|
10
|
+
# Transport is the MCP stdio convention: newline-delimited JSON-RPC 2.0,
|
|
11
|
+
# one message per line, no embedded newlines. Requests carry an `id` and
|
|
12
|
+
# get a response; notifications (no `id`) get none. The protocol stream
|
|
13
|
+
# owns stdout, so all diagnostics go to stderr.
|
|
14
|
+
#
|
|
15
|
+
# Operation errors (missing mod, no index, bad source) come back as a
|
|
16
|
+
# tool result with `isError: true` — the model sees the message and can
|
|
17
|
+
# recover. Only malformed protocol traffic (bad JSON, unknown method,
|
|
18
|
+
# unknown tool) uses a JSON-RPC error response.
|
|
19
|
+
#
|
|
20
|
+
# Alongside tools, the server exposes read-only **resources**
|
|
21
|
+
# (`resources/list` + `resources/read`): the command-tree introspection
|
|
22
|
+
# and the narrative docs, so an agent can pull context without a tool call.
|
|
23
|
+
class McpServer
|
|
24
|
+
PROTOCOL_VERSION = '2024-11-05'.freeze
|
|
25
|
+
|
|
26
|
+
# Each tool maps an MCP tool name to an Esp::Operations method plus the
|
|
27
|
+
# JSON Schema the client uses to build calls. `arguments` arrive as a
|
|
28
|
+
# string-keyed hash, exactly the shape Operations expects.
|
|
29
|
+
TOOLS = [
|
|
30
|
+
{
|
|
31
|
+
name: 'version',
|
|
32
|
+
description: 'Print the esp toolchain version.',
|
|
33
|
+
input_schema: { type: 'object', properties: {}, additionalProperties: false },
|
|
34
|
+
op: :version
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'commands',
|
|
38
|
+
description: 'Discover the full esp command tree — names, usage, and options.',
|
|
39
|
+
input_schema: { type: 'object', properties: {}, additionalProperties: false },
|
|
40
|
+
op: :commands
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'open_project',
|
|
44
|
+
description: 'Point the server at a project directory. Validates the path + the project ' \
|
|
45
|
+
'marker\'s game id; every subsequent op runs against this root until a new ' \
|
|
46
|
+
'open_project request or an explicit `root:` overrides it.',
|
|
47
|
+
input_schema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
root: { type: 'string', description: 'Absolute path to a directory (esp init creates one).' }
|
|
51
|
+
},
|
|
52
|
+
required: ['root'],
|
|
53
|
+
additionalProperties: false
|
|
54
|
+
},
|
|
55
|
+
op: :open_project
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'active_project',
|
|
59
|
+
description: 'The project currently active on the server (root + game). Returns nulls if no ' \
|
|
60
|
+
'open_project has happened yet.',
|
|
61
|
+
input_schema: { type: 'object', properties: {}, additionalProperties: false },
|
|
62
|
+
op: :active_project
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'projects_recent',
|
|
66
|
+
description: 'Recently-opened projects, newest-first. Persisted per user (ESP_DATA_DIR).',
|
|
67
|
+
input_schema: { type: 'object', properties: {}, additionalProperties: false },
|
|
68
|
+
op: :projects_recent
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'projects_new',
|
|
72
|
+
description: 'Scaffold a brand-new project (git init + .esp/project.json + first mod) under ' \
|
|
73
|
+
"the user's mods_home. Same flow ESPresso's \"New Project\" runs.",
|
|
74
|
+
input_schema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
project: { type: 'string', description: 'Project name (becomes the directory name).' },
|
|
78
|
+
mod: { type: 'string', description: 'Name of the first mod to scaffold inside it.' },
|
|
79
|
+
game: { type: 'string', description: "Game plugin id (default: 'mw')." },
|
|
80
|
+
format: { type: 'string', enum: %w[json rb py js mjs ts],
|
|
81
|
+
description: 'First mod source format (default: json).' },
|
|
82
|
+
author: { type: 'string', description: 'Author for the scaffolded mod header.' },
|
|
83
|
+
description: { type: 'string', description: 'Description for the scaffolded mod header.' }
|
|
84
|
+
},
|
|
85
|
+
required: %w[project mod],
|
|
86
|
+
additionalProperties: false
|
|
87
|
+
},
|
|
88
|
+
op: :projects_new
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'build',
|
|
92
|
+
description: 'Build mods/<MOD> to dist/<MOD>[.locale].esp via tes3conv.',
|
|
93
|
+
input_schema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
mod: { type: 'string', description: 'Mod name (folder under mods/).' },
|
|
97
|
+
locale: { type: 'string', description: 'Optional locale; output becomes <MOD>.<locale>.esp.' }
|
|
98
|
+
},
|
|
99
|
+
required: ['mod'],
|
|
100
|
+
additionalProperties: false
|
|
101
|
+
},
|
|
102
|
+
op: :build
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'build_all',
|
|
106
|
+
description: 'Build every mod under mods/ to dist/. Returns one result per mod.',
|
|
107
|
+
input_schema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: { locale: { type: 'string', description: 'Optional locale applied to every build.' } },
|
|
110
|
+
additionalProperties: false
|
|
111
|
+
},
|
|
112
|
+
op: :build_all
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'unpack',
|
|
116
|
+
description: 'Import an existing plugin (.esp/.esm/.omwaddon) into mods/<NAME>/<NAME>.json.',
|
|
117
|
+
input_schema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
plugin: { type: 'string', description: 'Plugin path or installed plugin name (Fargoth.esp).' },
|
|
121
|
+
name: { type: 'string', description: 'Mod folder name (default: plugin basename).' },
|
|
122
|
+
config: { type: 'string', description: 'openmw.cfg to resolve a bare name against (optional).' }
|
|
123
|
+
},
|
|
124
|
+
required: ['plugin'],
|
|
125
|
+
additionalProperties: false
|
|
126
|
+
},
|
|
127
|
+
op: :unpack
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'install',
|
|
131
|
+
description: 'Make a built dist/<MOD>.esp available to the game. Default: register with ' \
|
|
132
|
+
'openmw.cfg. Set copy_to or to_data_files to also copy the plugin into the ' \
|
|
133
|
+
'vanilla Data Files dir for the original engine.',
|
|
134
|
+
input_schema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
mod: { type: 'string', description: 'Mod name; built .esp must exist in dist/.' },
|
|
138
|
+
copy_to: { type: 'string',
|
|
139
|
+
description: 'Also copy the .esp into this directory (original engine).' },
|
|
140
|
+
to_data_files: { type: 'boolean',
|
|
141
|
+
description: 'Also copy to the auto-detected Morrowind Data Files dir.' },
|
|
142
|
+
register_openmw: { type: 'boolean',
|
|
143
|
+
description: 'Register with openmw.cfg (default true).' }
|
|
144
|
+
},
|
|
145
|
+
required: ['mod'],
|
|
146
|
+
additionalProperties: false
|
|
147
|
+
},
|
|
148
|
+
op: :install
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'plugins_list',
|
|
152
|
+
description: 'List plugins OpenMW has installed (from openmw.cfg) with active flag + load order.',
|
|
153
|
+
input_schema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: { config: { type: 'string', description: 'Path to openmw.cfg (optional).' } },
|
|
156
|
+
additionalProperties: false
|
|
157
|
+
},
|
|
158
|
+
op: :plugins_list
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'i18n_check',
|
|
162
|
+
description: 'Report missing/orphan i18n keys per locale for a mod (vs. the default en catalogue).',
|
|
163
|
+
input_schema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
|
|
166
|
+
required: ['mod'],
|
|
167
|
+
additionalProperties: false
|
|
168
|
+
},
|
|
169
|
+
op: :i18n_check
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'lint',
|
|
173
|
+
description: 'Check a mod for dangling refs and missing-master issues against the reference index.',
|
|
174
|
+
input_schema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
|
|
177
|
+
required: ['mod'],
|
|
178
|
+
additionalProperties: false
|
|
179
|
+
},
|
|
180
|
+
op: :lint
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'scaffold',
|
|
184
|
+
description: 'Create a new mod folder under mods/<MOD>/ with a starter source file and README.',
|
|
185
|
+
input_schema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: {
|
|
188
|
+
mod: { type: 'string', description: 'Mod name to create.' },
|
|
189
|
+
format: { type: 'string', enum: %w[json rb py js mjs ts], description: 'Format; default json.' },
|
|
190
|
+
author: { type: 'string', description: 'Author name (default: git config user.name).' },
|
|
191
|
+
description: { type: 'string', description: 'Plugin description.' },
|
|
192
|
+
force: { type: 'boolean', description: 'Overwrite an existing mod folder.' }
|
|
193
|
+
},
|
|
194
|
+
required: ['mod'],
|
|
195
|
+
additionalProperties: false
|
|
196
|
+
},
|
|
197
|
+
op: :scaffold
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'extract_scripts',
|
|
201
|
+
description: "Hoist each Script record's inline text into mods/<MOD>/scripts/<id>.mwscript.",
|
|
202
|
+
input_schema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
|
|
205
|
+
required: ['mod'],
|
|
206
|
+
additionalProperties: false
|
|
207
|
+
},
|
|
208
|
+
op: :extract_scripts
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'refs_find',
|
|
212
|
+
description: 'Search the vanilla reference index — substring match on id + name by default.',
|
|
213
|
+
input_schema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
q: { type: 'string', description: 'Substring to match against record id and name.' },
|
|
217
|
+
type: { type: 'string', description: 'Filter by record type (e.g. Npc, Cell, Script).' },
|
|
218
|
+
like: { type: 'string', description: "SQL LIKE pattern on id (e.g. 'Fargoth%')." },
|
|
219
|
+
exact: { type: 'boolean', description: 'Match q as an exact id instead of a substring.' },
|
|
220
|
+
show: { type: 'boolean', description: 'Return the full JSON record for each match.' },
|
|
221
|
+
limit: { type: 'integer', description: 'Max rows to return (default 100).' }
|
|
222
|
+
},
|
|
223
|
+
additionalProperties: false
|
|
224
|
+
},
|
|
225
|
+
op: :refs_find
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'records_read',
|
|
229
|
+
description: "Read a mod's records as structured data (works for any source format).",
|
|
230
|
+
input_schema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } },
|
|
233
|
+
required: ['mod'],
|
|
234
|
+
additionalProperties: false
|
|
235
|
+
},
|
|
236
|
+
op: :records_read
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'record_write',
|
|
240
|
+
description: "Insert/update one record in a mod's .json source, keyed by type+id (JSON only).",
|
|
241
|
+
input_schema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
mod: { type: 'string', description: 'Mod name (folder under mods/).' },
|
|
245
|
+
record: { type: 'object', description: 'TES3 record; needs "type" (+ "id" unless Header).' }
|
|
246
|
+
},
|
|
247
|
+
required: %w[mod record],
|
|
248
|
+
additionalProperties: false
|
|
249
|
+
},
|
|
250
|
+
op: :record_write
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: 'dialogue_write',
|
|
254
|
+
description: "Author dialogue from a JSON spec into a mod's .json source (data-driven Dialogue " \
|
|
255
|
+
'DSL). A topic is replaced wholesale, so re-authoring leaves no orphan info records. ' \
|
|
256
|
+
'Info filters mirror the DSL (speaker/race/class/faction/cell/sex/pc_faction/pc_rank/' \
|
|
257
|
+
'speaker_rank/disposition/sound/result_script/journal_index); @t:key in text for i18n.',
|
|
258
|
+
input_schema: {
|
|
259
|
+
type: 'object',
|
|
260
|
+
properties: {
|
|
261
|
+
mod: { type: 'string', description: 'Mod name (folder under mods/).' },
|
|
262
|
+
spec: {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {
|
|
265
|
+
topics: {
|
|
266
|
+
type: 'array',
|
|
267
|
+
items: {
|
|
268
|
+
type: 'object',
|
|
269
|
+
properties: {
|
|
270
|
+
name: { type: 'string' },
|
|
271
|
+
type: { type: 'string', enum: %w[topic journal greeting persuasion voice] },
|
|
272
|
+
speaker: { type: 'string' },
|
|
273
|
+
infos: {
|
|
274
|
+
type: 'array',
|
|
275
|
+
items: { type: 'object', properties: { text: { type: 'string' } } }
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
required: %w[name infos]
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
required: ['topics']
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
required: %w[mod spec],
|
|
286
|
+
additionalProperties: false
|
|
287
|
+
},
|
|
288
|
+
op: :dialogue_write
|
|
289
|
+
}
|
|
290
|
+
].freeze
|
|
291
|
+
|
|
292
|
+
TOOLS_BY_NAME = TOOLS.to_h { |t| [t[:name], t] }.freeze
|
|
293
|
+
|
|
294
|
+
# Narrative docs exposed as resources, by slug under docs/.
|
|
295
|
+
DOC_PAGES = [
|
|
296
|
+
['walkthrough', 'Walkthrough', 'End-to-end: a plugin from empty to built.'],
|
|
297
|
+
['getting-started', 'Getting started', 'Install, macOS paths, wiring mw into an AI client.'],
|
|
298
|
+
['authoring-guide', 'Authoring guide', 'Source formats, scripts, dialogue DSL, i18n, linting.'],
|
|
299
|
+
['architecture', 'Architecture', 'Layered design and the roadmap.']
|
|
300
|
+
].freeze
|
|
301
|
+
|
|
302
|
+
# Read-only context an agent can pull without a tool call: the command
|
|
303
|
+
# tree (same payload as the `commands` tool) and the narrative docs.
|
|
304
|
+
# Each :read lambda produces the resource body on demand.
|
|
305
|
+
RESOURCES = ([
|
|
306
|
+
{ uri: 'mw://introspection', name: 'mw command tree',
|
|
307
|
+
description: 'Full command tree, options, and module surface.',
|
|
308
|
+
mime_type: 'application/json',
|
|
309
|
+
read: -> { JSON.pretty_generate(Esp::Introspection.command_tree) } }
|
|
310
|
+
] + DOC_PAGES.map do |slug, name, description|
|
|
311
|
+
{ uri: "mw://docs/#{slug}", name: name, description: description, mime_type: 'text/markdown',
|
|
312
|
+
read: -> { File.read(File.join(Esp::ROOT, 'docs', "#{slug}.md")) } }
|
|
313
|
+
end).freeze
|
|
314
|
+
|
|
315
|
+
RESOURCES_BY_URI = RESOURCES.to_h { |r| [r[:uri], r] }.freeze
|
|
316
|
+
|
|
317
|
+
# JSON-RPC error codes (subset of the spec we actually emit).
|
|
318
|
+
PARSE_ERROR = -32_700
|
|
319
|
+
INVALID_REQUEST = -32_600
|
|
320
|
+
METHOD_NOT_FOUND = -32_601
|
|
321
|
+
INVALID_PARAMS = -32_602
|
|
322
|
+
INTERNAL_ERROR = -32_603
|
|
323
|
+
|
|
324
|
+
# Caller-fault errors surfaced as a tool result rather than a protocol
|
|
325
|
+
# error — the shared list Operations owns.
|
|
326
|
+
TOOL_ERRORS = Esp::Operations.caller_errors
|
|
327
|
+
|
|
328
|
+
# Raised internally to carry a JSON-RPC error code out to the responder.
|
|
329
|
+
class ProtocolError < StandardError
|
|
330
|
+
attr_reader :code
|
|
331
|
+
|
|
332
|
+
def initialize(code, message)
|
|
333
|
+
super(message)
|
|
334
|
+
@code = code
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def initialize(input: $stdin, output: $stdout)
|
|
339
|
+
@input = input
|
|
340
|
+
@output = output
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Read JSON-RPC messages line by line until stdin closes. Each line is a
|
|
344
|
+
# complete message; blank lines are ignored.
|
|
345
|
+
def start
|
|
346
|
+
@input.each_line do |line|
|
|
347
|
+
line = line.strip
|
|
348
|
+
next if line.empty?
|
|
349
|
+
|
|
350
|
+
response = process(line)
|
|
351
|
+
write(response) if response
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
private
|
|
356
|
+
|
|
357
|
+
def process(line)
|
|
358
|
+
message = JSON.parse(line)
|
|
359
|
+
rescue JSON::ParserError
|
|
360
|
+
error_response(nil, PARSE_ERROR, 'parse error')
|
|
361
|
+
else
|
|
362
|
+
handle(message)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def handle(message)
|
|
366
|
+
raise ProtocolError.new(INVALID_REQUEST, 'invalid request') unless message.is_a?(Hash)
|
|
367
|
+
|
|
368
|
+
id = message['id']
|
|
369
|
+
result = dispatch(message['method'], message['params'] || {}, id)
|
|
370
|
+
id.nil? ? nil : success_response(id, result)
|
|
371
|
+
rescue ProtocolError => e
|
|
372
|
+
error_response(message_id(message), e.code, e.message)
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
error_response(message_id(message), INTERNAL_ERROR, e.message)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Returns the result for a known method. Unknown methods raise
|
|
378
|
+
# METHOD_NOT_FOUND for requests but are silently dropped for
|
|
379
|
+
# notifications (no id), per JSON-RPC.
|
|
380
|
+
def dispatch(method, params, id)
|
|
381
|
+
case method
|
|
382
|
+
when 'initialize' then initialize_result
|
|
383
|
+
when 'tools/list' then { tools: tool_descriptors }
|
|
384
|
+
when 'tools/call' then call_tool(params)
|
|
385
|
+
when 'resources/list' then { resources: resource_descriptors }
|
|
386
|
+
when 'resources/templates/list' then { resourceTemplates: [] }
|
|
387
|
+
when 'resources/read' then read_resource(params)
|
|
388
|
+
when 'ping' then {}
|
|
389
|
+
when %r{\Anotifications/} then nil # fire-and-forget, no response
|
|
390
|
+
else
|
|
391
|
+
raise ProtocolError.new(METHOD_NOT_FOUND, "method not found: #{method}") unless id.nil?
|
|
392
|
+
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def initialize_result
|
|
398
|
+
{
|
|
399
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
400
|
+
capabilities: { tools: {}, resources: {} },
|
|
401
|
+
serverInfo: { name: 'esp', version: Esp::VERSION }
|
|
402
|
+
}
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def tool_descriptors
|
|
406
|
+
TOOLS.map do |tool|
|
|
407
|
+
{ name: tool[:name], description: tool[:description], inputSchema: tool[:input_schema] }
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def resource_descriptors
|
|
412
|
+
RESOURCES.map do |r|
|
|
413
|
+
{ uri: r[:uri], name: r[:name], description: r[:description], mimeType: r[:mime_type] }
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def read_resource(params)
|
|
418
|
+
uri = params['uri']
|
|
419
|
+
resource = RESOURCES_BY_URI[uri]
|
|
420
|
+
raise ProtocolError.new(INVALID_PARAMS, "unknown resource: #{uri}") unless resource
|
|
421
|
+
|
|
422
|
+
{ contents: [{ uri: uri, mimeType: resource[:mime_type], text: resource[:read].call }] }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def call_tool(params)
|
|
426
|
+
name = params['name']
|
|
427
|
+
tool = TOOLS_BY_NAME[name]
|
|
428
|
+
raise ProtocolError.new(INVALID_PARAMS, "unknown tool: #{name}") unless tool
|
|
429
|
+
|
|
430
|
+
payload = Esp::Operations.dispatch(tool[:op], params['arguments'] || {})
|
|
431
|
+
tool_result(payload)
|
|
432
|
+
rescue *TOOL_ERRORS => e
|
|
433
|
+
tool_error(e)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def tool_result(payload)
|
|
437
|
+
{ content: [{ type: 'text', text: JSON.pretty_generate(payload) }] }
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Structured tool error: the text content is a JSON object carrying a
|
|
441
|
+
# stable code + message, so an agent can branch on the code rather than
|
|
442
|
+
# parse prose. isError tells the client the call failed but is recoverable.
|
|
443
|
+
def tool_error(exception)
|
|
444
|
+
payload = { error: { code: Esp::Operations.error_code(exception), message: exception.message } }
|
|
445
|
+
{ content: [{ type: 'text', text: JSON.pretty_generate(payload) }], isError: true }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def success_response(id, result)
|
|
449
|
+
{ jsonrpc: '2.0', id: id, result: result }
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def error_response(id, code, message)
|
|
453
|
+
{ jsonrpc: '2.0', id: id, error: { code: code, message: message } }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def message_id(message)
|
|
457
|
+
message.is_a?(Hash) ? message['id'] : nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def write(response)
|
|
461
|
+
@output.puts(JSON.generate(response))
|
|
462
|
+
@output.flush
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module Esp
|
|
6
|
+
module Mw
|
|
7
|
+
# Orchestrates mods/<Mod>/<Mod>.json -> dist/<Mod>.esp:
|
|
8
|
+
# 1. Load source JSON.
|
|
9
|
+
# 2. Run Esp::Mw::Preflight to inline text_source files and regenerate
|
|
10
|
+
# Script record blobs.
|
|
11
|
+
# 3. Hand the canonical JSON to tes3conv via a tempfile (whose
|
|
12
|
+
# extension must be .json — tes3conv sniffs by extension).
|
|
13
|
+
module Builder
|
|
14
|
+
Result = Struct.new(:output, :logs, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def build(mod, root: Esp::ROOT, locale: nil)
|
|
18
|
+
source = Esp::Mw::Loader.resolve(mod, root: root)
|
|
19
|
+
source_dir = File.dirname(source)
|
|
20
|
+
|
|
21
|
+
i18n = build_i18n(source_dir, locale)
|
|
22
|
+
records = Esp::Mw::Loader.load(source, i18n: i18n)
|
|
23
|
+
i18n.resolve!(records)
|
|
24
|
+
|
|
25
|
+
logs = Esp::Mw::Preflight.process!(records, source_dir: source_dir)
|
|
26
|
+
logs.concat(i18n_miss_logs(i18n))
|
|
27
|
+
|
|
28
|
+
output = output_path(mod, root, locale: locale)
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
30
|
+
|
|
31
|
+
Tempfile.create(['mw-build-', '.json']) do |tmp|
|
|
32
|
+
tmp.write("#{JSON.pretty_generate(records)}\n")
|
|
33
|
+
tmp.close
|
|
34
|
+
Esp::Mw::Tes3conv.convert(tmp.path, output)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Result.new(output: output, logs: logs)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def output_path(mod, root, locale: nil)
|
|
41
|
+
name = locale ? "#{mod}.#{locale}" : mod
|
|
42
|
+
File.join(root, 'dist', "#{name}.esp")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def discover_mods(root: Esp::ROOT)
|
|
46
|
+
Dir.glob(File.join(root, 'mods', '*')).filter_map do |dir|
|
|
47
|
+
next unless File.directory?(dir)
|
|
48
|
+
|
|
49
|
+
name = File.basename(dir)
|
|
50
|
+
unless Esp::Mw::Loader.supported_exts.any? { |ext| File.exist?(File.join(dir, "#{name}#{ext}")) }
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
name
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build_i18n(source_dir, locale)
|
|
61
|
+
catalogues = Esp::Mw::I18n.load_catalogues(source_dir)
|
|
62
|
+
Esp::Mw::I18n.new(catalogues, locale: locale || Esp::Mw::I18n::DEFAULT_LOCALE)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def i18n_miss_logs(i18n)
|
|
66
|
+
i18n.misses.map { |m| "i18n: missing key #{m.key.inspect} in locale #{m.locale.inspect}" }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Esp
|
|
2
|
+
module Mw
|
|
3
|
+
# Where Morrowind keeps its vanilla data files (`Morrowind.esm` and
|
|
4
|
+
# friends, plus user-installed `.esp` plugins for the original engine).
|
|
5
|
+
# Engine-agnostic — OpenMW reads them via `data=` in openmw.cfg; the
|
|
6
|
+
# original engine reads them in place. The same set of probe paths
|
|
7
|
+
# serves both:
|
|
8
|
+
#
|
|
9
|
+
# - `esp refs unpack` reads the vanilla ESMs out of here.
|
|
10
|
+
# - `esp install --to-data-files` copies a built plugin into here so
|
|
11
|
+
# the original engine's launcher picks it up.
|
|
12
|
+
#
|
|
13
|
+
# Detection is best-effort, per-OS Steam/GOG defaults. Anything
|
|
14
|
+
# non-default is overridable via $MORROWIND_DATA or an explicit flag.
|
|
15
|
+
module DataFiles
|
|
16
|
+
class << self
|
|
17
|
+
def candidates
|
|
18
|
+
home = Dir.home
|
|
19
|
+
case Esp::Mw::OpenmwConfig.host_os
|
|
20
|
+
when :macos then macos_candidates(home)
|
|
21
|
+
when :windows then windows_candidates
|
|
22
|
+
else linux_candidates(home)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The first existing candidate, else the first candidate (so error
|
|
27
|
+
# messages can still name a reasonable path even when nothing's
|
|
28
|
+
# installed).
|
|
29
|
+
def default
|
|
30
|
+
candidates.find { |dir| File.directory?(dir) } || candidates.first
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolve the user's intent: --data override beats $MORROWIND_DATA
|
|
34
|
+
# beats the per-OS default. Returns the resolved path (no validation
|
|
35
|
+
# — the caller decides whether to error on a missing dir).
|
|
36
|
+
def resolve(override: nil)
|
|
37
|
+
return override if override && !override.to_s.empty?
|
|
38
|
+
return ENV['MORROWIND_DATA'] if ENV['MORROWIND_DATA'] && !ENV['MORROWIND_DATA'].empty?
|
|
39
|
+
|
|
40
|
+
default
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def macos_candidates(home)
|
|
46
|
+
steam = "#{home}/Library/Application Support/Steam/steamapps/common"
|
|
47
|
+
["#{steam}/The Elder Scrolls III - Morrowind/Data Files",
|
|
48
|
+
"#{steam}/Morrowind/Data Files"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def windows_candidates
|
|
52
|
+
['C:/Program Files (x86)/Steam/steamapps/common/Morrowind/Data Files',
|
|
53
|
+
'C:/GOG Games/Morrowind/Data Files',
|
|
54
|
+
'C:/Program Files (x86)/GOG Galaxy/Games/Morrowind/Data Files',
|
|
55
|
+
'C:/Program Files/Bethesda Softworks/Morrowind/Data Files']
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def linux_candidates(home)
|
|
59
|
+
["#{home}/.steam/steam/steamapps/common/Morrowind/Data Files",
|
|
60
|
+
"#{home}/.local/share/Steam/steamapps/common/Morrowind/Data Files",
|
|
61
|
+
"#{home}/Documents/Games/Morrowind/app/Data Files",
|
|
62
|
+
"#{home}/.wine/drive_c/Program Files/Bethesda Softworks/Morrowind/Data Files"]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|