Module: Esp::Operations

Defined in:
lib/esp/operations.rb

Overview

Transport-agnostic service layer — the engine-agnostic shell half. One method per operation, each taking a string-keyed params hash and returning a plain Ruby hash. The HTTP API (‘esp serve`) and the MCP server (`esp mcp serve`) are thin shells over these methods plus the active plugin’s ops, so both frontends emit byte-identical payloads.

What’s here vs. in the plugin (Esp::Mw::Operations): the ops that have no Morrowind-specific concept — version/health/commands, the LLM provider seam, project/workspace/preferences state, recents, the git-diff review surface (the document under review is opaque to the shell). The plugin owns build/lint/unpack/scaffold/i18n/refs/dialogue and anything that decodes records.

Errors that are the caller’s fault (missing field, no index, validation) raise InputError; frontends map that to a 400 / tool-error. Lower layers raise their own typed errors which both frontends also treat as caller errors via the ERROR_CODES table below.

Defined Under Namespace

Classes: InputError

Constant Summary collapse

ERROR_CODES =

Stable error codes both frontends surface — one source of truth so MCP tool errors and HTTP 400s agree. The shell registers the classes it raises directly; plugin modules append their own via ‘register_error` at load time (no plugin error class can collide with a shell class —caller_errors derives from this hash and can’t drift).

{
  InputError => 'invalid_input',
  Esp::Vcs::GitError => 'git_error',
  Esp::Providers::UnknownProvider => 'unknown_provider',
  ArgumentError => 'invalid_argument'
}

Class Method Summary collapse

Class Method Details

.active_project(_params = {}) ⇒ Object

The currently-open project’s root + game (or both nil if no project is open). Read-only counterpart to open_project for the GUI to query on boot — the GUI uses ‘game` to label the workspace.



137
138
139
# File 'lib/esp/operations.rb', line 137

def active_project(_params = {})
  { root: Esp::ActiveProject.root, game: Esp::ActiveProject.game }
end

.approve(params) ⇒ Object

Approve edits: stage the given paths (‘git add`). The change stays in the working tree to build from; the human commits when ready.



223
224
225
226
227
# File 'lib/esp/operations.rb', line 223

def approve(params)
  paths = require_paths(params)
  Esp::Vcs.stage(root: vcs_root(params), paths: paths)
  { staged: paths }
end

.caller_errorsObject

The classes both frontends rescue. Derived live from ERROR_CODES so ‘register_error` extends both at once.



49
50
51
# File 'lib/esp/operations.rb', line 49

def caller_errors
  ERROR_CODES.keys
end

.commands(_params = {}) ⇒ Object



86
87
88
# File 'lib/esp/operations.rb', line 86

def commands(_params = {})
  Esp::Introspection.command_tree
end

.diff(params = {}) ⇒ Object

The review surface (step 20): working-tree changes under the project’s mods/ (or ‘scope`), each with its unified diff. `root` overrides the git working tree (defaults to the active project, then the toolchain repo). The document under review is opaque to the shell, which is why diff/approve/reject live here rather than in the plugin.



211
212
213
214
215
216
217
218
219
# File 'lib/esp/operations.rb', line 211

def diff(params = {})
  root = vcs_root(params)
  scope = params['scope'] || 'mods'
  changes = Esp::Vcs.changes(root: root, scope: scope).map do |change|
    { path: change.path, status: change.status, staged: change.staged,
      diff: Esp::Vcs.file_diff(root: root, path: change.path) }
  end
  { root: root, scope: scope, changes: changes }
end

.dispatch(op, input = {}) ⇒ Object

