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
-
.active_project(_params = {}) ⇒ Object
The currently-open project’s root + game (or both nil if no project is open).
-
.approve(params) ⇒ Object
Approve edits: stage the given paths (‘git add`).
-
.caller_errors ⇒ Object
The classes both frontends rescue.
- .commands(_params = {}) ⇒ Object
-
.diff(params = {}) ⇒ Object
The review surface (step 20): working-tree changes under the project’s mods/ (or ‘scope`), each with its unified diff.
-
.dispatch(op, input = {}) ⇒ Object
Route an op symbol to whichever module owns it.
-
.error_code(exception) ⇒ Object
Map a raised error to its stable code, falling back to ‘error’.
-
.health(_params = {}) ⇒ Object
Liveness probe for the GUI/clients: are we up, and which version.
-
.ollama_models(_params = {}) ⇒ Object
Installed Ollama models, for the model-input autocomplete in ESPresso (step 22 slice 3).
-
.open_project(params) ⇒ Object
Set the active project (step 23).
-
.preferences(_params = {}) ⇒ Object
Read-only snapshot of persisted preferences merged over defaults.
-
.preferences_update(params) ⇒ Object
Update preferences with a whitelisted partial.
-
.projects_new(params) ⇒ Object
Scaffold a brand-new project (step 23 slice 5).
-
.projects_recent(_params = {}) ⇒ Object
The user’s recently-opened projects, newest-first.
-
.providers(_params = {}) ⇒ Object
The LLM providers a client can pick from (id, default model, whether a key is configured) plus the default.
-
.register_error(klass, code) ⇒ Object
Register a typed exception → stable code so frontends can rescue it without knowing the plugin’s class hierarchy.
-
.reject(params) ⇒ Object
Reject edits: discard the given paths — restore tracked files to HEAD, delete agent-created untracked files.
-
.relative(path, root: nil) ⇒ Object
Relative-to-project-root path.
- .version(_params = {}) ⇒ Object
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_errors ⇒ Object
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.
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.
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.(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.
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 |