esp-modkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +21 -0
- data/README.md +117 -0
- data/docs/architecture.md +125 -0
- data/docs/authoring-guide.md +206 -0
- data/docs/getting-started.md +183 -0
- data/docs/reference/api/active-project.md +22 -0
- data/docs/reference/api/agent.md +24 -0
- data/docs/reference/api/docs-generator.md +20 -0
- data/docs/reference/api/http-server.md +46 -0
- data/docs/reference/api/index.md +38 -0
- data/docs/reference/api/introspection.md +17 -0
- data/docs/reference/api/mcp-installer.md +26 -0
- data/docs/reference/api/mcp-server.md +27 -0
- data/docs/reference/api/mw-builder.md +14 -0
- data/docs/reference/api/mw-data-files.md +20 -0
- data/docs/reference/api/mw-dialogue-dsl.md +58 -0
- data/docs/reference/api/mw-i18n.md +20 -0
- data/docs/reference/api/mw-linter.md +18 -0
- data/docs/reference/api/mw-loader.md +26 -0
- data/docs/reference/api/mw-openmw-config.md +15 -0
- data/docs/reference/api/mw-operations.md +24 -0
- data/docs/reference/api/mw-preflight.md +17 -0
- data/docs/reference/api/mw-reference-index.md +21 -0
- data/docs/reference/api/mw-scaffolder.md +13 -0
- data/docs/reference/api/mw-script-blob.md +31 -0
- data/docs/reference/api/mw-script-extractor.md +17 -0
- data/docs/reference/api/operations.md +25 -0
- data/docs/reference/api/plugins.md +24 -0
- data/docs/reference/api/preferences.md +13 -0
- data/docs/reference/api/project-marker.md +23 -0
- data/docs/reference/api/providers.md +22 -0
- data/docs/reference/api/recents.md +17 -0
- data/docs/reference/api/ui.md +21 -0
- data/docs/reference/api/vcs.md +17 -0
- data/docs/reference/api/watcher.md +11 -0
- data/docs/reference/commands.md +271 -0
- data/docs/walkthrough.md +193 -0
- data/exe/esp +10 -0
- data/lib/esp/active_project.rb +71 -0
- data/lib/esp/agent.rb +104 -0
- data/lib/esp/cli/docs.rb +44 -0
- data/lib/esp/cli/i18n.rb +67 -0
- data/lib/esp/cli/mcp.rb +52 -0
- data/lib/esp/cli/plugins.rb +42 -0
- data/lib/esp/cli/refs.rb +137 -0
- data/lib/esp/cli/support.rb +87 -0
- data/lib/esp/cli.rb +317 -0
- data/lib/esp/docs_generator.rb +148 -0
- data/lib/esp/http_server.rb +232 -0
- data/lib/esp/introspection.rb +151 -0
- data/lib/esp/mcp_installer.rb +122 -0
- data/lib/esp/mcp_server.rb +465 -0
- data/lib/esp/mw/builder.rb +71 -0
- data/lib/esp/mw/data_files.rb +67 -0
- data/lib/esp/mw/dialogue_dsl.rb +209 -0
- data/lib/esp/mw/i18n.rb +113 -0
- data/lib/esp/mw/linter.rb +103 -0
- data/lib/esp/mw/loader.rb +130 -0
- data/lib/esp/mw/openmw_config.rb +138 -0
- data/lib/esp/mw/operations.rb +374 -0
- data/lib/esp/mw/preflight.rb +161 -0
- data/lib/esp/mw/reference_index.rb +182 -0
- data/lib/esp/mw/scaffolder.rb +197 -0
- data/lib/esp/mw/script_blob.rb +87 -0
- data/lib/esp/mw/script_extractor.rb +85 -0
- data/lib/esp/mw/tes3conv.rb +38 -0
- data/lib/esp/operations.rb +285 -0
- data/lib/esp/plugins.rb +75 -0
- data/lib/esp/preferences.rb +63 -0
- data/lib/esp/project_marker.rb +99 -0
- data/lib/esp/providers/anthropic.rb +74 -0
- data/lib/esp/providers/ollama.rb +102 -0
- data/lib/esp/providers/openai.rb +91 -0
- data/lib/esp/providers.rb +76 -0
- data/lib/esp/recents.rb +74 -0
- data/lib/esp/ui.rb +144 -0
- data/lib/esp/vcs.rb +112 -0
- data/lib/esp/version.rb +11 -0
- data/lib/esp/watcher.rb +55 -0
- data/lib/esp.rb +85 -0
- data/locales/en.yml +164 -0
- data/locales/fr.yml +10 -0
- metadata +241 -0
|
@@ -0,0 +1,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
|
data/lib/esp/recents.rb
ADDED
|
@@ -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
|
data/lib/esp/version.rb
ADDED
|
@@ -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
|
data/lib/esp/watcher.rb
ADDED
|
@@ -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
|