Route an op symbol to whichever module owns it. Shell ops (this module) win first because some are game-independent and a project may not yet be open. Otherwise Esp::Plugins.active_for(input) picks the plugin matching the input’s ‘game:` (if any), then the active project’s game, then the default plugin — its Operations module answers. Frontends call this so they don’t have to know which side of the seam each tool name lives on.

Raises:

  • (NoMethodError)


68
69
70
71
72
73
74
75
# File 'lib/esp/operations.rb', line 68

def dispatch(op, input = {})
  return public_send(op, input) if respond_to?(op)

  plugin = Esp::Plugins.active_for(input)
  return plugin.operations.public_send(op, input) if plugin.operations.respond_to?(op)

  raise NoMethodError, "unknown operation #{op} (no shell or #{plugin.id}-plugin handler)"
end

.error_code(exception) ⇒ Object

Map a raised error to its stable code, falling back to ‘error’. Order within ERROR_CODES doesn’t matter because no shell/plugin error inherits from another listed class.



56
57
58
59
# File 'lib/esp/operations.rb', line 56

def error_code(exception)
  ERROR_CODES.each { |klass, code| return code if exception.is_a?(klass) }
  'error'
end

.health(_params = {}) ⇒ Object

Liveness probe for the GUI/clients: are we up, and which version.



82
83
84
# File 'lib/esp/operations.rb', line 82

def health(_params = {})
  { status: 'ok', version: Esp::VERSION }
end

.ollama_models(_params = {}) ⇒ Object

Installed Ollama models, for the model-input autocomplete in ESPresso (step 22 slice 3). Returns [] on any failure — Ollama down, network blip, etc. — so the UI degrades to free-text entry.



99
100
101
# File 'lib/esp/operations.rb', line 99

def ollama_models(_params = {})
  { models: Esp::Providers::Ollama.list_models }
end

.open_project(params) ⇒ Object

Set the active project (step 23). Validates the path is a directory; path-not-found and path-not-a-dir are caller errors. Subsequent vcs_root lookups default to this root unless the request passes an explicit ‘root:` (which still wins — diff/approve/reject keep that affordance).

Step 22.5 slice 2: reads the project marker’s ‘game:` to pick the plugin that owns this project’s ops. Missing marker / missing field falls back to the default plugin (‘mw`) so pre-rename projects open transparently. An unknown game id is a caller error — surfaces when someone opens an Esp::Ob project on a build that only loads Mw.

Raises:



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/esp/operations.rb', line 114

def open_project(params)
  path = params['root']
  raise InputError, Esp.t('errors.operations.missing_field', field: 'root') if blank?(path)

  resolved = File.expand_path(path)
  unless File.directory?(resolved)
    raise InputError, Esp.t('errors.operations.not_a_directory', path: resolved)
  end

  game = project_game(resolved)
  unless Esp::Plugins.known?(game)
    raise InputError, Esp.t('errors.plugins.unknown_game',
                            game: game, known: Esp::Plugins.ids.join(', '))
  end

  Esp::ActiveProject.set(resolved, game: game)
  Esp::Recents.add(resolved)
  { root: resolved, game: game }
end

.preferences(_params = {}) ⇒ Object

Read-only snapshot of persisted preferences merged over defaults. Backs the ESPresso Settings panel (step 23 slice 4).



150
151
152
# File 'lib/esp/operations.rb', line 150

def preferences(_params = {})
  Esp::Preferences.read
end

.preferences_update(params) ⇒ Object

Update preferences with a whitelisted partial. Returns the resolved post-merge state so the GUI can settle without a separate read.



156
157
158
# File 'lib/esp/operations.rb', line 156

def preferences_update(params)
  Esp::Preferences.update(params)
end

.projects_new(params) ⇒ Object

Scaffold a brand-new project (step 23 slice 5). One call covers:

1. Create <mods-home>/<project>/.
2. git init.
3. Write .esp/project.json marker (with `game:`).
4. Scaffold the first mod via the active plugin's Scaffolder.
5. Mark the project active and add it to recents.

Returns the project root + the scaffolded mod’s source path so the GUI can show the first mod in the diff panel without re-fetching.

