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
data/lib/esp/cli/refs.rb
ADDED
|
@@ -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
|