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,102 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+
5
+ module Esp
6
+ module Providers
7
+ # Ollama (local-runtime) provider. Talks to Ollama's OpenAI-compatible
8
+ # Chat Completions endpoint (`<base>/v1/chat/completions`), so it reuses
9
+ # the OpenAI provider's request/response translation in full — the only
10
+ # differences are the base URL, a placeholder API key (the openai gem
11
+ # requires one but Ollama ignores it), and a reachability probe in place
12
+ # of "key is set" for the `configured?` semantic.
13
+ #
14
+ # `auto_default: false` at registration time means Ollama is never picked
15
+ # automatically — the user selects it explicitly in the UI, so a reachable
16
+ # local runtime doesn't mask an un-keyed cloud provider.
17
+ class Ollama < OpenAI
18
+ DEFAULT_MODEL = 'llama3.2'.freeze
19
+ DEFAULT_BASE_URL = 'http://localhost:11434/v1'.freeze
20
+ ENV_KEY = 'OLLAMA_BASE_URL'.freeze
21
+ PROBE_TIMEOUT = 0.25 # seconds — keep snappy, the UI hits this on /providers
22
+ PROBE_TTL = 3.0 # cache reachability so refreshes don't hammer Ollama
23
+
24
+ # Class-level probe cache, keyed by base URL. Mutexed so a threaded
25
+ # WEBrick request doesn't race the cache.
26
+ @probe_cache = {}
27
+ @probe_mutex = Mutex.new
28
+
29
+ class << self
30
+ # `configured?` = reachable, not "env set" — the base URL has a default,
31
+ # so the var being unset is the *common* case rather than misconfigured.
32
+ def configured?
33
+ reachable?
34
+ end
35
+
36
+ # GET /api/version against the configured base, with a tight timeout
37
+ # and a short-lived cache. Returns true iff Ollama answered 2xx.
38
+ def reachable?(base_url: nil)
39
+ url = base_url || ENV.fetch(ENV_KEY, DEFAULT_BASE_URL)
40
+ @probe_mutex.synchronize do
41
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ cached = @probe_cache[url]
43
+ return cached[:value] if cached && now - cached[:at] < PROBE_TTL
44
+
45
+ value = probe(url)
46
+ @probe_cache[url] = { at: now, value: value }
47
+ value
48
+ end
49
+ end
50
+
51
+ # Cleared between tests so caching doesn't leak between scenarios.
52
+ def reset_probe_cache!
53
+ @probe_mutex.synchronize { @probe_cache.clear }
54
+ end
55
+
56
+ # GET /api/tags → list of installed models for the model-input
57
+ # autocomplete (slice 3). Returns [] on any failure — auto-discovery
58
+ # is convenience, not capability; if Ollama is down or slow the user
59
+ # still types the model name by hand.
60
+ def list_models(base_url: nil)
61
+ url = base_url || ENV.fetch(ENV_KEY, DEFAULT_BASE_URL)
62
+ uri = URI("#{url.sub(%r{/v1/?\z}, '')}/api/tags")
63
+ res = Net::HTTP.start(uri.host, uri.port, open_timeout: PROBE_TIMEOUT,
64
+ read_timeout: 2.0) do |http|
65
+ http.get(uri.path.empty? ? '/api/tags' : uri.path)
66
+ end
67
+ return [] unless res.is_a?(Net::HTTPSuccess)
68
+
69
+ (JSON.parse(res.body)['models'] || []).map { |m| m['name'] }.compact
70
+ rescue StandardError
71
+ []
72
+ end
73
+
74
+ private
75
+
76
+ # Hit Ollama's native /api/version (sits at the host root, not under
77
+ # /v1). Anything raised → not reachable.
78
+ def probe(base_url)
79
+ uri = URI("#{base_url.sub(%r{/v1/?\z}, '')}/api/version")
80
+ Net::HTTP.start(uri.host, uri.port, open_timeout: PROBE_TIMEOUT,
81
+ read_timeout: PROBE_TIMEOUT) do |http|
82
+ http.get(uri.path.empty? ? '/' : uri.path).is_a?(Net::HTTPSuccess)
83
+ end
84
+ rescue StandardError
85
+ false
86
+ end
87
+ end
88
+
89
+ def build_default_client
90
+ require 'openai'
91
+ ::OpenAI::Client.new(
92
+ base_url: ENV.fetch(ENV_KEY, DEFAULT_BASE_URL),
93
+ # The openai gem requires a non-nil api_key; Ollama ignores it.
94
+ api_key: 'ollama'
95
+ )
96
+ end
97
+ end
98
+
99
+ register('ollama', Ollama, default_model: Ollama::DEFAULT_MODEL,
100
+ env_key: Ollama::ENV_KEY, auto_default: false)
101
+ end
102
+ end
@@ -0,0 +1,91 @@
1
+ require 'json'
2
+
3
+ module Esp
4
+ module Providers
5
+ # The OpenAI Chat Completions implementation of the provider contract, via
6
+ # the official `openai` gem. The neutral transcript maps to chat messages:
7
+ # the system prompt is a leading message (not a separate param), each tool
8
+ # result is its own role:'tool' message (vs Anthropic's single grouped user
9
+ # message), and the assistant turn is replayed from the stored native
10
+ # message. Tools become OpenAI function definitions; arguments arrive as a
11
+ # JSON string. Client injected for tests; real one built lazily.
12
+ class OpenAI
13
+ DEFAULT_MODEL = 'gpt-4o'.freeze
14
+ ENV_KEY = 'OPENAI_API_KEY'.freeze
15
+
16
+ # "Configured" = the SDK has a key to authenticate with.
17
+ def self.configured?
18
+ !ENV.fetch(ENV_KEY, '').to_s.empty?
19
+ end
20
+
21
+ def initialize(client: nil, model: DEFAULT_MODEL)
22
+ @client = client
23
+ @model = model
24
+ end
25
+
26
+ def complete(system:, tools:, messages:)
27
+ response = client.chat.completions.create(
28
+ model: @model,
29
+ messages: native_messages(system, messages),
30
+ tools: tools.map { |tool| tool_def(tool) }
31
+ )
32
+ message = response.choices.first.message
33
+ tool_calls = Array(message.tool_calls).map do |call|
34
+ { id: call.id, name: call.function.name, input: parse_args(call.function.arguments) }
35
+ end
36
+ Completion.new(text: message.content.to_s, tool_calls: tool_calls,
37
+ raw: native_assistant(message.content, tool_calls))
38
+ end
39
+
40
+ private
41
+
42
+ def native_messages(system, messages)
43
+ native = [{ role: 'system', content: system }]
44
+ messages.each do |msg|
45
+ case msg[:role]
46
+ when :user then native << { role: 'user', content: msg[:text] }
47
+ when :assistant then native << msg[:raw]
48
+ when :tool then msg[:results].each { |result| native << tool_message(result) }
49
+ end
50
+ end
51
+ native
52
+ end
53
+
54
+ def tool_message(result)
55
+ { role: 'tool', tool_call_id: result[:id], content: result[:content] }
56
+ end
57
+
58
+ # Reconstruct the assistant message as a plain hash to replay next turn.
59
+ def native_assistant(content, tool_calls)
60
+ native = { role: 'assistant', content: content }
61
+ unless tool_calls.empty?
62
+ native[:tool_calls] = tool_calls.map do |call|
63
+ { id: call[:id], type: 'function',
64
+ function: { name: call[:name], arguments: JSON.generate(call[:input]) } }
65
+ end
66
+ end
67
+ native
68
+ end
69
+
70
+ def tool_def(tool)
71
+ { type: 'function',
72
+ function: { name: tool[:name], description: tool[:description], parameters: tool[:input_schema] } }
73
+ end
74
+
75
+ def parse_args(arguments)
76
+ arguments.nil? || arguments.empty? ? {} : JSON.parse(arguments)
77
+ end
78
+
79
+ def client
80
+ @client ||= build_default_client
81
+ end
82
+
83
+ def build_default_client
84
+ require 'openai'
85
+ ::OpenAI::Client.new(api_key: ENV.fetch('OPENAI_API_KEY', nil))
86
+ end
87
+ end
88
+
89
+ register('openai', OpenAI, default_model: OpenAI::DEFAULT_MODEL, env_key: OpenAI::ENV_KEY)
90
+ end
91
+ end
@@ -0,0 +1,76 @@
1
+ module Esp
2
+ # The LLM-provider seam behind Esp::Agent. Each provider translates the
3
+ # agent's neutral transcript to its API's native wire shape and normalizes
4
+ # the response back to a Completion. Providers self-register here, so adding
5
+ # one is a new file under providers/ plus a require in the load manifest.
6
+ #
7
+ # Provider contract:
8
+ # #complete(system:, tools:, messages:) -> Completion(text:, tool_calls:, raw:)
9
+ # system — String system prompt
10
+ # tools — Array<{name:, description:, input_schema:}> (JSON-Schema)
11
+ # messages — the neutral transcript (see Esp::Agent)
12
+ # raw — the provider-native assistant *message* for this turn, stored
13
+ # on the assistant entry and replayed verbatim next call so
14
+ # provider-side state (Anthropic thinking signatures, OpenAI
15
+ # tool_calls) survives multi-turn tool use.
16
+ module Providers
17
+ Completion = Struct.new(:text, :tool_calls, :raw, keyword_init: true)
18
+ # `auto_default` decides whether default_id can pick this provider when no
19
+ # ESP_PROVIDER override is set — false providers (Ollama) require the user
20
+ # to choose explicitly, so a reachable local runtime never masks an
21
+ # un-keyed cloud provider.
22
+ Entry = Struct.new(:id, :klass, :default_model, :env_key, :auto_default, keyword_init: true)
23
+ class UnknownProvider < ArgumentError; end
24
+
25
+ class << self
26
+ # The id → Entry map, lazily initialized and populated by providers at
27
+ # load time (a plain mutable hash, hence not a frozen constant).
28
+ def registry
29
+ @registry ||= {}
30
+ end
31
+
32
+ # Providers call this at load time. `env_key` names the environment
33
+ # variable that carries the API key (the shell injects it per provider).
34
+ # Provider classes must define `self.configured?` — usually "env key set"
35
+ # for cloud providers, "reachable" for local runtimes. `auto_default:
36
+ # false` keeps a provider out of automatic default selection (used by
37
+ # Ollama so the user picks it explicitly).
38
+ def register(id, klass, default_model:, env_key:, auto_default: true)
39
+ registry[id] = Entry.new(id: id, klass: klass, default_model: default_model,
40
+ env_key: env_key, auto_default: auto_default)
41
+ end
42
+
43
+ # Instantiate a provider by id, with an optional model override (blank →
44
+ # the provider's default). Extra opts (e.g. an injected client) pass
45
+ # through for tests.
46
+ def build(id, model: nil, **)
47
+ entry = registry.fetch(id.to_s) { raise UnknownProvider, Esp.t('errors.providers.unknown', id: id) }
48
+ chosen = model.nil? || model.to_s.empty? ? entry.default_model : model
49
+ entry.klass.new(model: chosen, **)
50
+ end
51
+
52
+ # The providers a client can choose from: id, default model, and whether
53
+ # each provider considers itself configured (per its own `configured?`
54
+ # — env-key presence for cloud providers, reachability probe for local).
55
+ def available
56
+ registry.values.map do |entry|
57
+ { id: entry.id, default_model: entry.default_model.to_s,
58
+ configured: entry.klass.configured? }
59
+ end
60
+ end
61
+
62
+ # The provider to use when the caller doesn't pick one: an explicit
63
+ # ESP_PROVIDER override (legacy MW_PROVIDER still honoured until step
64
+ # 24 ships), else the first *auto-default* configured provider, else
65
+ # the first registered. Manual-only providers (Ollama) are excluded
66
+ # from automatic selection — see Entry#auto_default.
67
+ def default_id
68
+ override = ENV['ESP_PROVIDER'] || ENV.fetch('MW_PROVIDER', nil)
69
+ return override if registry.key?(override.to_s)
70
+
71
+ preferred = registry.values.find { |entry| entry.auto_default && entry.klass.configured? }
72
+ preferred ? preferred.id : registry.keys.first
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,74 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+ require 'time'
4
+
5
+ module Esp
6
+ # Per-user "recently opened projects" list, persisted as JSON in the data
7
+ # directory the Tauri shell injects via ESP_DATA_DIR (macOS:
8
+ # ~/Library/Application Support/com.coreyellis.espresso/). Falls back to
9
+ # ~/.config/esp/ for CLI / unit-test use. The MW_DATA_DIR env var still
10
+ # works as a one-release deprecation alias so existing dev shells keep
11
+ # finding the same on-disk state; remove after step 24 ships.
12
+ # Single-writer per process — the mutex serialises threaded WEBrick
13
+ # handlers; cross-process collisions are accepted in v1 (one ESPresso ↔
14
+ # one backend).
15
+ module Recents
16
+ CAP = 10
17
+
18
+ @mutex = Mutex.new
19
+
20
+ class << self
21
+ def path
22
+ File.join(data_dir, 'recents.json')
23
+ end
24
+
25
+ def data_dir
26
+ ENV['ESP_DATA_DIR'] || ENV['MW_DATA_DIR'] || File.expand_path('~/.config/esp')
27
+ end
28
+
29
+ # The current list, newest-first. Missing or malformed file → [] (we'd
30
+ # rather start clean than block the landing on a parse error).
31
+ def list
32
+ @mutex.synchronize { read_unsafe }
33
+ end
34
+
35
+ # Record an opened project: dedupe by root, prepend, cap. Returns the
36
+ # updated list so callers don't have to re-read.
37
+ def add(root)
38
+ normalized = File.expand_path(root.to_s)
39
+ @mutex.synchronize do
40
+ entries = read_unsafe.reject { |entry| entry['root'] == normalized }
41
+ entries.unshift(
42
+ 'root' => normalized,
43
+ 'name' => File.basename(normalized),
44
+ 'opened_at' => Time.now.utc.iso8601
45
+ )
46
+ entries = entries.first(CAP)
47
+ write_unsafe(entries)
48
+ entries
49
+ end
50
+ end
51
+
52
+ # Used by tests to start from a known state.
53
+ def clear!
54
+ @mutex.synchronize { write_unsafe([]) }
55
+ end
56
+
57
+ private
58
+
59
+ def read_unsafe
60
+ return [] unless File.exist?(path)
61
+
62
+ parsed = JSON.parse(File.read(path))
63
+ parsed.is_a?(Array) ? parsed : []
64
+ rescue StandardError
65
+ []
66
+ end
67
+
68
+ def write_unsafe(entries)
69
+ FileUtils.mkdir_p(data_dir)
70
+ File.write(path, "#{JSON.pretty_generate(entries)}\n")
71
+ end
72
+ end
73
+ end
74
+ end
data/lib/esp/ui.rb ADDED
@@ -0,0 +1,144 @@
1
+ require 'yaml'
2
+
3
+ module Esp
4
+ # Shorthand for the tool-UI translator. Lib code calls Esp.t(...); the CLI's
5
+ # Support mixin exposes the same thing as t(...). Heavier UI calls (audit,
6
+ # with_locale, locale=) still go through Esp::UI directly.
7
+ def self.t(key, **vars)
8
+ UI.t(key, **vars)
9
+ end
10
+
11
+ # Internal i18n for the *tool's own* user-facing strings — CLI output and
12
+ # error messages. Distinct from Esp::Mw::I18n, which localizes mod *content*;
13
+ # this localizes mw itself so the distributed tool can ship in other
14
+ # languages.
15
+ #
16
+ # Catalogues live at locales/<locale>.yml, resolved relative to this file
17
+ # (they ship with the tool, independent of Esp::ROOT — which points at the
18
+ # user's mod project). Lookup is dot-pathed with %{named} interpolation,
19
+ # falling back to en, then to the key itself.
20
+ #
21
+ # Active locale: ESP_UI_LOCALE (with MW_UI_LOCALE honoured as a one-release
22
+ # deprecation alias), then LANG/LC_ALL (stripped to the language subtag,
23
+ # e.g. "fr_FR.UTF-8" -> "fr"), then en. Tests pin it to en.
24
+ module UI
25
+ DEFAULT_LOCALE = 'en'.freeze
26
+ LOCALES_DIR = File.expand_path('../../locales', __dir__)
27
+
28
+ class << self
29
+ def t(key, **vars)
30
+ template = lookup(locale, key) || lookup(DEFAULT_LOCALE, key) || key
31
+ return template if vars.empty?
32
+
33
+ interpolate(template, vars, key)
34
+ end
35
+
36
+ def locale
37
+ @locale ||= detect_locale
38
+ end
39
+
40
+ attr_writer :locale
41
+
42
+ def with_locale(value)
43
+ previous = locale
44
+ @locale = value
45
+ yield
46
+ ensure
47
+ @locale = previous
48
+ end
49
+
50
+ # Quoted dotted literal, e.g. 'cli.build.done' or "errors.no_index".
51
+ # Starts lowercase so it skips "Morrowind.esm" and version numbers.
52
+ KEY_LITERAL = /(['"])([a-z]\w*(?:\.\w+)+)\1/
53
+
54
+ # Audit the catalogues against the code:
55
+ # undefined — a UI-namespaced key referenced in lib but absent from
56
+ # en (a bug: the user would see the raw key)
57
+ # unused — defined in en but referenced nowhere (dead/typo)
58
+ # orphans — per non-en locale, keys not present in en (stale)
59
+ # missing — per non-en locale, en keys it doesn't translate
60
+ # (falls back to en; informational — partials are fine)
61
+ #
62
+ # Detection is a literal scan, so it's robust to call form (ternary
63
+ # args, the `t` helper, `Esp::UI.t`). A key counts as referenced if its
64
+ # quoted literal appears anywhere in lib. "undefined" is scoped to the
65
+ # UI namespaces (en's top-level keys), so mod-content t() keys and doc
66
+ # examples don't masquerade as missing UI strings.
67
+ def audit(lib_dir: File.expand_path('..', __dir__))
68
+ en = (catalogues[DEFAULT_LOCALE] || {}).keys
69
+ namespaces = en.map { |k| k.split('.').first }.uniq
70
+ referenced = referenced_keys(lib_dir)
71
+ ui_referenced = referenced.select { |k| namespaces.include?(k.split('.').first) }
72
+ others = catalogues.reject { |loc, _| loc == DEFAULT_LOCALE }
73
+ {
74
+ undefined: (ui_referenced - en).sort,
75
+ unused: (en - referenced).sort,
76
+ orphans: others.transform_values { |c| (c.keys - en).sort }.reject { |_, v| v.empty? },
77
+ missing: others.transform_values { |c| (en - c.keys).sort }.reject { |_, v| v.empty? }
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ # Interpolate %{named} vars. A broken *translation* (a locale template
84
+ # referencing a var we weren't given — e.g. a typo'd %{nom}) must not
85
+ # crash the tool for that locale's users: fall back to the en template,
86
+ # then to the raw string.
87
+ def interpolate(template, vars, key)
88
+ format(template, vars)
89
+ rescue KeyError, ArgumentError
90
+ fallback = lookup(DEFAULT_LOCALE, key)
91
+ return template unless fallback && fallback != template
92
+
93
+ safe_format(fallback, vars)
94
+ end
95
+
96
+ def safe_format(template, vars)
97
+ format(template, vars)
98
+ rescue KeyError, ArgumentError
99
+ template
100
+ end
101
+
102
+ # Every quoted dotted literal across lib (used to decide both what's
103
+ # referenced and what's unused).
104
+ def referenced_keys(dir)
105
+ Dir.glob(File.join(dir, '**', '*.rb')).flat_map do |file|
106
+ File.read(file).scan(KEY_LITERAL).map { |_quote, key| key }
107
+ end.uniq
108
+ end
109
+
110
+ def detect_locale
111
+ raw = ENV['ESP_UI_LOCALE'] || ENV['MW_UI_LOCALE'] || ENV['LANG'] || ENV['LC_ALL'] || DEFAULT_LOCALE
112
+ tag = raw.to_s.split(/[._]/).first.to_s
113
+ tag.empty? ? DEFAULT_LOCALE : tag
114
+ end
115
+
116
+ def lookup(loc, key)
117
+ catalogues.dig(loc, key)
118
+ end
119
+
120
+ def catalogues
121
+ @catalogues ||= load_catalogues
122
+ end
123
+
124
+ def load_catalogues
125
+ return {} unless File.directory?(LOCALES_DIR)
126
+
127
+ Dir.glob(File.join(LOCALES_DIR, '*.yml')).to_h do |path|
128
+ [File.basename(path, '.yml'), flatten(YAML.safe_load_file(path) || {})]
129
+ end
130
+ end
131
+
132
+ def flatten(hash, prefix = '')
133
+ hash.each_with_object({}) do |(k, v), out|
134
+ key = prefix.empty? ? k.to_s : "#{prefix}.#{k}"
135
+ if v.is_a?(Hash)
136
+ out.merge!(flatten(v, key))
137
+ else
138
+ out[key] = v
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
data/lib/esp/vcs.rb ADDED
@@ -0,0 +1,112 @@
1
+ require 'open3'
2
+ require 'fileutils'
3
+
4
+ module Esp
5
+ # A thin wrapper over the `git` CLI, scoped to a working-tree `root`. Backs
6
+ # the diff-review loop (step 20): list working-tree changes, show one file's
7
+ # diff, stage approved files, discard rejected ones. We shell out rather than
8
+ # bind libgit2 (nothing extra to ship) — the project is already a git repo.
9
+ #
10
+ # Every method takes an explicit `root` so it operates on the *user's* mod
11
+ # project, never the toolchain repo, and so it's testable against a scratch
12
+ # repo. Git failures raise GitError; the Operations layer maps that to a
13
+ # caller-facing error.
14
+ module Vcs
15
+ Change = Struct.new(:path, :status, :staged, keyword_init: true)
16
+ class GitError < StandardError; end
17
+
18
+ class << self
19
+ # Working-tree changes under `scope` (a path prefix, e.g. "mods"), each a
20
+ # Change with status ∈ added | modified | deleted | renamed and a staged
21
+ # flag. Untracked files show up as `added`.
22
+ def changes(root:, scope: nil)
23
+ scope_args = scope.nil? || scope.empty? ? [] : ['--', scope]
24
+ parse_status(capture(root, 'status', '--porcelain=v1', '-z', *scope_args))
25
+ end
26
+
27
+ # Unified diff of one file against HEAD. An untracked (agent-created) file
28
+ # has no HEAD entry, so we diff it against the null device to render it as
29
+ # an all-add patch.
30
+ def file_diff(root:, path:)
31
+ if tracked?(root, path)
32
+ capture(root, 'diff', 'HEAD', '--', path)
33
+ else
34
+ # --no-index exits 1 whenever the files differ; that's expected here,
35
+ # not a failure.
36
+ capture(root, 'diff', '--no-index', '--', File::NULL, path, allow_fail: true)
37
+ end
38
+ end
39
+
40
+ # Stage approved paths (`git add`). The change stays in the working tree;
41
+ # the human commits when ready.
42
+ def stage(root:, paths:)
43
+ return if paths.empty?
44
+
45
+ run(root, 'add', '--', *paths)
46
+ end
47
+
48
+ # Discard a rejected change: restore a tracked file to HEAD, delete an
49
+ # agent-created untracked file. Destructive — callers confirm first.
50
+ def discard(root:, path:)
51
+ if tracked?(root, path)
52
+ run(root, 'checkout', 'HEAD', '--', path)
53
+ else
54
+ File.delete(File.join(root, path))
55
+ end
56
+ end
57
+
58
+ def tracked?(root, path)
59
+ !capture(root, 'ls-files', '--', path).strip.empty?
60
+ end
61
+
62
+ # Initialise a git repo at `root`. Used by the new-project flow
63
+ # (step 23 slice 5) so the freshly-scaffolded project tree is a
64
+ # tracked working tree from minute one.
65
+ def run_git_init(root)
66
+ FileUtils.mkdir_p(root)
67
+ run(root, 'init', '-q')
68
+ end
69
+
70
+ private
71
+
72
+ # Porcelain v1 -z: NUL-separated `XY PATH` records; a rename/copy is
73
+ # followed by its original path in the next field, which we consume.
74
+ def parse_status(output)
75
+ tokens = output.split("\0")
76
+ changes = []
77
+ i = 0
78
+ while i < tokens.length
79
+ entry = tokens[i]
80
+ i += 1
81
+ next if entry.to_s.empty?
82
+
83
+ code = entry[0, 2]
84
+ i += 1 if code.start_with?('R', 'C') # skip the rename/copy source path
85
+ changes << Change.new(path: entry[3..], status: status_for(code),
86
+ staged: code[0] != ' ' && code[0] != '?')
87
+ end
88
+ changes
89
+ end
90
+
91
+ def status_for(code)
92
+ return 'added' if code == '??' || code.include?('A')
93
+ return 'deleted' if code.include?('D')
94
+ return 'renamed' if code.start_with?('R')
95
+
96
+ 'modified'
97
+ end
98
+
99
+ def run(root, *)
100
+ capture(root, *)
101
+ nil
102
+ end
103
+
104
+ def capture(root, *args, allow_fail: false)
105
+ out, status = Open3.capture2e('git', '-C', root.to_s, *args)
106
+ raise GitError, "git #{args.first} failed: #{out.strip}" unless status.success? || allow_fail
107
+
108
+ out
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,11 @@
1
+ module Esp
2
+ VERSION = '0.1.0'.freeze
3
+
4
+ # Minimum Ruby the gem supports. Pinned to the version we actually develop
5
+ # and lint against (.ruby-version is 3.3.3, RuboCop TargetRubyVersion 3.3),
6
+ # so the claim is verifiable rather than aspirational. Widening to 3.1
7
+ # later means adding a real 3.1 CI lane first. The gemspec's
8
+ # required_ruby_version and `esp doctor` both read this so the two never
9
+ # drift.
10
+ MINIMUM_RUBY_VERSION = '3.3'.freeze
11
+ end
@@ -0,0 +1,55 @@
1
+ require 'listen'
2
+
3
+ module Esp
4
+ # File-watch-driven rebuild. Watches a mod's source directory (and
5
+ # its scripts/, i18n/, etc. subtrees) and re-invokes Esp::Mw::Builder on
6
+ # any matching change. Blocks until SIGINT.
7
+ class Watcher
8
+ WATCHED_EXTS = %w[.json .rb .py .js .mjs .ts .mwscript .yaml .yml].freeze
9
+
10
+ def initialize(mod, locale: nil, root: Esp::ROOT, output: $stdout)
11
+ @mod = mod
12
+ @locale = locale
13
+ @root = root
14
+ @output = output
15
+ end
16
+
17
+ def start
18
+ source_dir = File.dirname(Esp::Mw::Loader.resolve(@mod, root: @root))
19
+
20
+ rebuild
21
+ log "watching #{relative(source_dir)} (Ctrl-C to stop)"
22
+
23
+ listener = Listen.to(source_dir, only: extensions_regex, latency: 0.2) { rebuild }
24
+ listener.start
25
+ trap('INT') do
26
+ listener.stop
27
+ log 'stopped.'
28
+ exit 0
29
+ end
30
+ sleep
31
+ end
32
+
33
+ private
34
+
35
+ def rebuild
36
+ stamp = Time.now.strftime('%H:%M:%S')
37
+ result = Esp::Mw::Builder.build(@mod, root: @root, locale: @locale)
38
+ log "[#{stamp}] build ok -> #{relative(result.output)}"
39
+ rescue StandardError => e
40
+ log "[#{stamp}] build failed: #{e.message}"
41
+ end
42
+
43
+ def extensions_regex
44
+ Regexp.new("(#{WATCHED_EXTS.map { |e| Regexp.escape(e) }.join('|')})\\z")
45
+ end
46
+
47
+ def log(message)
48
+ @output.puts(message)
49
+ end
50
+
51
+ def relative(path)
52
+ path.sub("#{@root}/", '')
53
+ end
54
+ end
55
+ end