‘game` defaults to the default plugin (mw today). When ESPresso grows a game picker, it’ll pass the chosen id; the picker validates against Esp::Plugins.ids. Scaffolder still comes from Esp::Mw::* because that is the only plugin loaded today — future plugins will register their own Scaffolder and this op will look it up on the Esp::Plugins entry.

Raises:



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/esp/operations.rb', line 174

def projects_new(params)
  project = params['project'].to_s.strip
  first_mod = params['mod'].to_s.strip
  game = (params['game'] || Esp::Plugins.default_id).to_s
  raise InputError, Esp.t('errors.operations.missing_field', field: 'project') if project.empty?
  raise InputError, Esp.t('errors.operations.missing_field', field: 'mod')     if first_mod.empty?
  unless Esp::Plugins.known?(game)
    raise InputError, Esp.t('errors.plugins.unknown_game',
                            game: game, known: Esp::Plugins.ids.join(', '))
  end
  unless project.match?(Esp::Mw::Scaffolder::MOD_NAME_RE)
    raise InputError, Esp.t('errors.scaffolder.bad_name', mod: project.inspect)
  end

  root = File.join(Esp::Preferences.read['mods_home'], project)
  raise InputError, Esp.t('errors.operations.project_exists', path: root) if File.exist?(root)

  FileUtils.mkdir_p(root)
  Esp::Vcs.run_git_init(root)
  Esp::ProjectMarker.write(root, name: project, game: game)
  result = Esp::Mw::Scaffolder.create(
    first_mod,
    format: params['format'] || Esp::Mw::Scaffolder::DEFAULT_FORMAT,
    author: params['author'],
    description: params['description'],
    root: root
  )
  Esp::ActiveProject.set(root, game: game)
  Esp::Recents.add(root)
  { root: root, game: game, mod: result.mod, source: result.source.sub("#{root}/", '') }
end

.projects_recent(_params = {}) ⇒ Object

The user’s recently-opened projects, newest-first. Persisted by the backend at ESP_DATA_DIR/recents.json (slice 3 of step 23). Landing page renders this.



144
145
146
# File 'lib/esp/operations.rb', line 144

def projects_recent(_params = {})
  { projects: Esp::Recents.list }
end

.providers(_params = {}) ⇒ Object

The LLM providers a client can pick from (id, default model, whether a key is configured) plus the default. Backs the GUI’s provider selector.



92
93
94
# File 'lib/esp/operations.rb', line 92

def providers(_params = {})
  { providers: Esp::Providers.available, default: Esp::Providers.default_id }
end

.register_error(klass, code) ⇒ Object

Register a typed exception → stable code so frontends can rescue it without knowing the plugin’s class hierarchy. Idempotent (re-binding is a no-op in practice — the same class always maps to the same code) so plugins can re-load in tests.



43
44
45
# File 'lib/esp/operations.rb', line 43

def register_error(klass, code)
  ERROR_CODES[klass] = code
end

.reject(params) ⇒ Object

Reject edits: discard the given paths — restore tracked files to HEAD, delete agent-created untracked files. Destructive; the GUI confirms.



231
232
233
234
235
236
# File 'lib/esp/operations.rb', line 231

def reject(params)
  root = vcs_root(params)
  paths = require_paths(params)
  paths.each { |path| Esp::Vcs.discard(root: root, path: path) }
  { discarded: paths }
end

.relative(path, root: nil) ⇒ Object

Relative-to-project-root path. Strips the active project root (or the explicit ‘root:` override) so payloads carry “dist/MyMod.esp” rather than “/Users/me/projects/MyMod/dist/MyMod.esp”. Falls back to Esp::ROOT when no project is active, preserving pre-slice-1 output. Public so plugin ops can reuse it without duplicating the prefix.



243
244
245
246
# File 'lib/esp/operations.rb', line 243

def relative(path, root: nil)
  prefix = root || Esp::ActiveProject.resolve({})
  path.sub("#{prefix}/", '')
end

.version(_params = {}) ⇒ Object



77
78
79
# File 'lib/esp/operations.rb', line 77

def version(_params = {})
  { version: Esp::VERSION }
end