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,193 @@
1
+ # Walkthrough: a mod from empty to built
2
+
3
+ This is the end-to-end story the reference docs only show in pieces:
4
+ authoring one real plugin from nothing, the way the toolchain wants you
5
+ to work. If you've used the Construction Set, the mental shift is the
6
+ whole point — so it's worth stating up front.
7
+
8
+ ## The shift: source is truth, the `.esp` is output
9
+
10
+ The Construction Set is a direct-manipulation binary editor. The `.esp`
11
+ *is* the document; you click forms and a 3D viewport, and the binary is
12
+ what you save. `esp` inverts that. **You author intent as
13
+ human-readable source, and the `.esp` is a disposable build artifact** —
14
+ the same relationship a compiler has with its object files. You never
15
+ hand-edit the `.esp`; you edit source and rebuild.
16
+
17
+ That one inversion is what buys you `git diff`, branches, code review,
18
+ procedural generation, lint-as-a-build-step, and AI authoring. The cost
19
+ is real — you give up spatial placement and visual browsing — but
20
+ everything below is what you get back.
21
+
22
+ ## 0. One-time setup
23
+
24
+ ```sh
25
+ bundle install
26
+ bin/esp setup # git diff driver + pre-commit hook
27
+ bin/esp refs unpack # vanilla Morrowind/Tribunal/Bloodmoon -> JSON
28
+ bin/esp refs index # SQLite index over ~69k records (~3s)
29
+ ```
30
+
31
+ `refs unpack` + `refs index` are what let `lint` and `refs find` resolve
32
+ foreign IDs (does `Imperial Legion` exist? what's the editor ID of that
33
+ sign in Seyda Neen?). You only redo them when you upgrade the game.
34
+ See [getting-started](getting-started.md) for installing tes3conv itself.
35
+
36
+ ## 1. Scaffold
37
+
38
+ We'll build a signpost for Seyda Neen — an activator with a script
39
+ attached. Scaffold the folder:
40
+
41
+ ```sh
42
+ $ bin/esp new SeydaSign --author "Corey Ellis" \
43
+ --description "A welcome sign in Seyda Neen"
44
+ created: mods/SeydaSign/SeydaSign.json
45
+ created: mods/SeydaSign/README.md
46
+ next: bin/esp build SeydaSign
47
+ ```
48
+
49
+ `esp new` defaults to JSON; pass `--format rb|py|js|ts` to author in a
50
+ real language instead. Everything for this mod now lives under
51
+ `mods/SeydaSign/` — source, scripts, translations, design notes all stay
52
+ together (see [authoring-guide](authoring-guide.md#per-mod-self-containment)).
53
+
54
+ ## 2. Author the records
55
+
56
+ Open `mods/SeydaSign/SeydaSign.json`. The scaffold gives you just a
57
+ `Header`; add an `Activator` that references a script:
58
+
59
+ ```json
60
+ [
61
+ { "type": "Header", "flags": "", "file_type": "Esp", "version": 1.3,
62
+ "author": "Corey Ellis", "description": "A welcome sign in Seyda Neen",
63
+ "num_objects": 0, "masters": [["Morrowind.esm", 79837557]] },
64
+ { "type": "Activator", "flags": "", "id": "seyda_welcome_sign",
65
+ "name": "Welcome to Seyda Neen", "script": "seyda_sign_script",
66
+ "mesh": "f/active_sign.nif" }
67
+ ]
68
+ ```
69
+
70
+ > **The `flags` gotcha.** Every TES3 record needs `"flags": ""` even when
71
+ > it's empty — tes3conv rejects the build otherwise. If you forget, you
72
+ > get a clean, pointed error rather than a stack trace:
73
+ >
74
+ > ```
75
+ > $ bin/esp build SeydaSign
76
+ > error: Error: Custom { kind: InvalidData, error: Error("missing field `flags`", line: 6, column: 1) }
77
+ > hint: every record needs "flags": "" — tes3conv requires it even when empty
78
+ > ```
79
+
80
+ ## 3. Lint catches the dangling reference
81
+
82
+ We pointed the activator at `seyda_sign_script`, but that script doesn't
83
+ exist yet — not in this mod, not in vanilla. `lint` knows, because it
84
+ resolves every reference against the mod plus the indexed ESMs:
85
+
86
+ ```sh
87
+ $ bin/esp lint SeydaSign
88
+ ERROR Activator seyda_welcome_sign.script -> seyda_sign_script: unknown Script
89
+ 1 error(s), 0 warning(s)
90
+ ```
91
+
92
+ It exits non-zero, so this is the line you wire into CI. (A reference
93
+ that resolves in a vanilla ESM you *forgot to list as a master* is a
94
+ `WARN`, not an error — OpenMW loads it but logs complaints.)
95
+
96
+ ## 4. Add the script, go green
97
+
98
+ Inline MWScript text is unreadable inside JSON, so author it as a file
99
+ and reference it with `text_source` — the build inlines it and
100
+ regenerates the binary script blobs tes3conv expects:
101
+
102
+ ```
103
+ mods/SeydaSign/scripts/seyda_sign_script.mwscript
104
+ ```
105
+
106
+ ```mwscript
107
+ Begin seyda_sign_script
108
+ ; activate-only sign; nothing to do
109
+ End
110
+ ```
111
+
112
+ Add the `Script` record pointing at that file:
113
+
114
+ ```json
115
+ { "type": "Script", "flags": "", "id": "seyda_sign_script",
116
+ "text_source": "scripts/seyda_sign_script.mwscript" }
117
+ ```
118
+
119
+ ```sh
120
+ $ bin/esp lint SeydaSign
121
+ ok
122
+ ```
123
+
124
+ Need the editor ID of an existing record to reference? That's what the
125
+ index is for:
126
+
127
+ ```sh
128
+ $ bin/esp refs find "seyda neen"
129
+ Morrowind.esm Activator active_sign_seydaneen_01
130
+ Morrowind.esm Activator active_sign_seydaneen_02
131
+ ...
132
+ ```
133
+
134
+ ## 5. Build and install
135
+
136
+ ```sh
137
+ $ bin/esp build SeydaSign
138
+ build: SeydaSign -> dist/SeydaSign.esp
139
+
140
+ $ bin/esp install SeydaSign # appends data= + content= lines to openmw.cfg
141
+ ```
142
+
143
+ `bin/esp build SeydaSign --install` does both in one step (and
144
+ `--config <path>` targets a specific `openmw.cfg`). Launch OpenMW, confirm
145
+ `SeydaSign.esp` is enabled in the launcher. The
146
+ `.esp` in `dist/` is throwaway — delete it and `esp build` reproduces it
147
+ byte-for-byte from source.
148
+
149
+ ## 6. The payoff: review the diff, not the binary
150
+
151
+ Because the source is text, the change you just made is a readable diff.
152
+ In a real mod project you'd track `mods/` in git (it's gitignored *in
153
+ the toolchain repo* only because that repo ships the tool, not content):
154
+
155
+ ```sh
156
+ $ git add mods/SeydaSign && git diff --cached --stat
157
+ mods/SeydaSign/SeydaSign.json | 4 ++++
158
+ mods/SeydaSign/scripts/seyda_sign_script.mwscript | 3 +++
159
+ ```
160
+
161
+ Adding the script is `+4 / +3` lines you can read, blame, branch, and
162
+ review — not an opaque re-saved `.esp`. That is the entire reason for
163
+ the inversion.
164
+
165
+ ## Same pipeline, three frontends
166
+
167
+ Every step above is also an HTTP endpoint (`esp serve`) and an MCP tool
168
+ (`esp mcp serve`), returning the identical `--json` payloads. So an AI
169
+ agent runs exactly this loop without a human at the keyboard:
170
+
171
+ ```sh
172
+ $ bin/esp lint SeydaSign --json
173
+ {"mod":"SeydaSign","errors":0,"warnings":0,"issues":[]}
174
+ ```
175
+
176
+ 1. `commands` / `esp docs introspect` — discover the surface.
177
+ 2. `refs_find` — look up real vanilla IDs to reference.
178
+ 3. Write the source file.
179
+ 4. `lint --json` — validate; structured errors come back as
180
+ `{"error": "…"}`.
181
+ 5. `build --json` — produce the `.esp`.
182
+
183
+ That agent loop driving diffable source is the backbone of **ESPresso**,
184
+ the AI-first GUI built on this toolchain. The human's job moves from
185
+ clicking forms to reviewing the diff in step 6.
186
+
187
+ ## Where to go next
188
+
189
+ - [Authoring guide](authoring-guide.md) — every source format, scripts,
190
+ dialogue DSL, i18n, linting in full.
191
+ - [Getting started](getting-started.md) — install, paths, wiring `esp`
192
+ into an AI client over MCP.
193
+ - [Architecture](architecture.md) — why the pipeline is shaped this way.
data/exe/esp ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The gem's executable. Unlike bin/esp (a dev-repo bash launcher that pins
5
+ # rbenv + BUNDLE_GEMFILE to this checkout), this is plain Ruby: RubyGems has
6
+ # already put the gem's lib + its dependencies on $LOAD_PATH, so a bare
7
+ # `require 'esp'` loads the whole toolchain. No Bundler involved.
8
+ require 'esp'
9
+
10
+ Esp::CLI.start(ARGV)
@@ -0,0 +1,71 @@
1
+ module Esp
2
+ # Process-scoped state for "which project is the user currently working in"
3
+ # (step 23). Swapped in by ESPresso's POST /open-project; helpers that need
4
+ # a working-tree root (vcs_root for diff/approve/reject today; more later)
5
+ # consult this before falling back to Esp::ROOT.
6
+ #
7
+ # Step 22.5 slice 2: also tracks the project's game id (`mw` today, future
8
+ # `ob`/`sr`) so the plugin registry knows which Operations module owns each
9
+ # op for the current request. Missing game on a project read defaults to
10
+ # `mw` for backward compat (every pre-rename project is Morrowind).
11
+ #
12
+ # Stored module-globally rather than per-request because the WEBrick backend
13
+ # is single-tenant per-instance (one ESPresso ↔ one esp serve), so a global
14
+ # is the right shape; the mutex guards against the thread-per-request model
15
+ # racing a concurrent set + read.
16
+ module ActiveProject
17
+ @mutex = Mutex.new
18
+ @root = nil
19
+ @game = nil
20
+
21
+ class << self
22
+ def root
23
+ @mutex.synchronize { @root }
24
+ end
25
+
26
+ def game
27
+ @mutex.synchronize { @game }
28
+ end
29
+
30
+ # Store a project root + game id. Returns the normalized path so callers
31
+ # don't have to expand again. No directory check here —
32
+ # Operations.open_project validates before calling (separation: this
33
+ # module is pure state).
34
+ def set(path, game: nil)
35
+ normalized = File.expand_path(path.to_s)
36
+ @mutex.synchronize do
37
+ @root = normalized
38
+ @game = game
39
+ end
40
+ normalized
41
+ end
42
+
43
+ def clear!
44
+ @mutex.synchronize do
45
+ @root = nil
46
+ @game = nil
47
+ end
48
+ end
49
+
50
+ # Resolve a root for a request: explicit `root:` param wins (preserves
51
+ # the diff-panel pattern of "operate on a specific tree even if a
52
+ # project is open"), then the active project, then nil — the caller
53
+ # decides whether to fall back to Esp::ROOT or error.
54
+ def root_for(params)
55
+ explicit = params.is_a?(Hash) ? params['root'] : nil
56
+ return explicit unless explicit.nil? || explicit.to_s.empty?
57
+
58
+ root
59
+ end
60
+
61
+ # Same precedence as root_for, but with Esp::ROOT as a final fallback.
62
+ # This is what every op that *needs* a root should call — diff/approve/
63
+ # reject already do (via Esp::Operations#vcs_root); slice 1 of step
64
+ # 23.5 routes the game ops (build, lint, scaffold, …) through it too
65
+ # so ESPresso's "open project" actually moves where they operate.
66
+ def resolve(params = {})
67
+ root_for(params) || Esp::ROOT
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/esp/agent.rb ADDED
@@ -0,0 +1,104 @@
1
+ require 'json'
2
+
3
+ module Esp
4
+ # A hand-rolled tool-use loop. The agent authors mods by calling the same
5
+ # curated tool surface MCP exposes (Esp::McpServer::TOOLS), each tool
6
+ # dispatched to Esp::Operations.public_send(op, …) — so the in-house agent
7
+ # and external MCP clients share one tool definition.
8
+ #
9
+ # The LLM is reached through an injected **provider** (see Esp::Providers), so
10
+ # the loop is provider-agnostic and fully testable with a stub and no live
11
+ # key. When none is given, the default provider is built from the registry
12
+ # (Esp::Providers.default_id). See .claude/roadmap/19-agent-workspace.md.
13
+ #
14
+ # Transcript shape (neutral, owned by Agent; providers translate it to their
15
+ # native wire format):
16
+ # { role: :user, text: String }
17
+ # { role: :assistant, text: String, tool_calls: [{id:, name:, input:}],
18
+ # raw: <provider-native message, opaque to Agent> }
19
+ # { role: :tool, results: [{id:, content: String, is_error: bool}] }
20
+ class Agent
21
+ MAX_TURNS = 20
22
+
23
+ SYSTEM = <<~PROMPT.freeze
24
+ You are the authoring agent inside ESPresso, the GUI for the `esp`
25
+ Morrowind modding toolchain. You build and edit mods by calling the
26
+ provided tools, which operate on the user's mod project as diffable
27
+ source. Read current state (records_read, refs_find) before writing.
28
+ Make the smallest change that satisfies the request, then build and
29
+ lint to confirm. Keep explanations brief — the human reviews the diff.
30
+ PROMPT
31
+
32
+ Result = Struct.new(:text, :messages, keyword_init: true)
33
+
34
+ def initialize(provider: nil, system: SYSTEM, max_turns: MAX_TURNS)
35
+ @provider = provider || Esp::Providers.build(Esp::Providers.default_id)
36
+ @system = system
37
+ @max_turns = max_turns
38
+ end
39
+
40
+ # Run the loop to completion for one user prompt. Optionally yields events
41
+ # as they happen ([:text, str] / [:tool_use, name, input] /
42
+ # [:tool_result, name, payload] / [:tool_error, name, error]) for a
43
+ # streaming UI to render.
44
+ def run(prompt, &on_event)
45
+ messages = [{ role: :user, text: prompt }]
46
+ @max_turns.times do
47
+ completion = @provider.complete(system: @system, tools: tool_definitions, messages: messages)
48
+ tool_calls = Array(completion.tool_calls)
49
+ messages << { role: :assistant, text: completion.text, tool_calls: tool_calls, raw: completion.raw }
50
+ on_event&.call([:text, completion.text]) unless completion.text.to_s.empty?
51
+
52
+ break if tool_calls.empty?
53
+
54
+ messages << { role: :tool, results: tool_calls.map { |tc| run_tool(tc, &on_event) } }
55
+ end
56
+ Result.new(text: final_text(messages), messages: messages)
57
+ end
58
+
59
+ private
60
+
61
+ def tool_definitions
62
+ Esp::McpServer::TOOLS.map do |tool|
63
+ { name: tool[:name], description: tool[:description], input_schema: tool[:input_schema] }
64
+ end
65
+ end
66
+
67
+ # Dispatch one normalized tool call to its Operations method, returning a
68
+ # neutral tool result. Caller-fault errors (and an unknown tool name) come
69
+ # back as is_error results the model can recover from.
70
+ def run_tool(tool_call, &on_event)
71
+ name = tool_call[:name].to_s
72
+ input = deep_stringify(tool_call[:input] || {})
73
+ on_event&.call([:tool_use, name, input])
74
+
75
+ op = Esp::McpServer::TOOLS_BY_NAME.fetch(name)[:op]
76
+ payload = Esp::Operations.dispatch(op, input)
77
+ on_event&.call([:tool_result, name, payload])
78
+ { id: tool_call[:id], content: JSON.generate(payload), is_error: false }
79
+ rescue KeyError
80
+ tool_error(tool_call[:id], name, 'unknown_tool', name, &on_event)
81
+ rescue *Esp::Operations.caller_errors => e
82
+ tool_error(tool_call[:id], name, Esp::Operations.error_code(e), e.message, &on_event)
83
+ end
84
+
85
+ def tool_error(id, name, code, message, &on_event)
86
+ error = { error: { code: code, message: message } }
87
+ on_event&.call([:tool_error, name, error])
88
+ { id: id, content: JSON.generate(error), is_error: true }
89
+ end
90
+
91
+ def final_text(messages)
92
+ last = messages.reverse.find { |m| m[:role] == :assistant }
93
+ last ? last[:text].to_s : ''
94
+ end
95
+
96
+ def deep_stringify(obj)
97
+ case obj
98
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
99
+ when Array then obj.map { |v| deep_stringify(v) }
100
+ else obj
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,44 @@
1
+ require 'json'
2
+ require 'thor'
3
+
4
+ module Esp
5
+ class CLI < Thor
6
+ class Docs < Thor
7
+ include Support
8
+
9
+ def self.exit_on_failure? = true
10
+
11
+ class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
12
+
13
+ desc 'build', 'Regenerate docs/reference/ from the CLI tree and module headers'
14
+ option :out, type: :string, default: 'docs/reference',
15
+ desc: 'Output directory (relative to repo root)'
16
+ def build
17
+ out_dir = File.expand_path(options[:out], Esp::ROOT)
18
+ result = Esp::DocsGenerator.build(output_dir: out_dir)
19
+ relative = ->(p) { p.sub("#{Esp::ROOT}/", '') }
20
+ payload = {
21
+ output_dir: relative.call(out_dir),
22
+ commands_md: relative.call(result[:commands]),
23
+ api_index_md: relative.call(result[:api_index]),
24
+ api_module_md: result[:api_modules].map(&relative)
25
+ }
26
+ respond(payload) do
27
+ say t('docs.wrote', path: relative.call(result[:commands]))
28
+ say t('docs.wrote', path: relative.call(result[:api_index]))
29
+ result[:api_modules].each { |p| say t('docs.wrote', path: relative.call(p)) }
30
+ end
31
+ end
32
+
33
+ desc 'introspect', 'Dump the CLI command tree + module surface as JSON (always JSON)'
34
+ def introspect
35
+ payload = {
36
+ version: Esp::VERSION,
37
+ commands: Esp::Introspection.command_tree,
38
+ modules: Esp::Introspection.module_docs
39
+ }
40
+ $stdout.puts(JSON.generate(payload))
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,67 @@
1
+ require 'json'
2
+ require 'thor'
3
+
4
+ module Esp
5
+ class CLI < Thor
6
+ class I18nCli < Thor
7
+ include Support
8
+
9
+ def self.exit_on_failure? = true
10
+
11
+ class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
12
+ class_option :root, type: :string,
13
+ desc: 'Project root (defaults to the active project or the toolchain repo)'
14
+
15
+ desc 'check MOD', 'Report missing/orphan i18n keys per locale (vs. en)'
16
+ def check(mod)
17
+ result = Esp::Operations.dispatch(:i18n_check, params_with_root('mod' => mod))
18
+ respond(result) { render(result[:locales]) }
19
+ rescue Esp::Operations::InputError, Esp::Mw::Loader::LoadError => e
20
+ fail_with(e.message)
21
+ end
22
+
23
+ desc 'audit', 'Audit tool-UI locales: undefined/unused/orphan keys + missing translations'
24
+ def audit
25
+ report = Esp::UI.audit
26
+ respond(report) { render_audit(report) }
27
+ problems = report[:undefined].size + report[:unused].size + report[:orphans].size
28
+ exit(1) if problems.positive?
29
+ end
30
+
31
+ no_commands do
32
+ def render_audit(report)
33
+ render_key_list(t('i18n.audit.undefined'), report[:undefined])
34
+ render_key_list(t('i18n.audit.unused'), report[:unused])
35
+ report[:orphans].each { |loc, keys| render_key_list(t('i18n.audit.orphans', locale: loc), keys) }
36
+ report[:missing].each { |loc, keys| say t('i18n.audit.missing', locale: loc, count: keys.size) }
37
+ say(t('i18n.audit.clean')) if clean?(report)
38
+ end
39
+
40
+ def clean?(report)
41
+ report.values_at(:undefined, :unused, :orphans, :missing).all?(&:empty?)
42
+ end
43
+
44
+ def render_key_list(heading, keys)
45
+ return if keys.empty?
46
+
47
+ say heading
48
+ keys.each { |k| say t('i18n.audit.item', key: k) }
49
+ end
50
+
51
+ def render(report)
52
+ if report.empty?
53
+ say(t('i18n.none'))
54
+ return
55
+ end
56
+
57
+ report.each do |locale, info|
58
+ say t('i18n.locale', locale: locale)
59
+ info[:missing].each { |k| say t('i18n.missing', key: k) }
60
+ info[:orphan].each { |k| say t('i18n.orphan', key: k) }
61
+ say(t('i18n.ok')) if info[:missing].empty? && info[:orphan].empty?
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,52 @@
1
+ require 'json'
2
+ require 'thor'
3
+
4
+ module Esp
5
+ class CLI < Thor
6
+ # `esp mcp` — Model Context Protocol frontend. `serve` speaks JSON-RPC
7
+ # over stdio so an AI client can drive the toolchain as native tools;
8
+ # `install` wires that server into a client's config.
9
+ class Mcp < Thor
10
+ include Support
11
+
12
+ def self.exit_on_failure? = true
13
+
14
+ class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
15
+
16
+ desc 'serve', 'Run the MCP server over stdio (JSON-RPC 2.0) for AI clients'
17
+ def serve
18
+ # stdout is the protocol channel — never print to it here.
19
+ warn(t('mcp.serve.listening'))
20
+ Esp::McpServer.new.start
21
+ end
22
+
23
+ desc 'install', 'Register `esp mcp serve` with an AI client (Claude Code / Desktop)'
24
+ option :client, type: :string, default: 'claude-code',
25
+ enum: Esp::McpInstaller.clients,
26
+ desc: 'Which MCP client to configure'
27
+ option :name, type: :string, default: Esp::McpInstaller::SERVER_NAME,
28
+ desc: 'Name to register the server under in mcpServers'
29
+ def install
30
+ result = Esp::McpInstaller.install(options[:client], name: options[:name])
31
+ respond(result_payload(result)) do
32
+ say t('mcp.install.result', action: result.action, name: result.name, label: result.label)
33
+ say t('mcp.install.config', path: result.config_path)
34
+ say t('mcp.install.restart') if needs_restart?(result)
35
+ end
36
+ rescue ArgumentError => e
37
+ fail_with(e.message)
38
+ end
39
+
40
+ no_commands do
41
+ def result_payload(result)
42
+ { client: result.client, name: result.name, action: result.action.to_s,
43
+ config: result.config_path, entry: result.entry }
44
+ end
45
+
46
+ def needs_restart?(result)
47
+ result.client == 'claude-desktop' && result.action != :unchanged
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,42 @@
1
+ require 'json'
2
+ require 'thor'
3
+
4
+ module Esp
5
+ class CLI < Thor
6
+ # `esp plugins` — inspect the plugins OpenMW already has installed,
7
+ # read from openmw.cfg's data=/content= entries.
8
+ class Plugins < Thor
9
+ include Support
10
+
11
+ def self.exit_on_failure? = true
12
+
13
+ class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
14
+
15
+ desc 'list', 'List plugins installed in OpenMW (active flag + load order)'
16
+ option :config, type: :string, desc: 'Path to openmw.cfg (default: per-OS user config)'
17
+ def list
18
+ result = Esp::Operations.dispatch(:plugins_list, 'config' => options[:config])
19
+ respond(result) { render(result) }
20
+ end
21
+
22
+ no_commands do
23
+ def render(result)
24
+ say(header(result))
25
+ plugins = result[:plugins]
26
+ return say(t('plugins.list.none')) if plugins.empty?
27
+
28
+ active, inactive = plugins.partition { |p| p[:active] }
29
+ active.sort_by { |p| p[:load_order] }
30
+ .each { |p| say t('plugins.list.active', order: p[:load_order], name: p[:name]) }
31
+ inactive.sort_by { |p| p[:name] }
32
+ .each { |p| say t('plugins.list.inactive', name: p[:name]) }
33
+ end
34
+
35
+ def header(result)
36
+ key = result[:exists] ? 'plugins.list.config' : 'plugins.list.missing'
37
+ t(key, path: result[:openmw_cfg])
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end