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,137 @@
1
+ require 'json'
2
+ require 'thor'
3
+ require 'fileutils'
4
+
5
+ module Esp
6
+ class CLI < Thor
7
+ class Refs < Thor
8
+ include Support
9
+
10
+ MASTERS = %w[Morrowind Tribunal Bloodmoon].freeze
11
+
12
+ # Detection of the vanilla Data Files directory now lives on
13
+ # Esp::Mw::DataFiles so `esp install --to-data-files` can reuse it.
14
+ # Refs keeps the names for back-compat with anything that imported
15
+ # them directly.
16
+ def self.data_candidates = Esp::Mw::DataFiles.candidates
17
+ def self.default_data = Esp::Mw::DataFiles.default
18
+
19
+ def self.exit_on_failure? = true
20
+
21
+ class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
22
+ class_option :references_dir, type: :string,
23
+ desc: 'Override the references directory ' \
24
+ '(default: $ESP_REFERENCES_DIR or $ESP_DATA_DIR/references)'
25
+
26
+ desc 'unpack', 'Convert vanilla ESMs to JSON under the per-user references directory'
27
+ option :data, type: :string,
28
+ desc: 'Data Files directory (default: $MORROWIND_DATA or the detected install)'
29
+ def unpack
30
+ data = options[:data] || ENV['MORROWIND_DATA'] || self.class.default_data
31
+ out = resolved_references_dir
32
+ FileUtils.mkdir_p(out)
33
+ unpacked = []
34
+ skipped = []
35
+ MASTERS.each do |name|
36
+ src = File.join(data, "#{name}.esm")
37
+ if File.exist?(src)
38
+ dst = File.join(out, "#{name}.esm.json")
39
+ Esp::Mw::Tes3conv.convert(src, dst)
40
+ unpacked << { name: name, source: src, output: dst }
41
+ else
42
+ skipped << { name: name, source: src, reason: 'not found' }
43
+ end
44
+ end
45
+ respond({ unpacked: unpacked, skipped: skipped, references_dir: out }) do
46
+ unpacked.each { |u| say t('refs.unpack.done', name: u[:name], output: u[:output]) }
47
+ skipped.each { |s| say t('refs.unpack.skip', source: s[:source], reason: s[:reason]) }
48
+ end
49
+ end
50
+
51
+ desc 'index', 'Build/refresh the SQLite index over the per-user references directory'
52
+ def index
53
+ idx = Esp::Mw::ReferenceIndex.new(source_dir: resolved_references_dir)
54
+ n = idx.rebuild!
55
+ respond({ indexed: n, db_path: idx.db_path, source_dir: idx.source_dir }) do
56
+ say t('refs.index.indexing', dir: idx.source_dir, db: idx.db_path)
57
+ say t('refs.index.indexed', count: n)
58
+ end
59
+ rescue RuntimeError => e
60
+ fail_with(e.message)
61
+ end
62
+
63
+ desc 'find [QUERY]', 'Find vanilla records — substring match on id + name by default'
64
+ option :type, type: :string, desc: 'Filter by record type (e.g. Npc, Cell, Script)'
65
+ option :like, type: :string, desc: "SQL LIKE pattern on id (e.g. 'Fargoth%')"
66
+ option :exact, type: :boolean, desc: 'Match QUERY as an exact id instead of a substring'
67
+ option :show, type: :boolean, desc: 'Print full JSON of each match'
68
+ option :limit, type: :numeric, default: 100, desc: 'Max rows to print'
69
+ def find(query = nil)
70
+ fail_with(t('refs.find.usage')) unless any_filter?(query)
71
+ idx = require_index!
72
+ criteria = { query: query, type: options[:type], like: options[:like], exact: options[:exact] }
73
+ rows = idx.find(**criteria, limit: options[:limit])
74
+ total = idx.count_matching(**criteria)
75
+ options[:show] ? respond_show(idx, rows, total) : respond_match(rows, total)
76
+ end
77
+
78
+ no_commands do
79
+ def any_filter?(query)
80
+ query || options[:type] || options[:like]
81
+ end
82
+
83
+ # The references directory this invocation operates on. Precedence:
84
+ # --references-dir flag (per-call override for power users / CI) →
85
+ # the ReferenceIndex default ($ESP_REFERENCES_DIR / $ESP_DATA_DIR /
86
+ # ~/.config/esp).
87
+ def resolved_references_dir
88
+ override = options[:references_dir]
89
+ override.nil? || override.to_s.empty? ? Esp::Mw::ReferenceIndex.default_source_dir : override
90
+ end
91
+
92
+ def require_index!
93
+ idx = Esp::Mw::ReferenceIndex.new(source_dir: resolved_references_dir)
94
+ fail_with(t('errors.no_index', db: idx.db_path)) unless File.exist?(idx.db_path)
95
+
96
+ idx
97
+ end
98
+
99
+ def respond_match(rows, total)
100
+ respond({ matches: rows, count: rows.size, total: total }) do
101
+ if rows.empty?
102
+ say(t('refs.find.no_matches'))
103
+ else
104
+ render_table(rows)
105
+ say(truncation_note(rows.size, total))
106
+ end
107
+ end
108
+ end
109
+
110
+ def respond_show(idx, rows, total)
111
+ records = rows.map { |r| idx.fetch_record(r['source_esm'], r['record_index']) }
112
+ respond({ records: records, count: records.size, total: total }) do
113
+ records.each_with_index do |rec, i|
114
+ puts '---' if i.positive?
115
+ puts JSON.pretty_generate(rec)
116
+ end
117
+ say(truncation_note(records.size, total))
118
+ end
119
+ end
120
+
121
+ def truncation_note(shown, total)
122
+ return total == 1 ? t('refs.find.total_one') : t('refs.find.total', total: total) if shown >= total
123
+
124
+ t('refs.find.truncated', shown: shown, total: total)
125
+ end
126
+
127
+ def render_table(rows)
128
+ src_w = [12, *rows.map { |r| r['source_esm'].length }].max
129
+ type_w = [4, *rows.map { |r| r['type'].length }].max
130
+ rows.each do |r|
131
+ puts "#{r['source_esm'].ljust(src_w)} #{r['type'].ljust(type_w)} #{r['id']}"
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,87 @@
1
+ require 'json'
2
+ require 'thor'
3
+
4
+ module Esp
5
+ class CLI < Thor
6
+ # Helpers shared by the root CLI and every subcommand. Kept in one module
7
+ # rather than copy-pasted per class. They're private, and Thor only
8
+ # registers methods defined directly in a command class as commands, so
9
+ # these never become commands either way.
10
+ module Support
11
+ private
12
+
13
+ # Tool-UI string lookup (see Esp::UI).
14
+ def t(key, **vars)
15
+ Esp.t(key, **vars)
16
+ end
17
+
18
+ # JSON when --json is set, otherwise yield to the block for human text.
19
+ # Both branches see the same payload.
20
+ def respond(payload)
21
+ if options[:json]
22
+ $stdout.puts(JSON.generate(payload))
23
+ elsif block_given?
24
+ yield(payload)
25
+ end
26
+ end
27
+
28
+ # Error path: {"error": msg} on stderr in JSON mode, else a plain line.
29
+ def fail_with(message)
30
+ warn(options[:json] ? JSON.generate(error: message) : "error: #{message}")
31
+ exit(1)
32
+ end
33
+
34
+ # The project root this invocation operates on. Precedence (step 23.5
35
+ # slice 3 full chain):
36
+ #
37
+ # 1. --root flag (explicit per-call override).
38
+ # 2. $ESP_PROJECT_ROOT (per-shell default).
39
+ # 3. cwd walk-up to the nearest .esp/project.json (git-style
40
+ # discovery; legacy .espresso/project.json accepted).
41
+ # 4. Esp::ROOT (toolchain repo) as last-resort fallback. Emits a
42
+ # one-line stderr warning naming the fallback + a suggested
43
+ # `esp init`, so a user is never confused why the build went to
44
+ # this dir.
45
+ def resolve_root
46
+ explicit = options[:root]
47
+ return File.expand_path(explicit) if explicit && !explicit.to_s.empty?
48
+
49
+ env = ENV.fetch('ESP_PROJECT_ROOT', nil)
50
+ return File.expand_path(env) if env && !env.to_s.empty?
51
+
52
+ walked = Esp::ProjectMarker.find_walking_up(Dir.pwd)
53
+ return walked if walked
54
+
55
+ warn_no_project_context
56
+ Esp::ROOT
57
+ end
58
+
59
+ # Merge the resolved `root` into a params hash for `Esp::Operations.dispatch`.
60
+ # Always sets `root` (resolve_root always returns something) so the op
61
+ # never has to re-derive the precedence chain on the server side.
62
+ def params_with_root(params = {})
63
+ params.merge('root' => resolve_root)
64
+ end
65
+
66
+ # Backward-compatible helper for slice-1 callers that only wanted the
67
+ # explicit --root flag without firing the full resolution. Today no
68
+ # CLI command uses it; kept for tooling that imports Support directly.
69
+ def project_root
70
+ explicit = options[:root]
71
+ explicit.nil? || explicit.to_s.empty? ? nil : File.expand_path(explicit)
72
+ end
73
+
74
+ # Print the fallback warning at most once per invocation. Goes to
75
+ # stderr so JSON callers' stdout stays clean; respects ESP_QUIET=1
76
+ # for scripted invocations that know they're inside the toolchain
77
+ # repo and don't want noise.
78
+ def warn_no_project_context
79
+ return if @no_project_context_warned
80
+ return if ENV['ESP_QUIET'] == '1'
81
+
82
+ @no_project_context_warned = true
83
+ warn t('cli.no_project_context', root: Esp::ROOT)
84
+ end
85
+ end
86
+ end
87
+ end
data/lib/esp/cli.rb ADDED
@@ -0,0 +1,317 @@
1
+ require 'json'
2
+ require 'thor'
3
+ require 'fileutils'
4
+
5
+ module Esp
6
+ class CLI < 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
+ map 'extract-scripts' => :extract_scripts
16
+ map 'new' => :new_mod
17
+
18
+ desc 'version', 'Print esp version'
19
+ def version
20
+ respond({ version: Esp::VERSION }) { |p| say p[:version] }
21
+ end
22
+
23
+ desc 'doctor', 'Check install prerequisites (Ruby, tes3conv, references index)'
24
+ def doctor
25
+ ruby_ok = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(Esp::MINIMUM_RUBY_VERSION)
26
+ tes3conv = tes3conv_path
27
+ refs_db = Esp::Mw::ReferenceIndex.default_db_path
28
+ refs_present = File.exist?(refs_db)
29
+ problems = [!ruby_ok, tes3conv.nil?].count(true)
30
+
31
+ payload = {
32
+ ruby: { version: RUBY_VERSION, required: Esp::MINIMUM_RUBY_VERSION, ok: ruby_ok },
33
+ esp: Esp::VERSION,
34
+ tes3conv: { found: !tes3conv.nil?, path: tes3conv },
35
+ references_index: { present: refs_present, path: refs_db },
36
+ problems: problems
37
+ }
38
+ respond(payload) { print_doctor(payload) }
39
+ exit(1) if problems.positive?
40
+ end
41
+
42
+ desc 'init [NAME]',
43
+ 'Bootstrap an esp project here (or in NAME subdir) — writes .esp/project.json + git init'
44
+ option :game, type: :string, default: 'mw',
45
+ desc: 'Game plugin (default: mw; pass --game ob etc. once those plugins exist)'
46
+ option :force, type: :boolean, desc: 'Re-initialise even if .esp/project.json already exists'
47
+ def init(name = nil)
48
+ target = File.expand_path(name ? File.join(Dir.pwd, name) : Dir.pwd)
49
+ game = options[:game].to_s
50
+
51
+ unless Esp::Plugins.known?(game)
52
+ fail_with(t('errors.plugins.unknown_game',
53
+ game: game, known: Esp::Plugins.ids.join(', ')))
54
+ end
55
+
56
+ if Esp::ProjectMarker.find_in(target) && !options[:force]
57
+ fail_with(t('cli.init.already_initialised', path: target))
58
+ end
59
+
60
+ FileUtils.mkdir_p(File.join(target, 'mods'))
61
+ FileUtils.mkdir_p(File.join(target, 'dist'))
62
+ Esp::Vcs.run_git_init(target) unless File.directory?(File.join(target, '.git'))
63
+ ensure_default_gitignore(target)
64
+ Esp::ProjectMarker.write(target, name: File.basename(target), game: game)
65
+
66
+ respond({ root: target, game: game }) do
67
+ say t('cli.init.created', path: target, game: game)
68
+ say t('cli.init.next')
69
+ end
70
+ end
71
+
72
+ desc 'setup', 'Configure git diff driver + tracked pre-commit hooks'
73
+ def setup
74
+ configured = []
75
+ Dir.chdir(Esp::ROOT) do
76
+ { 'diff.tes3.textconv' => 'tes3conv',
77
+ 'diff.tes3.binary' => 'true',
78
+ 'core.hooksPath' => '.githooks' }.each do |key, value|
79
+ system('git', 'config', key, value, exception: true)
80
+ configured << { key: key, value: value }
81
+ end
82
+ end
83
+ respond({ configured: configured }) do
84
+ say t('cli.setup.diff_driver')
85
+ say t('cli.setup.hooks_path')
86
+ end
87
+ end
88
+
89
+ desc 'new MOD', 'Scaffold a new mod folder under mods/<MOD>/'
90
+ option :format, type: :string, default: 'json',
91
+ desc: 'Source format: json | rb | py | js | mjs | ts'
92
+ option :author, type: :string, desc: 'Author name (default: git config user.name)'
93
+ option :description, type: :string, desc: 'Plugin description'
94
+ option :force, type: :boolean, desc: 'Overwrite an existing mod folder'
95
+ def new_mod(mod)
96
+ root = resolve_root
97
+ result = Esp::Mw::Scaffolder.create(
98
+ mod,
99
+ format: options[:format],
100
+ author: options[:author],
101
+ description: options[:description],
102
+ force: options[:force],
103
+ root: root
104
+ )
105
+ relative = ->(p) { p.sub("#{root}/", '') }
106
+ payload = {
107
+ mod: result.mod,
108
+ format: result.format,
109
+ source: relative.call(result.source),
110
+ readme: relative.call(result.readme)
111
+ }
112
+ respond(payload) do
113
+ say t('cli.new.created', path: payload[:source])
114
+ say t('cli.new.created', path: payload[:readme])
115
+ say t('cli.new.next', mod: result.mod)
116
+ end
117
+ rescue ArgumentError => e
118
+ fail_with(e.message)
119
+ end
120
+
121
+ desc 'unpack PLUGIN [NAME]', 'Import a plugin (a path, or an installed plugin NAME) to mods/<name>/'
122
+ option :config, type: :string, desc: 'openmw.cfg to resolve a bare plugin NAME against'
123
+ def unpack(plugin, name = nil)
124
+ params = params_with_root('plugin' => plugin, 'name' => name, 'config' => options[:config]).compact
125
+ result = Esp::Operations.dispatch(:unpack, params)
126
+ respond(result) do
127
+ say t('cli.unpack.done', plugin: result[:plugin], output: result[:output])
128
+ end
129
+ rescue Esp::Operations::InputError, Esp::Mw::Tes3conv::ConvertFailed, Esp::Mw::Tes3conv::NotFound => e
130
+ fail_with(e.message)
131
+ end
132
+
133
+ desc 'build [MOD]', 'Build mods/<MOD>/<MOD>.{json,rb,py,js,mjs,ts} -> dist/<MOD>[.locale].esp'
134
+ option :all, type: :boolean, desc: 'Build every mod in mods/'
135
+ option :install, type: :boolean, desc: 'After building, register the mod(s) with openmw.cfg'
136
+ option :config, type: :string, desc: 'openmw.cfg to register with (implies --install target)'
137
+ option :locale, type: :string,
138
+ desc: 'Build with this locale (suffixes the output: dist/<MOD>.<locale>.esp)'
139
+ def build(mod = nil)
140
+ fail_with(t('cli.build.usage')) if !options[:all] && mod.nil?
141
+
142
+ root = resolve_root
143
+ mods = options[:all] ? Esp::Mw::Builder.discover_mods(root: root) : [mod]
144
+ results = mods.map { |m| build_one(m, locale: options[:locale], root: root) }
145
+ installs = options[:install] ? mods.map { |m| install_one(m) } : []
146
+ respond({ results: results, installs: installs }) do
147
+ results.each do |r|
148
+ r[:logs].each { |line| say(line) }
149
+ say t('cli.build.done', mod: r[:mod], output: r[:output])
150
+ end
151
+ installs.each { |ins| say_install_actions(ins) }
152
+ end
153
+ end
154
+
155
+ desc 'install MOD', "Register dist/<MOD>.esp with OpenMW's openmw.cfg (and/or copy to Data Files)"
156
+ option :copy_to, type: :string,
157
+ desc: 'Also copy the built .esp into PATH ' \
158
+ '(original-engine users: Morrowind/Data Files/)'
159
+ option :to_data_files, type: :boolean,
160
+ desc: 'Also copy to the auto-detected Morrowind Data Files dir ' \
161
+ '(MORROWIND_DATA env override)'
162
+ option :register_openmw, type: :boolean, default: true,
163
+ desc: 'Register with openmw.cfg (default: true; --no-register-openmw skips)'
164
+ def install(mod)
165
+ params = params_with_root(
166
+ 'mod' => mod,
167
+ 'copy_to' => options[:copy_to],
168
+ 'to_data_files' => options[:to_data_files],
169
+ 'register_openmw' => options[:register_openmw]
170
+ ).compact
171
+ result = Esp::Operations.dispatch(:install, params)
172
+ respond(result) do
173
+ Array(result[:actions]).each do |a|
174
+ say(t(a[:added] ? 'cli.install.added' : 'cli.install.present', line: a[:line]))
175
+ end
176
+ say t('cli.install.copied', path: result[:copied_to]) if result[:copied_to]
177
+ say t('cli.install.done')
178
+ end
179
+ rescue Esp::Operations::InputError => e
180
+ fail_with(e.message)
181
+ end
182
+
183
+ desc 'lint MOD', 'Find dangling refs and missing-master issues using the reference index'
184
+ def lint(mod)
185
+ result = Esp::Operations.dispatch(:lint, params_with_root('mod' => mod))
186
+ issues = result[:issues]
187
+ respond(result) do
188
+ if issues.empty?
189
+ say t('cli.lint.ok')
190
+ else
191
+ issues.each { |raw| say(format_issue(Esp::Mw::Linter::Issue.new(**raw))) }
192
+ say t('cli.lint.summary', errors: result[:errors], warnings: result[:warnings])
193
+ end
194
+ end
195
+ exit(1) if result[:errors].positive?
196
+ rescue Esp::Operations::InputError, Esp::Mw::Loader::LoadError => e
197
+ fail_with(e.message)
198
+ end
199
+
200
+ desc 'watch MOD', 'Rebuild MOD on any change under mods/<MOD>/. Blocks until Ctrl-C.'
201
+ option :locale, type: :string, desc: 'Build with this locale on each change'
202
+ def watch(mod)
203
+ root = resolve_root
204
+ Esp::Watcher.new(mod, locale: options[:locale], root: root).start
205
+ rescue Esp::Mw::Loader::LoadError => e
206
+ fail_with(e.message)
207
+ end
208
+
209
+ desc 'serve', 'Start the HTTP API on localhost (mirrors the CLI; same payload shapes)'
210
+ option :port, type: :numeric, default: Esp::HttpServer::DEFAULT_PORT,
211
+ desc: 'TCP port to bind on localhost'
212
+ def serve
213
+ server = Esp::HttpServer.new(port: options[:port])
214
+ say t('cli.serve.listening', port: options[:port])
215
+ say t('cli.serve.routes')
216
+ server.start
217
+ end
218
+
219
+ desc 'extract-scripts MOD',
220
+ "Move every Script record's inline text into mods/<MOD>/scripts/<id>.mwscript"
221
+ def extract_scripts(mod)
222
+ result = Esp::Operations.dispatch(:extract_scripts, params_with_root('mod' => mod))
223
+ respond(result) do
224
+ result[:extracted].each { |id| say t('cli.extract_scripts.extracted', id: id) }
225
+ result[:skipped].each { |id| say t('cli.extract_scripts.skipped', id: id) }
226
+ say t('cli.extract_scripts.done', extracted: result[:extracted].size, skipped: result[:skipped].size)
227
+ end
228
+ rescue ArgumentError, Esp::Mw::Loader::LoadError, Esp::Operations::InputError => e
229
+ fail_with(e.message)
230
+ end
231
+
232
+ desc 'plugins SUBCOMMAND', 'Inspect installed OpenMW plugins'
233
+ subcommand 'plugins', Plugins
234
+
235
+ desc 'refs SUBCOMMAND', 'Manage vanilla ESM references'
236
+ subcommand 'refs', Refs
237
+
238
+ desc 'i18n SUBCOMMAND', 'Translation catalogue tools'
239
+ subcommand 'i18n', I18nCli
240
+
241
+ desc 'docs SUBCOMMAND', 'Generate / introspect reference documentation'
242
+ subcommand 'docs', Docs
243
+
244
+ desc 'mcp SUBCOMMAND', 'Model Context Protocol server for AI tools'
245
+ subcommand 'mcp', Mcp
246
+
247
+ no_commands do
248
+ def format_issue(issue)
249
+ label = issue.severity == :error ? 'ERROR' : 'WARN '
250
+ field = "#{issue.record_id}.#{issue.field}"
251
+ "#{label} #{issue.record_type.to_s.ljust(10)} #{field} -> #{issue.ref_id}: #{issue.message}"
252
+ end
253
+
254
+ def build_one(mod, locale: nil, root: nil)
255
+ root ||= resolve_root
256
+ result = Esp::Mw::Builder.build(mod, locale: locale, root: root)
257
+ { mod: mod, output: result.output.sub("#{root}/", ''), logs: result.logs }
258
+ rescue Esp::Mw::Preflight::ValidationError, Esp::Mw::Loader::LoadError, ArgumentError,
259
+ Esp::Mw::Tes3conv::ConvertFailed, Esp::Mw::Tes3conv::NotFound => e
260
+ fail_with(e.message)
261
+ end
262
+
263
+ # Write a minimal .gitignore on first init. mods/ stays tracked
264
+ # (source!); dist/ does not (build artifacts). Skips an existing
265
+ # .gitignore so we don't clobber a user's choices.
266
+ def ensure_default_gitignore(target)
267
+ path = File.join(target, '.gitignore')
268
+ return if File.exist?(path)
269
+
270
+ File.write(path, "dist/\n")
271
+ end
272
+
273
+ def install_one(mod)
274
+ Esp::Operations.dispatch(:install, params_with_root('mod' => mod, 'config' => options[:config]))
275
+ rescue Esp::Operations::InputError => e
276
+ fail_with(e.message)
277
+ end
278
+
279
+ def say_install_actions(install)
280
+ install[:actions].each do |a|
281
+ say t(a[:added] ? 'cli.install.added' : 'cli.install.present', line: a[:line])
282
+ end
283
+ end
284
+
285
+ # Resolve tes3conv the same way Esp::Mw::Tes3conv will at build time:
286
+ # honour $TES3CONV (may be an absolute path), else search PATH. Returns
287
+ # the resolved absolute path, or nil if not found.
288
+ def tes3conv_path
289
+ bin = Esp::Mw::Tes3conv::BIN
290
+ return File.expand_path(bin) if bin.include?(File::SEPARATOR) && File.executable?(bin)
291
+
292
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
293
+ candidate = File.join(dir, bin)
294
+ return candidate if File.executable?(candidate) && !File.directory?(candidate)
295
+ end
296
+ nil
297
+ end
298
+
299
+ def print_doctor(payload)
300
+ say t('cli.doctor.header')
301
+ say t('cli.doctor.esp', version: payload[:esp])
302
+ ruby = payload[:ruby]
303
+ say t(ruby[:ok] ? 'cli.doctor.ruby_ok' : 'cli.doctor.ruby_old',
304
+ version: ruby[:version], required: ruby[:required])
305
+ t3 = payload[:tes3conv]
306
+ say t(t3[:found] ? 'cli.doctor.tes3conv_found' : 'cli.doctor.tes3conv_missing', path: t3[:path])
307
+ refs = payload[:references_index]
308
+ say t(refs[:present] ? 'cli.doctor.refs_present' : 'cli.doctor.refs_missing', path: refs[:path])
309
+ if payload[:problems].positive?
310
+ say t('cli.doctor.problems', count: payload[:problems])
311
+ else
312
+ say t('cli.doctor.ok')
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,148 @@
1
+ require 'fileutils'
2
+ require 'stringio'
3
+
4
+ module Esp
5
+ # Renders the data from Esp::Introspection into markdown files under
6
+ # docs/reference/. Each file is split into two regions: an `esp:auto`
7
+ # block (regenerated by `esp docs build`, enforced fresh by lefthook so
8
+ # it can't drift) and a hand-written tail below it that survives rebuilds.
9
+ # write_doc rewrites only the auto block and preserves the tail verbatim,
10
+ # so an unchanged source reproduces the file byte-for-byte — that
11
+ # idempotence is what lets the freshness hook keep diffing the whole file.
12
+ #
13
+ # The marker literal kept the `mw:auto` token for one release so existing
14
+ # generated files migrate cleanly: manual_tail accepts both the new
15
+ # `esp:auto` marker and the legacy `mw:auto` one. After the next regen,
16
+ # every file carries the new marker and the fallback is dead code.
17
+ module DocsGenerator
18
+ AUTO_OPEN = '<!-- esp:auto — regenerated by `esp docs build`; edits here are overwritten -->'.freeze
19
+ AUTO_CLOSE = '<!-- /esp:auto — write durable docs below this line; they survive rebuilds -->'.freeze
20
+ LEGACY_AUTO_CLOSE = '<!-- /mw:auto — write durable docs below this line; they survive rebuilds -->'.freeze
21
+
22
+ class << self
23
+ def build(output_dir:)
24
+ api_dir = File.join(output_dir, 'api')
25
+ FileUtils.mkdir_p(api_dir)
26
+
27
+ write_doc(File.join(output_dir, 'commands.md'), render_commands)
28
+ module_docs = Esp::Introspection.module_docs
29
+ module_docs.each do |mod|
30
+ write_doc(File.join(api_dir, "#{slug_for(mod[:name])}.md"), render_module(mod))
31
+ end
32
+ write_doc(File.join(api_dir, 'index.md'), render_api_index(module_docs))
33
+
34
+ { commands: File.join(output_dir, 'commands.md'),
35
+ api_index: File.join(api_dir, 'index.md'),
36
+ api_modules: module_docs.map { |m| File.join(api_dir, "#{slug_for(m[:name])}.md") } }
37
+ end
38
+
39
+ # Write the regenerated auto block, then re-attach the file's existing
40
+ # hand-written tail (everything after AUTO_CLOSE) verbatim — or scaffold
41
+ # one if the file is new.
42
+ def write_doc(path, body)
43
+ File.write(path, "#{AUTO_OPEN}\n\n#{body.strip}\n\n#{AUTO_CLOSE}#{manual_tail(path)}")
44
+ end
45
+
46
+ def render_commands
47
+ out = StringIO.new
48
+ tree = Esp::Introspection.command_tree
49
+ out.puts '# Command reference'
50
+ out.puts
51
+ out.puts 'Every command accepts `--json` for structured output to stdout.'
52
+ out.puts 'Errors print as `{"error": "..."}` to stderr with a non-zero exit.'
53
+ out.puts
54
+ out.puts '## Top-level commands'
55
+ out.puts
56
+ tree[:commands].each { |cmd| render_command(out, cmd) }
57
+ tree[:subcommands].each { |sub| render_subcommand_section(out, sub) }
58
+ out.string
59
+ end
60
+
61
+ def render_module(mod)
62
+ <<~MD
63
+ # #{mod[:name]}
64
+
65
+ **Source:** `#{mod[:source]}`
66
+
67
+ #{mod[:description]}
68
+ MD
69
+ end
70
+
71
+ def render_api_index(modules)
72
+ out = StringIO.new
73
+ out.puts '# API reference'
74
+ out.puts
75
+ out.puts 'Core library modules. Shell modules are `Esp::<Name>`;'
76
+ out.puts 'Morrowind-plugin modules are `Esp::Mw::<Name>`.'
77
+ out.puts
78
+ modules.each do |mod|
79
+ out.puts "- [`#{mod[:name]}`](#{slug_for(mod[:name])}.md)"
80
+ end
81
+ out.string
82
+ end
83
+
84
+ private
85
+
86
+ # The hand-written region of an existing file is everything after the
87
+ # AUTO_CLOSE marker; preserve it verbatim so rebuilds never touch it. A
88
+ # new (or pre-marker) file gets just a trailing newline — the close
89
+ # marker already invites authors to write below it.
90
+ def manual_tail(path)
91
+ return "\n" unless File.exist?(path)
92
+
93
+ existing = File.read(path)
94
+ if (marker = existing.index(AUTO_CLOSE))
95
+ existing[(marker + AUTO_CLOSE.length)..]
96
+ elsif (legacy = existing.index(LEGACY_AUTO_CLOSE))
97
+ existing[(legacy + LEGACY_AUTO_CLOSE.length)..]
98
+ else
99
+ "\n"
100
+ end
101
+ end
102
+
103
+ def render_command(out, cmd)
104
+ full = ['esp', *cmd[:path]].join(' ')
105
+ out.puts "### `#{full}`"
106
+ out.puts
107
+ out.puts cmd[:description] if cmd[:description] && !cmd[:description].empty?
108
+ out.puts
109
+ out.puts "Usage: `esp #{cmd[:usage]}`"
110
+ out.puts
111
+ render_options(out, cmd[:options])
112
+ out.puts
113
+ end
114
+
115
+ def render_subcommand_section(out, sub)
116
+ out.puts "## `esp #{sub[:name]}` subcommand group"
117
+ out.puts
118
+ out.puts sub[:description] if sub[:description]
119
+ out.puts
120
+ sub[:commands].each { |cmd| render_command(out, cmd) }
121
+ end
122
+
123
+ def render_options(out, options)
124
+ return if options.empty?
125
+
126
+ out.puts 'Options:'
127
+ options.uniq { |o| o[:name] }.each { |opt| out.puts option_line(opt) }
128
+ end
129
+
130
+ def option_line(opt)
131
+ type_label = opt[:type] == :boolean ? '' : " #{opt[:type].to_s.upcase}"
132
+ desc = opt[:description] || '(no description)'
133
+ "- `--#{opt[:name]}#{type_label}` — #{desc}"
134
+ end
135
+
136
+ # 'Esp::Builder' → 'builder'
137
+ # 'Esp::Mw::Builder' → 'mw-builder'
138
+ # 'Esp::ReferenceIndex' → 'reference-index'
139
+ def slug_for(constant_name)
140
+ constant_name
141
+ .sub(/\AEsp::/, '')
142
+ .gsub('::', '-')
143
+ .gsub(/([a-z])([A-Z])/, '\1-\2')
144
+ .downcase
145
+ end
146
+ end
147
+ end
148
+ end