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,285 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ module Esp
6
+ # Transport-agnostic service layer — the engine-agnostic shell half. One
7
+ # method per operation, each taking a string-keyed params hash and
8
+ # returning a plain Ruby hash. The HTTP API (`esp serve`) and the MCP
9
+ # server (`esp mcp serve`) are thin shells over these methods plus the
10
+ # active plugin's ops, so both frontends emit byte-identical payloads.
11
+ #
12
+ # What's here vs. in the plugin (Esp::Mw::Operations): the ops that have
13
+ # no Morrowind-specific concept — version/health/commands, the LLM
14
+ # provider seam, project/workspace/preferences state, recents, the
15
+ # git-diff review surface (the document under review is opaque to the
16
+ # shell). The plugin owns build/lint/unpack/scaffold/i18n/refs/dialogue
17
+ # and anything that decodes records.
18
+ #
19
+ # Errors that are the caller's fault (missing field, no index, validation)
20
+ # raise InputError; frontends map that to a 400 / tool-error. Lower
21
+ # layers raise their own typed errors which both frontends also treat as
22
+ # caller errors via the ERROR_CODES table below.
23
+ module Operations
24
+ class InputError < StandardError; end
25
+
26
+ # Stable error codes both frontends surface — one source of truth so MCP
27
+ # tool errors and HTTP 400s agree. The shell registers the classes it
28
+ # raises directly; plugin modules append their own via `register_error`
29
+ # at load time (no plugin error class can collide with a shell class —
30
+ # caller_errors derives from this hash and can't drift).
31
+ ERROR_CODES = {
32
+ InputError => 'invalid_input',
33
+ Esp::Vcs::GitError => 'git_error',
34
+ Esp::Providers::UnknownProvider => 'unknown_provider',
35
+ ArgumentError => 'invalid_argument'
36
+ }
37
+
38
+ class << self
39
+ # Register a typed exception → stable code so frontends can rescue it
40
+ # without knowing the plugin's class hierarchy. Idempotent (re-binding
41
+ # is a no-op in practice — the same class always maps to the same
42
+ # code) so plugins can re-load in tests.
43
+ def register_error(klass, code)
44
+ ERROR_CODES[klass] = code
45
+ end
46
+
47
+ # The classes both frontends rescue. Derived live from ERROR_CODES so
48
+ # `register_error` extends both at once.
49
+ def caller_errors
50
+ ERROR_CODES.keys
51
+ end
52
+
53
+ # Map a raised error to its stable code, falling back to 'error'. Order
54
+ # within ERROR_CODES doesn't matter because no shell/plugin error
55
+ # inherits from another listed class.
56
+ def error_code(exception)
57
+ ERROR_CODES.each { |klass, code| return code if exception.is_a?(klass) }
58
+ 'error'
59
+ end
60
+
61
+ # Route an op symbol to whichever module owns it. Shell ops (this
62
+ # module) win first because some are game-independent and a project
63
+ # may not yet be open. Otherwise Esp::Plugins.active_for(input)
64
+ # picks the plugin matching the input's `game:` (if any), then the
65
+ # active project's game, then the default plugin — its Operations
66
+ # module answers. Frontends call this so they don't have to know
67
+ # which side of the seam each tool name lives on.
68
+ def dispatch(op, input = {})
69
+ return public_send(op, input) if respond_to?(op)
70
+
71
+ plugin = Esp::Plugins.active_for(input)
72
+ return plugin.operations.public_send(op, input) if plugin.operations.respond_to?(op)
73
+
74
+ raise NoMethodError, "unknown operation #{op} (no shell or #{plugin.id}-plugin handler)"
75
+ end
76
+
77
+ def version(_params = {})
78
+ { version: Esp::VERSION }
79
+ end
80
+
81
+ # Liveness probe for the GUI/clients: are we up, and which version.
82
+ def health(_params = {})
83
+ { status: 'ok', version: Esp::VERSION }
84
+ end
85
+
86
+ def commands(_params = {})
87
+ Esp::Introspection.command_tree
88
+ end
89
+
90
+ # The LLM providers a client can pick from (id, default model, whether a
91
+ # key is configured) plus the default. Backs the GUI's provider selector.
92
+ def providers(_params = {})
93
+ { providers: Esp::Providers.available, default: Esp::Providers.default_id }
94
+ end
95
+
96
+ # Installed Ollama models, for the model-input autocomplete in ESPresso
97
+ # (step 22 slice 3). Returns [] on any failure — Ollama down, network
98
+ # blip, etc. — so the UI degrades to free-text entry.
99
+ def ollama_models(_params = {})
100
+ { models: Esp::Providers::Ollama.list_models }
101
+ end
102
+
103
+ # Set the active project (step 23). Validates the path is a directory;
104
+ # path-not-found and path-not-a-dir are caller errors. Subsequent
105
+ # vcs_root lookups default to this root unless the request passes an
106
+ # explicit `root:` (which still wins — diff/approve/reject keep that
107
+ # affordance).
108
+ #
109
+ # Step 22.5 slice 2: reads the project marker's `game:` to pick the
110
+ # plugin that owns this project's ops. Missing marker / missing field
111
+ # falls back to the default plugin (`mw`) so pre-rename projects open
112
+ # transparently. An unknown game id is a caller error — surfaces when
113
+ # someone opens an Esp::Ob project on a build that only loads Mw.
114
+ def open_project(params)
115
+ path = params['root']
116
+ raise InputError, Esp.t('errors.operations.missing_field', field: 'root') if blank?(path)
117
+
118
+ resolved = File.expand_path(path)
119
+ unless File.directory?(resolved)
120
+ raise InputError, Esp.t('errors.operations.not_a_directory', path: resolved)
121
+ end
122
+
123
+ game = project_game(resolved)
124
+ unless Esp::Plugins.known?(game)
125
+ raise InputError, Esp.t('errors.plugins.unknown_game',
126
+ game: game, known: Esp::Plugins.ids.join(', '))
127
+ end
128
+
129
+ Esp::ActiveProject.set(resolved, game: game)
130
+ Esp::Recents.add(resolved)
131
+ { root: resolved, game: game }
132
+ end
133
+
134
+ # The currently-open project's root + game (or both nil if no project
135
+ # is open). Read-only counterpart to open_project for the GUI to query
136
+ # on boot — the GUI uses `game` to label the workspace.
137
+ def active_project(_params = {})
138
+ { root: Esp::ActiveProject.root, game: Esp::ActiveProject.game }
139
+ end
140
+
141
+ # The user's recently-opened projects, newest-first. Persisted by the
142
+ # backend at ESP_DATA_DIR/recents.json (slice 3 of step 23). Landing
143
+ # page renders this.
144
+ def projects_recent(_params = {})
145
+ { projects: Esp::Recents.list }
146
+ end
147
+
148
+ # Read-only snapshot of persisted preferences merged over defaults.
149
+ # Backs the ESPresso Settings panel (step 23 slice 4).
150
+ def preferences(_params = {})
151
+ Esp::Preferences.read
152
+ end
153
+
154
+ # Update preferences with a whitelisted partial. Returns the resolved
155
+ # post-merge state so the GUI can settle without a separate read.
156
+ def preferences_update(params)
157
+ Esp::Preferences.update(params)
158
+ end
159
+
160
+ # Scaffold a brand-new project (step 23 slice 5). One call covers:
161
+ # 1. Create <mods-home>/<project>/.
162
+ # 2. git init.
163
+ # 3. Write .esp/project.json marker (with `game:`).
164
+ # 4. Scaffold the first mod via the active plugin's Scaffolder.
165
+ # 5. Mark the project active and add it to recents.
166
+ # Returns the project root + the scaffolded mod's source path so the
167
+ # GUI can show the first mod in the diff panel without re-fetching.
168
+ #
169
+ # `game` defaults to the default plugin (mw today). When ESPresso grows
170
+ # a game picker, it'll pass the chosen id; the picker validates against
171
+ # Esp::Plugins.ids. Scaffolder still comes from Esp::Mw::* because that
172
+ # is the only plugin loaded today — future plugins will register their
173
+ # own Scaffolder and this op will look it up on the Esp::Plugins entry.
174
+ def projects_new(params)
175
+ project = params['project'].to_s.strip
176
+ first_mod = params['mod'].to_s.strip
177
+ game = (params['game'] || Esp::Plugins.default_id).to_s
178
+ raise InputError, Esp.t('errors.operations.missing_field', field: 'project') if project.empty?
179
+ raise InputError, Esp.t('errors.operations.missing_field', field: 'mod') if first_mod.empty?
180
+ unless Esp::Plugins.known?(game)
181
+ raise InputError, Esp.t('errors.plugins.unknown_game',
182
+ game: game, known: Esp::Plugins.ids.join(', '))
183
+ end
184
+ unless project.match?(Esp::Mw::Scaffolder::MOD_NAME_RE)
185
+ raise InputError, Esp.t('errors.scaffolder.bad_name', mod: project.inspect)
186
+ end
187
+
188
+ root = File.join(Esp::Preferences.read['mods_home'], project)
189
+ raise InputError, Esp.t('errors.operations.project_exists', path: root) if File.exist?(root)
190
+
191
+ FileUtils.mkdir_p(root)
192
+ Esp::Vcs.run_git_init(root)
193
+ Esp::ProjectMarker.write(root, name: project, game: game)
194
+ result = Esp::Mw::Scaffolder.create(
195
+ first_mod,
196
+ format: params['format'] || Esp::Mw::Scaffolder::DEFAULT_FORMAT,
197
+ author: params['author'],
198
+ description: params['description'],
199
+ root: root
200
+ )
201
+ Esp::ActiveProject.set(root, game: game)
202
+ Esp::Recents.add(root)
203
+ { root: root, game: game, mod: result.mod, source: result.source.sub("#{root}/", '') }
204
+ end
205
+
206
+ # The review surface (step 20): working-tree changes under the project's
207
+ # mods/ (or `scope`), each with its unified diff. `root` overrides the
208
+ # git working tree (defaults to the active project, then the toolchain
209
+ # repo). The document under review is opaque to the shell, which is
210
+ # why diff/approve/reject live here rather than in the plugin.
211
+ def diff(params = {})
212
+ root = vcs_root(params)
213
+ scope = params['scope'] || 'mods'
214
+ changes = Esp::Vcs.changes(root: root, scope: scope).map do |change|
215
+ { path: change.path, status: change.status, staged: change.staged,
216
+ diff: Esp::Vcs.file_diff(root: root, path: change.path) }
217
+ end
218
+ { root: root, scope: scope, changes: changes }
219
+ end
220
+
221
+ # Approve edits: stage the given paths (`git add`). The change stays in
222
+ # the working tree to build from; the human commits when ready.
223
+ def approve(params)
224
+ paths = require_paths(params)
225
+ Esp::Vcs.stage(root: vcs_root(params), paths: paths)
226
+ { staged: paths }
227
+ end
228
+
229
+ # Reject edits: discard the given paths — restore tracked files to HEAD,
230
+ # delete agent-created untracked files. Destructive; the GUI confirms.
231
+ def reject(params)
232
+ root = vcs_root(params)
233
+ paths = require_paths(params)
234
+ paths.each { |path| Esp::Vcs.discard(root: root, path: path) }
235
+ { discarded: paths }
236
+ end
237
+
238
+ # Relative-to-project-root path. Strips the active project root (or
239
+ # the explicit `root:` override) so payloads carry "dist/MyMod.esp"
240
+ # rather than "/Users/me/projects/MyMod/dist/MyMod.esp". Falls back to
241
+ # Esp::ROOT when no project is active, preserving pre-slice-1 output.
242
+ # Public so plugin ops can reuse it without duplicating the prefix.
243
+ def relative(path, root: nil)
244
+ prefix = root || Esp::ActiveProject.resolve({})
245
+ path.sub("#{prefix}/", '')
246
+ end
247
+
248
+ private
249
+
250
+ def blank?(value)
251
+ value.nil? || value.to_s.empty?
252
+ end
253
+
254
+ # A non-empty array of non-empty string paths, or a caller error.
255
+ def require_paths(params)
256
+ paths = params['paths']
257
+ valid = paths.is_a?(Array) && !paths.empty? && paths.all? { |p| p.is_a?(String) && !p.empty? }
258
+ raise InputError, Esp.t('errors.operations.paths_required') unless valid
259
+
260
+ paths
261
+ end
262
+
263
+ # Read the project marker (`.esp/project.json`, with legacy
264
+ # `.espresso/project.json` accepted as one-release back-compat), return
265
+ # its `game:`. Missing file / missing field / malformed JSON all
266
+ # collapse to the default plugin id so pre-22.5 projects still open
267
+ # (every one was Morrowind).
268
+ def project_game(root)
269
+ marker = Esp::ProjectMarker.read(root)
270
+ return Esp::Plugins.default_id unless marker
271
+
272
+ marker['game'] || Esp::Plugins.default_id
273
+ end
274
+
275
+ # The git working tree to operate on. Precedence: explicit `root:` param
276
+ # (lets the diff panel target an arbitrary tree even with a project
277
+ # open) → active project (step 23) → the toolchain repo. Same
278
+ # precedence as every other root-needing op (step 23.5 slice 1) so
279
+ # the diff panel and the build target the same tree by default.
280
+ def vcs_root(params)
281
+ Esp::ActiveProject.resolve(params)
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,75 @@
1
+ module Esp
2
+ # The game-plugin registry. Each plugin (Esp::Mw today; future Esp::Ob,
3
+ # Esp::Sr) registers itself here at load time with a short `id` (the value
4
+ # that lands in a project's `.espresso/project.json` `game:` field) and the
5
+ # modules the frontends need to reach into — its Operations façade today.
6
+ #
7
+ # The shell composes its routes/tools against this registry, so the only
8
+ # thing tying the shell to a specific game is the project the user opens.
9
+ # Add a plugin: drop `lib/esp/<id>/`, call `Esp::Plugins.register` from its
10
+ # entry file, and add it to the load manifest.
11
+ #
12
+ # Lookups are by id string; `default_id` names the plugin used when no
13
+ # project is open (today: the first registered, which is `mw` because the
14
+ # load manifest loads it first). Slice 2 wires Esp::Operations.dispatch
15
+ # through `Esp::Plugins.active_for(input)` so an op like `build` reaches
16
+ # the plugin matching the active project's `game:` field rather than
17
+ # always Esp::Mw::Operations.
18
+ module Plugins
19
+ Entry = Struct.new(:id, :label, :operations, keyword_init: true)
20
+
21
+ @mutex = Mutex.new
22
+
23
+ class << self
24
+ # The id → Entry map, lazily initialized and populated by plugins at
25
+ # load time. Mutable hash (like Esp::Providers.registry) because the
26
+ # plugin set isn't known until every require finishes.
27
+ def registry
28
+ @registry ||= {}
29
+ end
30
+
31
+ # A plugin announces itself. `operations` is its Esp::*::Operations
32
+ # module (the game half of the service layer); `label` is the
33
+ # human-friendly name shown in error messages. Idempotent — a re-load
34
+ # in tests just rebinds the same entry.
35
+ def register(id, label:, operations:)
36
+ @mutex.synchronize do
37
+ registry[id.to_s] = Entry.new(id: id.to_s, label: label, operations: operations)
38
+ end
39
+ end
40
+
41
+ # The plugin handling the current request. Order of precedence:
42
+ # 1. explicit `game:` in the input hash (lets the diff panel etc.
43
+ # target a specific plugin even when a project is open),
44
+ # 2. the active project's game (Esp::ActiveProject.game),
45
+ # 3. the default plugin (the only one for now; the fallback for
46
+ # ops invoked before any project is open — version, providers).
47
+ # Unknown id raises so frontends surface a clear error.
48
+ def active_for(input = {})
49
+ id = (input.is_a?(Hash) && input['game']) || Esp::ActiveProject.game || default_id
50
+ fetch(id)
51
+ end
52
+
53
+ def fetch(id)
54
+ registry.fetch(id.to_s) do
55
+ raise Esp::Operations::InputError,
56
+ Esp.t('errors.plugins.unknown_game', game: id, known: ids.join(', '))
57
+ end
58
+ end
59
+
60
+ def ids
61
+ registry.keys
62
+ end
63
+
64
+ def default_id
65
+ registry.keys.first
66
+ end
67
+
68
+ # True when `id` names a registered plugin. Cheap predicate for
69
+ # open_project to validate before storing on ActiveProject.
70
+ def known?(id)
71
+ registry.key?(id.to_s)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,63 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+
4
+ module Esp
5
+ # Per-user preferences persisted as JSON alongside the recents file at
6
+ # ESP_DATA_DIR/preferences.json. Today: just `mods_home` (the directory the
7
+ # Open Project picker starts in / New Project scaffolds into). More keys to
8
+ # come; the merge-with-defaults read keeps backward compatibility as the
9
+ # schema grows. Same single-writer / mutex story as Recents.
10
+ module Preferences
11
+ DEFAULTS = {
12
+ 'mods_home' => File.expand_path('~/Documents/ESPresso')
13
+ }.freeze
14
+
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ def path
19
+ File.join(Esp::Recents.data_dir, 'preferences.json')
20
+ end
21
+
22
+ def read
23
+ @mutex.synchronize { read_unsafe }
24
+ end
25
+
26
+ # Whitelist-merge an update. Unknown keys are ignored; nil/empty values
27
+ # also fall through (so a UI accidentally clearing a field doesn't blow
28
+ # away the default). Returns the resolved (post-merge) state.
29
+ def update(updates)
30
+ @mutex.synchronize do
31
+ stored = read_unsafe
32
+ accepted = updates.to_h
33
+ .slice(*DEFAULTS.keys)
34
+ .reject { |_, v| v.nil? || v.to_s.empty? }
35
+ merged = stored.merge(accepted)
36
+ write_unsafe(merged)
37
+ merged
38
+ end
39
+ end
40
+
41
+ def clear!
42
+ @mutex.synchronize { write_unsafe({}) }
43
+ end
44
+
45
+ private
46
+
47
+ # Defaults merged underneath whatever's on disk, so new keys always
48
+ # have a value and a hand-edited file with a missing key stays usable.
49
+ def read_unsafe
50
+ on_disk = File.exist?(path) ? JSON.parse(File.read(path)) : {}
51
+ on_disk = {} unless on_disk.is_a?(Hash)
52
+ DEFAULTS.merge(on_disk)
53
+ rescue StandardError
54
+ DEFAULTS.dup
55
+ end
56
+
57
+ def write_unsafe(state)
58
+ FileUtils.mkdir_p(File.dirname(path))
59
+ File.write(path, "#{JSON.pretty_generate(state)}\n")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,99 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ module Esp
6
+ # Reads, writes, and discovers the `.esp/project.json` marker that
7
+ # identifies a directory as an esp project. Used by:
8
+ #
9
+ # - `esp init` and `Esp::Operations.projects_new` to write the marker.
10
+ # - `Esp::Operations.open_project` to read the marker's `game:` field so
11
+ # the plugin registry knows which Operations module owns this project.
12
+ # - The CLI cwd walk-up so `esp build` works from anywhere inside a
13
+ # project tree without `--root`.
14
+ #
15
+ # Marker filename rename (step 23.5 slice 3): the canonical location is
16
+ # now `.esp/project.json`. Projects scaffolded by an earlier ESPresso
17
+ # release wrote `.espresso/project.json`; readers accept both, writers
18
+ # only emit the new name. The back-compat lookup goes away when the
19
+ # release that drops it ships (no auto-migration — we don't silently
20
+ # rewrite a user's project files).
21
+ module ProjectMarker
22
+ FILENAME = 'project.json'.freeze
23
+ DIRECTORY = '.esp'.freeze
24
+ LEGACY_DIRECTORY = '.espresso'.freeze
25
+
26
+ # Cap the cwd walk-up depth to defend against symlink loops and
27
+ # pathological filesystem layouts. 32 levels is deeper than any sane
28
+ # project tree (and matches git's own MAX_DEPTH for similar reasons).
29
+ WALK_DEPTH_CAP = 32
30
+
31
+ SCHEMA_VERSION = 1
32
+
33
+ class << self
34
+ # The canonical write path for a project rooted at `root`.
35
+ def path_in(root)
36
+ File.join(root, DIRECTORY, FILENAME)
37
+ end
38
+
39
+ # The marker path under `root`, preferring the new `.esp/` location
40
+ # but accepting a legacy `.espresso/` marker. Returns nil if neither
41
+ # exists.
42
+ def find_in(root)
43
+ new_path = path_in(root)
44
+ return new_path if File.exist?(new_path)
45
+
46
+ legacy = File.join(root, LEGACY_DIRECTORY, FILENAME)
47
+ return legacy if File.exist?(legacy)
48
+
49
+ nil
50
+ end
51
+
52
+ # Read + parse the marker at `root`. Returns nil if no marker exists
53
+ # or the JSON is malformed — callers default to the registry's
54
+ # default plugin (every pre-22.5 project is Morrowind) rather than
55
+ # surface a sharp edge.
56
+ def read(root)
57
+ path = find_in(root)
58
+ return nil unless path
59
+
60
+ JSON.parse(File.read(path))
61
+ rescue JSON::ParserError
62
+ nil
63
+ end
64
+
65
+ # Write the canonical marker. Always emits the new `.esp/` location;
66
+ # never touches a legacy `.espresso/` marker the project might
67
+ # already carry. Returns the path written.
68
+ def write(root, name:, game:)
69
+ path = path_in(root)
70
+ FileUtils.mkdir_p(File.dirname(path))
71
+ payload = {
72
+ 'name' => name,
73
+ 'schema' => SCHEMA_VERSION,
74
+ 'game' => game,
75
+ 'created_at' => Time.now.utc.iso8601
76
+ }
77
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
78
+ path
79
+ end
80
+
81
+ # Walk up from `start_dir` looking for a project marker. Returns the
82
+ # project root (the directory *containing* the marker dir), not the
83
+ # marker path. Nil if no marker is found before the filesystem root.
84
+ # Bounded by WALK_DEPTH_CAP so a symlink loop can't hang us.
85
+ def find_walking_up(start_dir)
86
+ current = File.expand_path(start_dir)
87
+ WALK_DEPTH_CAP.times do
88
+ return current if find_in(current)
89
+
90
+ parent = File.dirname(current)
91
+ return nil if parent == current # reached filesystem root
92
+
93
+ current = parent
94
+ end
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,74 @@
1
+ module Esp
2
+ module Providers
3
+ # The Anthropic Messages API implementation of the provider contract. It
4
+ # translates the neutral transcript to Messages-API shape, prompt-caches the
5
+ # stable tools+system preamble, and normalizes the response back. The client
6
+ # is injected for tests; the real one is built lazily so no key is needed
7
+ # until a live run.
8
+ class Anthropic
9
+ DEFAULT_MODEL = 'claude-sonnet-4-6'.freeze
10
+ MAX_TOKENS = 16_000
11
+ ENV_KEY = 'ANTHROPIC_API_KEY'.freeze
12
+
13
+ # "Configured" = the SDK has a key to authenticate with.
14
+ def self.configured?
15
+ !ENV.fetch(ENV_KEY, '').to_s.empty?
16
+ end
17
+
18
+ def initialize(client: nil, model: DEFAULT_MODEL, max_tokens: MAX_TOKENS)
19
+ @client = client
20
+ @model = model
21
+ @max_tokens = max_tokens
22
+ end
23
+
24
+ def complete(system:, tools:, messages:)
25
+ response = client.messages.create(
26
+ model: @model,
27
+ max_tokens: @max_tokens,
28
+ thinking: { type: 'adaptive' },
29
+ # tools render before system, so caching the system block caches the
30
+ # whole (stable) tools + system preamble.
31
+ system_: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
32
+ # `tools` already arrives in the {name, description, input_schema}
33
+ # shape the Messages API wants.
34
+ tools: tools,
35
+ messages: messages.map { |message| native_message(message) }
36
+ )
37
+ blocks = response.content
38
+ text = blocks.select { |b| b.type == :text }.map(&:text).join("\n")
39
+ tool_calls = blocks.select { |b| b.type == :tool_use }
40
+ .map { |b| { id: b.id, name: b.name.to_s, input: b.input || {} } }
41
+ Completion.new(text: text, tool_calls: tool_calls, raw: { role: 'assistant', content: blocks })
42
+ end
43
+
44
+ private
45
+
46
+ def native_message(msg)
47
+ case msg[:role]
48
+ when :user then { role: 'user', content: msg[:text] }
49
+ # Replay the stored native message verbatim — preserves thinking-block
50
+ # signatures the API requires on later turns with tool use.
51
+ when :assistant then msg[:raw]
52
+ # All tool results for one assistant turn go in one user message.
53
+ when :tool then { role: 'user', content: msg[:results].map { |r| tool_result_block(r) } }
54
+ end
55
+ end
56
+
57
+ def tool_result_block(result)
58
+ { type: 'tool_result', tool_use_id: result[:id],
59
+ content: result[:content], is_error: result[:is_error] }
60
+ end
61
+
62
+ def client
63
+ @client ||= build_default_client
64
+ end
65
+
66
+ def build_default_client
67
+ require 'anthropic'
68
+ ::Anthropic::Client.new
69
+ end
70
+ end
71
+
72
+ register('anthropic', Anthropic, default_model: Anthropic::DEFAULT_MODEL, env_key: Anthropic::ENV_KEY)
73
+ end
74
+ end