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
data/docs/walkthrough.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Walkthrough: a mod from empty to built
|
|
2
|
+
|
|
3
|
+
This is the end-to-end story the reference docs only show in pieces:
|
|
4
|
+
authoring one real plugin from nothing, the way the toolchain wants you
|
|
5
|
+
to work. If you've used the Construction Set, the mental shift is the
|
|
6
|
+
whole point — so it's worth stating up front.
|
|
7
|
+
|
|
8
|
+
## The shift: source is truth, the `.esp` is output
|
|
9
|
+
|
|
10
|
+
The Construction Set is a direct-manipulation binary editor. The `.esp`
|
|
11
|
+
*is* the document; you click forms and a 3D viewport, and the binary is
|
|
12
|
+
what you save. `esp` inverts that. **You author intent as
|
|
13
|
+
human-readable source, and the `.esp` is a disposable build artifact** —
|
|
14
|
+
the same relationship a compiler has with its object files. You never
|
|
15
|
+
hand-edit the `.esp`; you edit source and rebuild.
|
|
16
|
+
|
|
17
|
+
That one inversion is what buys you `git diff`, branches, code review,
|
|
18
|
+
procedural generation, lint-as-a-build-step, and AI authoring. The cost
|
|
19
|
+
is real — you give up spatial placement and visual browsing — but
|
|
20
|
+
everything below is what you get back.
|
|
21
|
+
|
|
22
|
+
## 0. One-time setup
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bundle install
|
|
26
|
+
bin/esp setup # git diff driver + pre-commit hook
|
|
27
|
+
bin/esp refs unpack # vanilla Morrowind/Tribunal/Bloodmoon -> JSON
|
|
28
|
+
bin/esp refs index # SQLite index over ~69k records (~3s)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`refs unpack` + `refs index` are what let `lint` and `refs find` resolve
|
|
32
|
+
foreign IDs (does `Imperial Legion` exist? what's the editor ID of that
|
|
33
|
+
sign in Seyda Neen?). You only redo them when you upgrade the game.
|
|
34
|
+
See [getting-started](getting-started.md) for installing tes3conv itself.
|
|
35
|
+
|
|
36
|
+
## 1. Scaffold
|
|
37
|
+
|
|
38
|
+
We'll build a signpost for Seyda Neen — an activator with a script
|
|
39
|
+
attached. Scaffold the folder:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
$ bin/esp new SeydaSign --author "Corey Ellis" \
|
|
43
|
+
--description "A welcome sign in Seyda Neen"
|
|
44
|
+
created: mods/SeydaSign/SeydaSign.json
|
|
45
|
+
created: mods/SeydaSign/README.md
|
|
46
|
+
next: bin/esp build SeydaSign
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`esp new` defaults to JSON; pass `--format rb|py|js|ts` to author in a
|
|
50
|
+
real language instead. Everything for this mod now lives under
|
|
51
|
+
`mods/SeydaSign/` — source, scripts, translations, design notes all stay
|
|
52
|
+
together (see [authoring-guide](authoring-guide.md#per-mod-self-containment)).
|
|
53
|
+
|
|
54
|
+
## 2. Author the records
|
|
55
|
+
|
|
56
|
+
Open `mods/SeydaSign/SeydaSign.json`. The scaffold gives you just a
|
|
57
|
+
`Header`; add an `Activator` that references a script:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
[
|
|
61
|
+
{ "type": "Header", "flags": "", "file_type": "Esp", "version": 1.3,
|
|
62
|
+
"author": "Corey Ellis", "description": "A welcome sign in Seyda Neen",
|
|
63
|
+
"num_objects": 0, "masters": [["Morrowind.esm", 79837557]] },
|
|
64
|
+
{ "type": "Activator", "flags": "", "id": "seyda_welcome_sign",
|
|
65
|
+
"name": "Welcome to Seyda Neen", "script": "seyda_sign_script",
|
|
66
|
+
"mesh": "f/active_sign.nif" }
|
|
67
|
+
]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
> **The `flags` gotcha.** Every TES3 record needs `"flags": ""` even when
|
|
71
|
+
> it's empty — tes3conv rejects the build otherwise. If you forget, you
|
|
72
|
+
> get a clean, pointed error rather than a stack trace:
|
|
73
|
+
>
|
|
74
|
+
> ```
|
|
75
|
+
> $ bin/esp build SeydaSign
|
|
76
|
+
> error: Error: Custom { kind: InvalidData, error: Error("missing field `flags`", line: 6, column: 1) }
|
|
77
|
+
> hint: every record needs "flags": "" — tes3conv requires it even when empty
|
|
78
|
+
> ```
|
|
79
|
+
|
|
80
|
+
## 3. Lint catches the dangling reference
|
|
81
|
+
|
|
82
|
+
We pointed the activator at `seyda_sign_script`, but that script doesn't
|
|
83
|
+
exist yet — not in this mod, not in vanilla. `lint` knows, because it
|
|
84
|
+
resolves every reference against the mod plus the indexed ESMs:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
$ bin/esp lint SeydaSign
|
|
88
|
+
ERROR Activator seyda_welcome_sign.script -> seyda_sign_script: unknown Script
|
|
89
|
+
1 error(s), 0 warning(s)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It exits non-zero, so this is the line you wire into CI. (A reference
|
|
93
|
+
that resolves in a vanilla ESM you *forgot to list as a master* is a
|
|
94
|
+
`WARN`, not an error — OpenMW loads it but logs complaints.)
|
|
95
|
+
|
|
96
|
+
## 4. Add the script, go green
|
|
97
|
+
|
|
98
|
+
Inline MWScript text is unreadable inside JSON, so author it as a file
|
|
99
|
+
and reference it with `text_source` — the build inlines it and
|
|
100
|
+
regenerates the binary script blobs tes3conv expects:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
mods/SeydaSign/scripts/seyda_sign_script.mwscript
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```mwscript
|
|
107
|
+
Begin seyda_sign_script
|
|
108
|
+
; activate-only sign; nothing to do
|
|
109
|
+
End
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Add the `Script` record pointing at that file:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{ "type": "Script", "flags": "", "id": "seyda_sign_script",
|
|
116
|
+
"text_source": "scripts/seyda_sign_script.mwscript" }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
$ bin/esp lint SeydaSign
|
|
121
|
+
ok
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Need the editor ID of an existing record to reference? That's what the
|
|
125
|
+
index is for:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
$ bin/esp refs find "seyda neen"
|
|
129
|
+
Morrowind.esm Activator active_sign_seydaneen_01
|
|
130
|
+
Morrowind.esm Activator active_sign_seydaneen_02
|
|
131
|
+
...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 5. Build and install
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
$ bin/esp build SeydaSign
|
|
138
|
+
build: SeydaSign -> dist/SeydaSign.esp
|
|
139
|
+
|
|
140
|
+
$ bin/esp install SeydaSign # appends data= + content= lines to openmw.cfg
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`bin/esp build SeydaSign --install` does both in one step (and
|
|
144
|
+
`--config <path>` targets a specific `openmw.cfg`). Launch OpenMW, confirm
|
|
145
|
+
`SeydaSign.esp` is enabled in the launcher. The
|
|
146
|
+
`.esp` in `dist/` is throwaway — delete it and `esp build` reproduces it
|
|
147
|
+
byte-for-byte from source.
|
|
148
|
+
|
|
149
|
+
## 6. The payoff: review the diff, not the binary
|
|
150
|
+
|
|
151
|
+
Because the source is text, the change you just made is a readable diff.
|
|
152
|
+
In a real mod project you'd track `mods/` in git (it's gitignored *in
|
|
153
|
+
the toolchain repo* only because that repo ships the tool, not content):
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
$ git add mods/SeydaSign && git diff --cached --stat
|
|
157
|
+
mods/SeydaSign/SeydaSign.json | 4 ++++
|
|
158
|
+
mods/SeydaSign/scripts/seyda_sign_script.mwscript | 3 +++
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Adding the script is `+4 / +3` lines you can read, blame, branch, and
|
|
162
|
+
review — not an opaque re-saved `.esp`. That is the entire reason for
|
|
163
|
+
the inversion.
|
|
164
|
+
|
|
165
|
+
## Same pipeline, three frontends
|
|
166
|
+
|
|
167
|
+
Every step above is also an HTTP endpoint (`esp serve`) and an MCP tool
|
|
168
|
+
(`esp mcp serve`), returning the identical `--json` payloads. So an AI
|
|
169
|
+
agent runs exactly this loop without a human at the keyboard:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
$ bin/esp lint SeydaSign --json
|
|
173
|
+
{"mod":"SeydaSign","errors":0,"warnings":0,"issues":[]}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
1. `commands` / `esp docs introspect` — discover the surface.
|
|
177
|
+
2. `refs_find` — look up real vanilla IDs to reference.
|
|
178
|
+
3. Write the source file.
|
|
179
|
+
4. `lint --json` — validate; structured errors come back as
|
|
180
|
+
`{"error": "…"}`.
|
|
181
|
+
5. `build --json` — produce the `.esp`.
|
|
182
|
+
|
|
183
|
+
That agent loop driving diffable source is the backbone of **ESPresso**,
|
|
184
|
+
the AI-first GUI built on this toolchain. The human's job moves from
|
|
185
|
+
clicking forms to reviewing the diff in step 6.
|
|
186
|
+
|
|
187
|
+
## Where to go next
|
|
188
|
+
|
|
189
|
+
- [Authoring guide](authoring-guide.md) — every source format, scripts,
|
|
190
|
+
dialogue DSL, i18n, linting in full.
|
|
191
|
+
- [Getting started](getting-started.md) — install, paths, wiring `esp`
|
|
192
|
+
into an AI client over MCP.
|
|
193
|
+
- [Architecture](architecture.md) — why the pipeline is shaped this way.
|
data/exe/esp
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The gem's executable. Unlike bin/esp (a dev-repo bash launcher that pins
|
|
5
|
+
# rbenv + BUNDLE_GEMFILE to this checkout), this is plain Ruby: RubyGems has
|
|
6
|
+
# already put the gem's lib + its dependencies on $LOAD_PATH, so a bare
|
|
7
|
+
# `require 'esp'` loads the whole toolchain. No Bundler involved.
|
|
8
|
+
require 'esp'
|
|
9
|
+
|
|
10
|
+
Esp::CLI.start(ARGV)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Esp
|
|
2
|
+
# Process-scoped state for "which project is the user currently working in"
|
|
3
|
+
# (step 23). Swapped in by ESPresso's POST /open-project; helpers that need
|
|
4
|
+
# a working-tree root (vcs_root for diff/approve/reject today; more later)
|
|
5
|
+
# consult this before falling back to Esp::ROOT.
|
|
6
|
+
#
|
|
7
|
+
# Step 22.5 slice 2: also tracks the project's game id (`mw` today, future
|
|
8
|
+
# `ob`/`sr`) so the plugin registry knows which Operations module owns each
|
|
9
|
+
# op for the current request. Missing game on a project read defaults to
|
|
10
|
+
# `mw` for backward compat (every pre-rename project is Morrowind).
|
|
11
|
+
#
|
|
12
|
+
# Stored module-globally rather than per-request because the WEBrick backend
|
|
13
|
+
# is single-tenant per-instance (one ESPresso ↔ one esp serve), so a global
|
|
14
|
+
# is the right shape; the mutex guards against the thread-per-request model
|
|
15
|
+
# racing a concurrent set + read.
|
|
16
|
+
module ActiveProject
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@root = nil
|
|
19
|
+
@game = nil
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def root
|
|
23
|
+
@mutex.synchronize { @root }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def game
|
|
27
|
+
@mutex.synchronize { @game }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Store a project root + game id. Returns the normalized path so callers
|
|
31
|
+
# don't have to expand again. No directory check here —
|
|
32
|
+
# Operations.open_project validates before calling (separation: this
|
|
33
|
+
# module is pure state).
|
|
34
|
+
def set(path, game: nil)
|
|
35
|
+
normalized = File.expand_path(path.to_s)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@root = normalized
|
|
38
|
+
@game = game
|
|
39
|
+
end
|
|
40
|
+
normalized
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear!
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@root = nil
|
|
46
|
+
@game = nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve a root for a request: explicit `root:` param wins (preserves
|
|
51
|
+
# the diff-panel pattern of "operate on a specific tree even if a
|
|
52
|
+
# project is open"), then the active project, then nil — the caller
|
|
53
|
+
# decides whether to fall back to Esp::ROOT or error.
|
|
54
|
+
def root_for(params)
|
|
55
|
+
explicit = params.is_a?(Hash) ? params['root'] : nil
|
|
56
|
+
return explicit unless explicit.nil? || explicit.to_s.empty?
|
|
57
|
+
|
|
58
|
+
root
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Same precedence as root_for, but with Esp::ROOT as a final fallback.
|
|
62
|
+
# This is what every op that *needs* a root should call — diff/approve/
|
|
63
|
+
# reject already do (via Esp::Operations#vcs_root); slice 1 of step
|
|
64
|
+
# 23.5 routes the game ops (build, lint, scaffold, …) through it too
|
|
65
|
+
# so ESPresso's "open project" actually moves where they operate.
|
|
66
|
+
def resolve(params = {})
|
|
67
|
+
root_for(params) || Esp::ROOT
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/esp/agent.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Esp
|
|
4
|
+
# A hand-rolled tool-use loop. The agent authors mods by calling the same
|
|
5
|
+
# curated tool surface MCP exposes (Esp::McpServer::TOOLS), each tool
|
|
6
|
+
# dispatched to Esp::Operations.public_send(op, …) — so the in-house agent
|
|
7
|
+
# and external MCP clients share one tool definition.
|
|
8
|
+
#
|
|
9
|
+
# The LLM is reached through an injected **provider** (see Esp::Providers), so
|
|
10
|
+
# the loop is provider-agnostic and fully testable with a stub and no live
|
|
11
|
+
# key. When none is given, the default provider is built from the registry
|
|
12
|
+
# (Esp::Providers.default_id). See .claude/roadmap/19-agent-workspace.md.
|
|
13
|
+
#
|
|
14
|
+
# Transcript shape (neutral, owned by Agent; providers translate it to their
|
|
15
|
+
# native wire format):
|
|
16
|
+
# { role: :user, text: String }
|
|
17
|
+
# { role: :assistant, text: String, tool_calls: [{id:, name:, input:}],
|
|
18
|
+
# raw: <provider-native message, opaque to Agent> }
|
|
19
|
+
# { role: :tool, results: [{id:, content: String, is_error: bool}] }
|
|
20
|
+
class Agent
|
|
21
|
+
MAX_TURNS = 20
|
|
22
|
+
|
|
23
|
+
SYSTEM = <<~PROMPT.freeze
|
|
24
|
+
You are the authoring agent inside ESPresso, the GUI for the `esp`
|
|
25
|
+
Morrowind modding toolchain. You build and edit mods by calling the
|
|
26
|
+
provided tools, which operate on the user's mod project as diffable
|
|
27
|
+
source. Read current state (records_read, refs_find) before writing.
|
|
28
|
+
Make the smallest change that satisfies the request, then build and
|
|
29
|
+
lint to confirm. Keep explanations brief — the human reviews the diff.
|
|
30
|
+
PROMPT
|
|
31
|
+
|
|
32
|
+
Result = Struct.new(:text, :messages, keyword_init: true)
|
|
33
|
+
|
|
34
|
+
def initialize(provider: nil, system: SYSTEM, max_turns: MAX_TURNS)
|
|
35
|
+
@provider = provider || Esp::Providers.build(Esp::Providers.default_id)
|
|
36
|
+
@system = system
|
|
37
|
+
@max_turns = max_turns
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Run the loop to completion for one user prompt. Optionally yields events
|
|
41
|
+
# as they happen ([:text, str] / [:tool_use, name, input] /
|
|
42
|
+
# [:tool_result, name, payload] / [:tool_error, name, error]) for a
|
|
43
|
+
# streaming UI to render.
|
|
44
|
+
def run(prompt, &on_event)
|
|
45
|
+
messages = [{ role: :user, text: prompt }]
|
|
46
|
+
@max_turns.times do
|
|
47
|
+
completion = @provider.complete(system: @system, tools: tool_definitions, messages: messages)
|
|
48
|
+
tool_calls = Array(completion.tool_calls)
|
|
49
|
+
messages << { role: :assistant, text: completion.text, tool_calls: tool_calls, raw: completion.raw }
|
|
50
|
+
on_event&.call([:text, completion.text]) unless completion.text.to_s.empty?
|
|
51
|
+
|
|
52
|
+
break if tool_calls.empty?
|
|
53
|
+
|
|
54
|
+
messages << { role: :tool, results: tool_calls.map { |tc| run_tool(tc, &on_event) } }
|
|
55
|
+
end
|
|
56
|
+
Result.new(text: final_text(messages), messages: messages)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def tool_definitions
|
|
62
|
+
Esp::McpServer::TOOLS.map do |tool|
|
|
63
|
+
{ name: tool[:name], description: tool[:description], input_schema: tool[:input_schema] }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Dispatch one normalized tool call to its Operations method, returning a
|
|
68
|
+
# neutral tool result. Caller-fault errors (and an unknown tool name) come
|
|
69
|
+
# back as is_error results the model can recover from.
|
|
70
|
+
def run_tool(tool_call, &on_event)
|
|
71
|
+
name = tool_call[:name].to_s
|
|
72
|
+
input = deep_stringify(tool_call[:input] || {})
|
|
73
|
+
on_event&.call([:tool_use, name, input])
|
|
74
|
+
|
|
75
|
+
op = Esp::McpServer::TOOLS_BY_NAME.fetch(name)[:op]
|
|
76
|
+
payload = Esp::Operations.dispatch(op, input)
|
|
77
|
+
on_event&.call([:tool_result, name, payload])
|
|
78
|
+
{ id: tool_call[:id], content: JSON.generate(payload), is_error: false }
|
|
79
|
+
rescue KeyError
|
|
80
|
+
tool_error(tool_call[:id], name, 'unknown_tool', name, &on_event)
|
|
81
|
+
rescue *Esp::Operations.caller_errors => e
|
|
82
|
+
tool_error(tool_call[:id], name, Esp::Operations.error_code(e), e.message, &on_event)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tool_error(id, name, code, message, &on_event)
|
|
86
|
+
error = { error: { code: code, message: message } }
|
|
87
|
+
on_event&.call([:tool_error, name, error])
|
|
88
|
+
{ id: id, content: JSON.generate(error), is_error: true }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def final_text(messages)
|
|
92
|
+
last = messages.reverse.find { |m| m[:role] == :assistant }
|
|
93
|
+
last ? last[:text].to_s : ''
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deep_stringify(obj)
|
|
97
|
+
case obj
|
|
98
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
99
|
+
when Array then obj.map { |v| deep_stringify(v) }
|
|
100
|
+
else obj
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/esp/cli/docs.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'thor'
|
|
3
|
+
|
|
4
|
+
module Esp
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
class Docs < Thor
|
|
7
|
+
include Support
|
|
8
|
+
|
|
9
|
+
def self.exit_on_failure? = true
|
|
10
|
+
|
|
11
|
+
class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
|
|
12
|
+
|
|
13
|
+
desc 'build', 'Regenerate docs/reference/ from the CLI tree and module headers'
|
|
14
|
+
option :out, type: :string, default: 'docs/reference',
|
|
15
|
+
desc: 'Output directory (relative to repo root)'
|
|
16
|
+
def build
|
|
17
|
+
out_dir = File.expand_path(options[:out], Esp::ROOT)
|
|
18
|
+
result = Esp::DocsGenerator.build(output_dir: out_dir)
|
|
19
|
+
relative = ->(p) { p.sub("#{Esp::ROOT}/", '') }
|
|
20
|
+
payload = {
|
|
21
|
+
output_dir: relative.call(out_dir),
|
|
22
|
+
commands_md: relative.call(result[:commands]),
|
|
23
|
+
api_index_md: relative.call(result[:api_index]),
|
|
24
|
+
api_module_md: result[:api_modules].map(&relative)
|
|
25
|
+
}
|
|
26
|
+
respond(payload) do
|
|
27
|
+
say t('docs.wrote', path: relative.call(result[:commands]))
|
|
28
|
+
say t('docs.wrote', path: relative.call(result[:api_index]))
|
|
29
|
+
result[:api_modules].each { |p| say t('docs.wrote', path: relative.call(p)) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc 'introspect', 'Dump the CLI command tree + module surface as JSON (always JSON)'
|
|
34
|
+
def introspect
|
|
35
|
+
payload = {
|
|
36
|
+
version: Esp::VERSION,
|
|
37
|
+
commands: Esp::Introspection.command_tree,
|
|
38
|
+
modules: Esp::Introspection.module_docs
|
|
39
|
+
}
|
|
40
|
+
$stdout.puts(JSON.generate(payload))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/esp/cli/i18n.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'thor'
|
|
3
|
+
|
|
4
|
+
module Esp
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
class I18nCli < Thor
|
|
7
|
+
include Support
|
|
8
|
+
|
|
9
|
+
def self.exit_on_failure? = true
|
|
10
|
+
|
|
11
|
+
class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
|
|
12
|
+
class_option :root, type: :string,
|
|
13
|
+
desc: 'Project root (defaults to the active project or the toolchain repo)'
|
|
14
|
+
|
|
15
|
+
desc 'check MOD', 'Report missing/orphan i18n keys per locale (vs. en)'
|
|
16
|
+
def check(mod)
|
|
17
|
+
result = Esp::Operations.dispatch(:i18n_check, params_with_root('mod' => mod))
|
|
18
|
+
respond(result) { render(result[:locales]) }
|
|
19
|
+
rescue Esp::Operations::InputError, Esp::Mw::Loader::LoadError => e
|
|
20
|
+
fail_with(e.message)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc 'audit', 'Audit tool-UI locales: undefined/unused/orphan keys + missing translations'
|
|
24
|
+
def audit
|
|
25
|
+
report = Esp::UI.audit
|
|
26
|
+
respond(report) { render_audit(report) }
|
|
27
|
+
problems = report[:undefined].size + report[:unused].size + report[:orphans].size
|
|
28
|
+
exit(1) if problems.positive?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
no_commands do
|
|
32
|
+
def render_audit(report)
|
|
33
|
+
render_key_list(t('i18n.audit.undefined'), report[:undefined])
|
|
34
|
+
render_key_list(t('i18n.audit.unused'), report[:unused])
|
|
35
|
+
report[:orphans].each { |loc, keys| render_key_list(t('i18n.audit.orphans', locale: loc), keys) }
|
|
36
|
+
report[:missing].each { |loc, keys| say t('i18n.audit.missing', locale: loc, count: keys.size) }
|
|
37
|
+
say(t('i18n.audit.clean')) if clean?(report)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def clean?(report)
|
|
41
|
+
report.values_at(:undefined, :unused, :orphans, :missing).all?(&:empty?)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_key_list(heading, keys)
|
|
45
|
+
return if keys.empty?
|
|
46
|
+
|
|
47
|
+
say heading
|
|
48
|
+
keys.each { |k| say t('i18n.audit.item', key: k) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render(report)
|
|
52
|
+
if report.empty?
|
|
53
|
+
say(t('i18n.none'))
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
report.each do |locale, info|
|
|
58
|
+
say t('i18n.locale', locale: locale)
|
|
59
|
+
info[:missing].each { |k| say t('i18n.missing', key: k) }
|
|
60
|
+
info[:orphan].each { |k| say t('i18n.orphan', key: k) }
|
|
61
|
+
say(t('i18n.ok')) if info[:missing].empty? && info[:orphan].empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/esp/cli/mcp.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'thor'
|
|
3
|
+
|
|
4
|
+
module Esp
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
# `esp mcp` — Model Context Protocol frontend. `serve` speaks JSON-RPC
|
|
7
|
+
# over stdio so an AI client can drive the toolchain as native tools;
|
|
8
|
+
# `install` wires that server into a client's config.
|
|
9
|
+
class Mcp < Thor
|
|
10
|
+
include Support
|
|
11
|
+
|
|
12
|
+
def self.exit_on_failure? = true
|
|
13
|
+
|
|
14
|
+
class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
|
|
15
|
+
|
|
16
|
+
desc 'serve', 'Run the MCP server over stdio (JSON-RPC 2.0) for AI clients'
|
|
17
|
+
def serve
|
|
18
|
+
# stdout is the protocol channel — never print to it here.
|
|
19
|
+
warn(t('mcp.serve.listening'))
|
|
20
|
+
Esp::McpServer.new.start
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc 'install', 'Register `esp mcp serve` with an AI client (Claude Code / Desktop)'
|
|
24
|
+
option :client, type: :string, default: 'claude-code',
|
|
25
|
+
enum: Esp::McpInstaller.clients,
|
|
26
|
+
desc: 'Which MCP client to configure'
|
|
27
|
+
option :name, type: :string, default: Esp::McpInstaller::SERVER_NAME,
|
|
28
|
+
desc: 'Name to register the server under in mcpServers'
|
|
29
|
+
def install
|
|
30
|
+
result = Esp::McpInstaller.install(options[:client], name: options[:name])
|
|
31
|
+
respond(result_payload(result)) do
|
|
32
|
+
say t('mcp.install.result', action: result.action, name: result.name, label: result.label)
|
|
33
|
+
say t('mcp.install.config', path: result.config_path)
|
|
34
|
+
say t('mcp.install.restart') if needs_restart?(result)
|
|
35
|
+
end
|
|
36
|
+
rescue ArgumentError => e
|
|
37
|
+
fail_with(e.message)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
no_commands do
|
|
41
|
+
def result_payload(result)
|
|
42
|
+
{ client: result.client, name: result.name, action: result.action.to_s,
|
|
43
|
+
config: result.config_path, entry: result.entry }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def needs_restart?(result)
|
|
47
|
+
result.client == 'claude-desktop' && result.action != :unchanged
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'thor'
|
|
3
|
+
|
|
4
|
+
module Esp
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
# `esp plugins` — inspect the plugins OpenMW already has installed,
|
|
7
|
+
# read from openmw.cfg's data=/content= entries.
|
|
8
|
+
class Plugins < Thor
|
|
9
|
+
include Support
|
|
10
|
+
|
|
11
|
+
def self.exit_on_failure? = true
|
|
12
|
+
|
|
13
|
+
class_option :json, type: :boolean, desc: 'Output structured JSON instead of human text'
|
|
14
|
+
|
|
15
|
+
desc 'list', 'List plugins installed in OpenMW (active flag + load order)'
|
|
16
|
+
option :config, type: :string, desc: 'Path to openmw.cfg (default: per-OS user config)'
|
|
17
|
+
def list
|
|
18
|
+
result = Esp::Operations.dispatch(:plugins_list, 'config' => options[:config])
|
|
19
|
+
respond(result) { render(result) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
no_commands do
|
|
23
|
+
def render(result)
|
|
24
|
+
say(header(result))
|
|
25
|
+
plugins = result[:plugins]
|
|
26
|
+
return say(t('plugins.list.none')) if plugins.empty?
|
|
27
|
+
|
|
28
|
+
active, inactive = plugins.partition { |p| p[:active] }
|
|
29
|
+
active.sort_by { |p| p[:load_order] }
|
|
30
|
+
.each { |p| say t('plugins.list.active', order: p[:load_order], name: p[:name]) }
|
|
31
|
+
inactive.sort_by { |p| p[:name] }
|
|
32
|
+
.each { |p| say t('plugins.list.inactive', name: p[:name]) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def header(result)
|
|
36
|
+
key = result[:exists] ? 'plugins.list.config' : 'plugins.list.missing'
|
|
37
|
+
t(key, path: result[:openmw_cfg])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|