hmgr 0.0.5 → 0.0.6
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.
- package/README.md +10 -0
- package/dist/adapter/checkout-pfad.js +10 -2
- package/dist/adapter/claude-code.js +231 -7
- package/dist/adapter/composer.js +4 -3
- package/dist/adapter/datei-text.js +18 -0
- package/dist/adapter/dateisystem.js +42 -4
- package/dist/adapter/git.js +34 -8
- package/dist/adapter/hermes.js +73 -1
- package/dist/adapter/marketplace-json.js +2 -1
- package/dist/adapter/mcp-katalog.js +124 -0
- package/dist/adapter/quelle-text.js +10 -1
- package/dist/adapter/skill-md.js +2 -1
- package/dist/adapter/umgebung.js +43 -0
- package/dist/adapter/vergleich.js +20 -0
- package/dist/adapter/zustand-json.js +2 -1
- package/dist/cli/hilfe.js +98 -0
- package/dist/cli/main.js +97 -3
- package/dist/gui/beobachter.js +108 -0
- package/dist/gui/login-pfad.js +120 -0
- package/dist/gui/main.js +361 -19
- package/dist/gui/mcp-ansicht.js +57 -0
- package/dist/gui/oeffnen.js +1 -1
- package/dist/gui/preload.cjs +16 -1
- package/dist/gui/renderer/index.html +96 -6
- package/dist/gui/renderer/renderer.js +654 -38
- package/dist/gui/renderer/styles.css +65 -2
- package/dist/kern/anwendungsfaelle.js +175 -4
- package/dist/kern/modell.js +141 -3
- package/dist/kern/quelle-ort.js +14 -1
- package/dist/ports/harness.js +3 -0
- package/dist/ports/mcp-katalog.js +1 -0
- package/dist/ports/umgebung.js +1 -0
- package/package.json +43 -1
package/README.md
CHANGED
|
@@ -24,6 +24,16 @@ npm run build
|
|
|
24
24
|
|
|
25
25
|
`npm run dev` startet `src/cli/main.ts`, `npm run build` erzeugt `dist/`.
|
|
26
26
|
|
|
27
|
+
## GUI
|
|
28
|
+
|
|
29
|
+
`npm run gui` startet die Electron-Oberfläche im Dev-Modus (`electron dist/gui/main.js`).
|
|
30
|
+
|
|
31
|
+
`npm run gui:app` baut ein macOS-App-Bundle „hmgr.app" mit eigenem Icon und Namen im Dock/Finder (statt „Electron"), über [electron-builder](https://www.electron.build/). Das Ergebnis liegt in `release/mac-arm64/hmgr.app`, dazu ein `.zip` zum Weitergeben. Nur arm64, unsigniert, keine Notarisierung, kein Auto-Update — für den lokalen Gebrauch. Das Icon (`build/icon.icns`) entsteht aus `entwurf/icon/icon-1024.png`.
|
|
32
|
+
|
|
33
|
+
`npm run gui:app:win` baut den Windows-Installer, auch auf dem Mac: NSIS braucht dort kein Wine, electron-builder lädt sich die nötigen Teile selbst. Es entsteht `release/hmgr-Setup-<version>-x64.exe` (rund 107 MiB) samt `.blockmap`, daneben die ungepackte App in `release/win-unpacked/`. Nur x64, installiert wird pro Benutzer nach `%LOCALAPPDATA%\Programs\hmgr` — ohne Administratorrechte, das Zielverzeichnis lässt sich im Installer ändern. Das Windows-Icon (`build/icon.ico`) erzeugt `entwurf/icon/erzeuge-ico.py` aus derselben `icon-1024.png`.
|
|
34
|
+
|
|
35
|
+
Der Installer ist nicht signiert. Windows SmartScreen meldet sich deshalb beim ersten Start mit „Der Computer wurde durch Windows geschützt"; der Weg dahinter führt über „Weitere Informationen" und „Trotzdem ausführen".
|
|
36
|
+
|
|
27
37
|
## Kommandos
|
|
28
38
|
|
|
29
39
|
`hmgr --help` zeigt die Kommandos, `hmgr help <kommando>` Schalter, Beispiele
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pfadschema der Checkouts (E22): <wurzel>/<host>/<besitzer>/<repo>/<ref>/,
|
|
2
2
|
// je Repo ein Basis-Clone unter <wurzel>/<host>/<besitzer>/<repo>/.repo.
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { ortAusSegmenten, urlOrt } from "../kern/quelle-ort.js";
|
|
4
|
+
import { istWindowsLaufwerkspfad, ortAusSegmenten, urlOrt } from "../kern/quelle-ort.js";
|
|
5
5
|
export const BASIS_NAME = '.repo';
|
|
6
6
|
/** Refs mit „/" im Namen werden im Pfad kodiert, damit ein Ref ein Verzeichnis bleibt. */
|
|
7
7
|
export function refKodieren(ref) {
|
|
@@ -15,8 +15,16 @@ export function urlZerlegen(url) {
|
|
|
15
15
|
const ort = urlOrt(url);
|
|
16
16
|
if (ort !== undefined)
|
|
17
17
|
return ort;
|
|
18
|
+
const text = url.trim();
|
|
19
|
+
// Windows-Laufwerkspfad (E65/B12): an beiden Trennern zerlegen und ohne den Laufwerksbuchstaben,
|
|
20
|
+
// denn ein Doppelpunkt ist unter Windows kein erlaubtes Zeichen in einem Verzeichnisnamen —
|
|
21
|
+
// „C:" als Besitzer ergäbe einen Checkout-Pfad, den das Dateisystem nicht anlegen kann. Ohne das
|
|
22
|
+
// Laufwerk ist es derselbe Fall wie ein POSIX-Pfad, dessen erstes Segment leer ist. Bewusst ohne
|
|
23
|
+
// `path.resolve`: die Zerlegung soll auf jeder Plattform dasselbe ergeben.
|
|
24
|
+
if (istWindowsLaufwerkspfad(text))
|
|
25
|
+
return ortAusSegmenten('local', text.slice(2).split(/[\\/]/));
|
|
18
26
|
// Nackter lokaler Pfad: Besitzer aus dem Elternverzeichnis.
|
|
19
|
-
return ortAusSegmenten('local', path.resolve(
|
|
27
|
+
return ortAusSegmenten('local', path.resolve(text).split(path.sep));
|
|
20
28
|
}
|
|
21
29
|
export function quelleZerlegen(quelle) {
|
|
22
30
|
return urlZerlegen(quelle.art === 'git' ? quelle.url : quelle.pfad);
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
// Claude Code: Grundverhalten plus Plugins über die claude-CLI (E16, E24, E27).
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
// Claude Code: Grundverhalten plus Plugins und MCP-Server über die claude-CLI (E16, E24, E27, E61).
|
|
2
|
+
import spawn from 'cross-spawn';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { mkdir, readFile, stat } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
|
-
import { OrtNichtUnterstuetzt, pluginSchluessel } from "../kern/modell.js";
|
|
6
|
+
import { McpAbweichenderEintrag, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, OrtNichtUnterstuetzt, pluginSchluessel, } from "../kern/modell.js";
|
|
7
|
+
import { ohneBom } from "./datei-text.js";
|
|
6
8
|
import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
|
|
9
|
+
import { tiefGleich } from "./vergleich.js";
|
|
7
10
|
const GITIGNORE_ZEILE = '.claude/';
|
|
8
11
|
/**
|
|
9
12
|
* Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme.
|
|
10
13
|
* stdin ist zu, sonst hängt eine CLI, die auf Eingabe wartet, den ganzen Ablauf; der Timeout
|
|
11
14
|
* fängt alles Übrige (beobachtet: 13 Minuten ohne Rückkehr in der GUI). SIGKILL, weil die
|
|
12
15
|
* CLI SIGTERM überleben kann.
|
|
16
|
+
*
|
|
17
|
+
* `spawn` kommt von cross-spawn, nicht aus node:child_process (E65/B7): unter Windows ist die
|
|
18
|
+
* npm-installierte `claude` ein `.cmd`-Shim, und Node startet `.cmd`/`.bat` seit dem Fix zu
|
|
19
|
+
* CVE-2024-27980 ohne `shell: true` nicht mehr (EINVAL). Der error-Handler machte daraus Code 1,
|
|
20
|
+
* `vorhanden()` hielt die CLI für abwesend und alle MCP- und Plugin-Fähigkeiten fielen weg.
|
|
21
|
+
* cross-spawn setzt den Aufruf selbst auf cmd.exe um und quotet dabei richtig — nötig für
|
|
22
|
+
* Argumente wie das JSON von `mcp add-json`. Auf macOS und Linux ändert sich nichts.
|
|
13
23
|
*/
|
|
14
24
|
export const claudeAusfuehrer = (args, cwd) => new Promise((aufloesen) => {
|
|
15
25
|
const kind = spawn('claude', args, {
|
|
@@ -20,6 +30,8 @@ export const claudeAusfuehrer = (args, cwd) => new Promise((aufloesen) => {
|
|
|
20
30
|
});
|
|
21
31
|
let stdout = '';
|
|
22
32
|
let stderr = '';
|
|
33
|
+
// `!`, weil cross-spawn nur den allgemeinen ChildProcess-Typ mit nullbaren Strömen kennt;
|
|
34
|
+
// das `stdio` oben verlangt für beide eine Pipe, also sind sie da.
|
|
23
35
|
kind.stdout.on('data', (d) => (stdout += d));
|
|
24
36
|
kind.stderr.on('data', (d) => (stderr += d));
|
|
25
37
|
kind.on('error', (fehler) => aufloesen({ stdout: fehler.message, code: 1 }));
|
|
@@ -143,28 +155,240 @@ class ClaudePlugins {
|
|
|
143
155
|
.map((eintrag) => pluginSchluessel(eintrag));
|
|
144
156
|
}
|
|
145
157
|
}
|
|
158
|
+
/** Der JSON-Block für `claude mcp add-json`, aus dem neutralen Katalog-Konfigblock (E61). */
|
|
159
|
+
export function claudeMcpJson(konfig) {
|
|
160
|
+
if ('command' in konfig)
|
|
161
|
+
return JSON.stringify({
|
|
162
|
+
type: 'stdio',
|
|
163
|
+
command: konfig.command,
|
|
164
|
+
...(konfig.args === undefined ? {} : { args: konfig.args }),
|
|
165
|
+
...(konfig.env === undefined ? {} : { env: konfig.env }),
|
|
166
|
+
});
|
|
167
|
+
return JSON.stringify({
|
|
168
|
+
type: 'http',
|
|
169
|
+
url: konfig.url,
|
|
170
|
+
...(konfig.headers === undefined ? {} : { headers: konfig.headers }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function objektFeld(daten, feld) {
|
|
174
|
+
if (typeof daten !== 'object' || daten === null)
|
|
175
|
+
return undefined;
|
|
176
|
+
return daten[feld];
|
|
177
|
+
}
|
|
178
|
+
function mcpServersVon(daten) {
|
|
179
|
+
const mcp = objektFeld(daten, 'mcpServers');
|
|
180
|
+
if (typeof mcp !== 'object' || mcp === null || Array.isArray(mcp))
|
|
181
|
+
return {};
|
|
182
|
+
return mcp;
|
|
183
|
+
}
|
|
184
|
+
/** Der aufgelöste Pfad, oder der Pfad selbst, wenn es ihn nicht (mehr) gibt. */
|
|
185
|
+
function aufgeloest(pfad) {
|
|
186
|
+
try {
|
|
187
|
+
return realpathSync(pfad);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return pfad;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Die MCP-Server der geteilten `.mcp.json` eines Arbeitsverzeichnisses (E61-Nachtrag, Format
|
|
195
|
+
* `{"mcpServers": {<name>: {...}}}`, wie `~/.claude.json`). Nur gelesen, nie geschrieben: fehlende
|
|
196
|
+
* Datei heißt nichts, kaputtes JSON oder fremde Struktur ebenso, derselbe tolerante Umgang wie beim
|
|
197
|
+
* Lesen von `~/.claude.json`. Eine BOM zählt nicht als kaputt (E65/B11), sonst wirkt eine unter
|
|
198
|
+
* Windows angelegte Datei leer.
|
|
199
|
+
*/
|
|
200
|
+
async function projektMcpJsonLesen(verzeichnis) {
|
|
201
|
+
try {
|
|
202
|
+
const text = await readFile(path.join(verzeichnis, '.mcp.json'), 'utf8');
|
|
203
|
+
return mcpServersVon(JSON.parse(ohneBom(text)));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* MCP-Server über `claude mcp …` (E61). Schreiben ausschließlich über die CLI, dieselbe
|
|
211
|
+
* Ausführer-Infrastruktur wie bei Plugins. Für die Übersicht wird NUR `~/.claude.json` gelesen, nie
|
|
212
|
+
* geschrieben: user-Scope unter `mcpServers`, local-Scope je Projekt unter
|
|
213
|
+
* `projects.<pfad>.mcpServers`. Die geteilte `.mcp.json` in der Projektwurzel bleibt beim Ein- und
|
|
214
|
+
* Aushängen unangetastet (E57), `geteilteFundorte` liest sie für die Übersicht mit (E61-Nachtrag);
|
|
215
|
+
* geschrieben wird sie einzig von `geteiltenUebernehmen` auf ausdrücklichen Auftrag (E64). Format
|
|
216
|
+
* und Verhalten der CLI stehen in HARNESS-FAKTEN.md, geprüft an Claude Code 2.1.261 gegen ein
|
|
217
|
+
* eigenes CLAUDE_CONFIG_DIR, nie an einer echten Nutzerdatei.
|
|
218
|
+
*/
|
|
219
|
+
class ClaudeMcp {
|
|
220
|
+
harness;
|
|
221
|
+
ausfuehrer;
|
|
222
|
+
home;
|
|
223
|
+
constructor(harness, ausfuehrer, home) {
|
|
224
|
+
this.harness = harness;
|
|
225
|
+
this.ausfuehrer = ausfuehrer;
|
|
226
|
+
this.home = home;
|
|
227
|
+
}
|
|
228
|
+
ebenen() {
|
|
229
|
+
return ['home', 'arbeitsverzeichnis'];
|
|
230
|
+
}
|
|
231
|
+
hinweisNachAenderung() {
|
|
232
|
+
return 'Claude Code übernimmt die Änderung mit der nächsten Sitzung.';
|
|
233
|
+
}
|
|
234
|
+
scope(ort) {
|
|
235
|
+
if (ort.art === 'home')
|
|
236
|
+
return { scope: 'user' };
|
|
237
|
+
if (ort.art === 'arbeitsverzeichnis')
|
|
238
|
+
return { scope: 'local', cwd: ort.pfad };
|
|
239
|
+
throw new OrtNichtUnterstuetzt(this.harness.name, ort.art);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Die CLI legt keinen Eintrag über einen bestehenden: sie bricht mit Code 1 und „already exists"
|
|
243
|
+
* ab (belegt an 2.1.261). Deshalb wird danach in `~/.claude.json` nachgesehen: derselbe Block
|
|
244
|
+
* heißt „schon eingehängt" und ist idempotent wie bei Hermes, ein abweichender Block ist ein
|
|
245
|
+
* eigener Fehler statt einer englischen CLI-Zeile. hmgr überschreibt ihn nicht (E61).
|
|
246
|
+
*/
|
|
247
|
+
async einhaengen(name, konfig, ort) {
|
|
248
|
+
const { scope, cwd } = this.scope(ort);
|
|
249
|
+
const json = claudeMcpJson(konfig);
|
|
250
|
+
const { stdout, code } = await this.ausfuehrer(['mcp', 'add-json', name, json, '--scope', scope], cwd);
|
|
251
|
+
if (code === 0)
|
|
252
|
+
return;
|
|
253
|
+
const bestehend = await this.eintrag(name, ort);
|
|
254
|
+
if (bestehend !== undefined) {
|
|
255
|
+
if (tiefGleich(bestehend, JSON.parse(json)))
|
|
256
|
+
return;
|
|
257
|
+
throw new McpAbweichenderEintrag({ harness: this.harness.name, ort }, name);
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`claude mcp add-json ${name} scheiterte (Code ${code}): ${stdout.trim()}`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Ist der Name am Ort gar nicht eingehängt, bricht die CLI mit Code 1 ab („No MCP server named",
|
|
263
|
+
* belegt an 2.1.261). Das ist kein Fehler, sondern das gewünschte Ergebnis: aushängen ist
|
|
264
|
+
* idempotent wie bei Hermes. Ausnahme (E61-Nachtrag): steht der Name nur in der geteilten
|
|
265
|
+
* `.mcp.json` der Projektwurzel, bleibt dort tatsächlich ein Eintrag liegen — das darf nicht als
|
|
266
|
+
* Erfolg durchgehen, sondern bekommt eine eigene Meldung.
|
|
267
|
+
*/
|
|
268
|
+
async aushaengen(name, ort) {
|
|
269
|
+
const { scope, cwd } = this.scope(ort);
|
|
270
|
+
const { stdout, code } = await this.ausfuehrer(['mcp', 'remove', name, '--scope', scope], cwd);
|
|
271
|
+
if (code === 0)
|
|
272
|
+
return;
|
|
273
|
+
if ((await this.eintrag(name, ort)) !== undefined)
|
|
274
|
+
throw new Error(`claude mcp remove ${name} scheiterte (Code ${code}): ${stdout.trim()}`);
|
|
275
|
+
if (ort.art === 'arbeitsverzeichnis') {
|
|
276
|
+
const geteilt = await projektMcpJsonLesen(ort.pfad);
|
|
277
|
+
if (name in geteilt)
|
|
278
|
+
throw new McpNurGeteilterFundort({ harness: this.harness.name, ort }, name, path.join(ort.pfad, '.mcp.json'));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async eingehaengte(ort) {
|
|
282
|
+
return Object.keys(await this.mcpServersAm(ort));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Die geteilte `.mcp.json` eines Arbeitsverzeichnisses (E61-Nachtrag): im Alltag nur gelesen,
|
|
286
|
+
* weder beim Ein- noch beim Aushängen angefasst. Home kennt keine solche Datei.
|
|
287
|
+
*/
|
|
288
|
+
async geteilteFundorte(ort) {
|
|
289
|
+
if (ort.art !== 'arbeitsverzeichnis')
|
|
290
|
+
return [];
|
|
291
|
+
const datei = path.join(ort.pfad, '.mcp.json');
|
|
292
|
+
const server = await projektMcpJsonLesen(ort.pfad);
|
|
293
|
+
return Object.keys(server).map((name) => ({ name, datei, scope: 'project' }));
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Übernehmen (E64): den Block aus der geteilten `.mcp.json` der Projektwurzel 1:1 in den privaten
|
|
297
|
+
* local-Scope schreiben und ihn dort entfernen. Geschrieben wird ausschließlich über die CLI
|
|
298
|
+
* (E61), auch die geteilte Datei — `claude mcp remove <name> --scope project` mit der
|
|
299
|
+
* Projektwurzel als cwd kann das, ein eigener JSON-Schreibweg wäre eine zweite Wahrheit über das
|
|
300
|
+
* Dateiformat. Preis dafür: die CLI schreibt die Datei komplett neu und behält nur `mcpServers`,
|
|
301
|
+
* zusätzliche Top-Level-Schlüssel gehen verloren (belegt an 2.1.261, siehe HARNESS-FAKTEN.md) —
|
|
302
|
+
* dasselbe passiert dem Nutzer, wenn er die CLI selbst aufruft.
|
|
303
|
+
*
|
|
304
|
+
* Die Reihenfolge ist fail-safe: erst eintragen, dann entfernen. Scheitert der erste Schritt, ist
|
|
305
|
+
* nichts geschehen. Scheitert der zweite, steht der Eintrag doppelt statt nirgends — und weil
|
|
306
|
+
* local Vorrang vor project hat (belegt an 2.1.261), wirkt schon der private. Der Block kommt aus
|
|
307
|
+
* der Datei, nicht aus dem Katalog: auch ein Eintrag, den der Katalog nicht kennt, lässt sich so
|
|
308
|
+
* übernehmen.
|
|
309
|
+
*/
|
|
310
|
+
async geteiltenUebernehmen(name, ort) {
|
|
311
|
+
const ziel = { harness: this.harness.name, ort };
|
|
312
|
+
if (ort.art !== 'arbeitsverzeichnis')
|
|
313
|
+
throw new McpUebernahmeNichtMoeglich(ziel);
|
|
314
|
+
const datei = path.join(ort.pfad, '.mcp.json');
|
|
315
|
+
const block = (await projektMcpJsonLesen(ort.pfad))[name];
|
|
316
|
+
if (block === undefined)
|
|
317
|
+
throw new McpNichtInGeteilterDatei(ziel, name, datei);
|
|
318
|
+
const hin = await this.ausfuehrer(['mcp', 'add-json', name, JSON.stringify(block), '--scope', 'local'], ort.pfad);
|
|
319
|
+
if (hin.code !== 0) {
|
|
320
|
+
// Der einzige harmlose Fehlschlag: derselbe Block liegt schon im privaten Scope, etwa weil ein
|
|
321
|
+
// früherer Lauf zwischen den beiden Schritten abgebrochen ist. Ein abweichender Block wird
|
|
322
|
+
// nicht überschrieben (E61), und die geteilte Datei bleibt dann unangetastet.
|
|
323
|
+
const bestehend = await this.eintrag(name, ort);
|
|
324
|
+
if (bestehend === undefined)
|
|
325
|
+
throw new Error(`claude mcp add-json ${name} scheiterte (Code ${hin.code}): ${hin.stdout.trim()}`);
|
|
326
|
+
if (!tiefGleich(bestehend, block))
|
|
327
|
+
throw new McpAbweichenderEintrag(ziel, name);
|
|
328
|
+
}
|
|
329
|
+
const weg = await this.ausfuehrer(['mcp', 'remove', name, '--scope', 'project'], ort.pfad);
|
|
330
|
+
// Nicht der Code entscheidet, sondern die Datei danach: steht der Name nicht mehr drin, ist die
|
|
331
|
+
// Übernahme fertig, egal wie die CLI den Fall genannt hat.
|
|
332
|
+
if (weg.code !== 0 && name in (await projektMcpJsonLesen(ort.pfad)))
|
|
333
|
+
throw new McpUebernahmeUnvollstaendig(ziel, name, datei, weg.stdout.trim());
|
|
334
|
+
return { datei };
|
|
335
|
+
}
|
|
336
|
+
/** Der eingehängte Block unter diesem Namen, oder undefined. */
|
|
337
|
+
async eintrag(name, ort) {
|
|
338
|
+
return (await this.mcpServersAm(ort))[name];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Die MCP-Server, die `~/.claude.json` für diesen Ort führt. Ein Arbeitsverzeichnis steht dort
|
|
342
|
+
* unter dem aufgelösten Pfad, weil Claude Code den cwd auflöst (belegt an 2.1.261); hmgr merkt
|
|
343
|
+
* sich Arbeitsverzeichnisse aber so, wie der Nutzer sie genannt hat, also zählen beide.
|
|
344
|
+
*/
|
|
345
|
+
async mcpServersAm(ort) {
|
|
346
|
+
const daten = await this.claudeJsonLesen();
|
|
347
|
+
if (ort.art === 'home')
|
|
348
|
+
return mcpServersVon(daten);
|
|
349
|
+
if (ort.art === 'arbeitsverzeichnis') {
|
|
350
|
+
const projects = objektFeld(daten, 'projects');
|
|
351
|
+
const projekt = objektFeld(projects, ort.pfad) ?? objektFeld(projects, aufgeloest(ort.pfad));
|
|
352
|
+
return mcpServersVon(projekt);
|
|
353
|
+
}
|
|
354
|
+
throw new OrtNichtUnterstuetzt(this.harness.name, ort.art);
|
|
355
|
+
}
|
|
356
|
+
async claudeJsonLesen() {
|
|
357
|
+
try {
|
|
358
|
+
const text = await readFile(path.join(this.home, '.claude.json'), 'utf8');
|
|
359
|
+
return JSON.parse(ohneBom(text));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
146
366
|
export class ClaudeCode {
|
|
147
367
|
name = 'claude-code';
|
|
148
368
|
home;
|
|
149
369
|
ausfuehrer;
|
|
150
370
|
/** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt. */
|
|
151
371
|
plugins;
|
|
372
|
+
/** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt (E61). */
|
|
373
|
+
mcp;
|
|
152
374
|
geprueft = false;
|
|
153
375
|
constructor(home, ausfuehrer = claudeAusfuehrer) {
|
|
154
376
|
this.home = home;
|
|
155
377
|
this.ausfuehrer = ausfuehrer;
|
|
156
378
|
}
|
|
157
379
|
/**
|
|
158
|
-
* Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin-
|
|
159
|
-
*
|
|
380
|
+
* Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin- und
|
|
381
|
+
* MCP-Fähigkeit kommen dazu, sobald `claude --version` durchläuft: ohne CLI gibt es beides nicht.
|
|
160
382
|
*/
|
|
161
383
|
async vorhanden() {
|
|
162
384
|
const da = (await stat(path.join(this.home, '.claude')).catch(() => undefined)) !== undefined;
|
|
163
385
|
if (!this.geprueft) {
|
|
164
386
|
this.geprueft = true;
|
|
165
387
|
const { code } = await this.ausfuehrer(['--version']).catch(() => ({ code: 1 }));
|
|
166
|
-
if (code === 0)
|
|
388
|
+
if (code === 0) {
|
|
167
389
|
this.plugins = new ClaudePlugins(this, this.ausfuehrer);
|
|
390
|
+
this.mcp = new ClaudeMcp(this, this.ausfuehrer, this.home);
|
|
391
|
+
}
|
|
168
392
|
}
|
|
169
393
|
return da;
|
|
170
394
|
}
|
package/dist/adapter/composer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Zeiger in der composer.json des Repos, unter extra.hmgr (B8, E25, E26).
|
|
2
2
|
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ohneBom } from "./datei-text.js";
|
|
4
5
|
import { quelleAusText } from "./quelle-text.js";
|
|
5
6
|
const DATEI = 'composer.json';
|
|
6
7
|
/** Ruft hmgr nur auf, wenn es vorhanden ist, und lässt composer nie scheitern (E20). */
|
|
@@ -40,7 +41,7 @@ export class ComposerZeiger {
|
|
|
40
41
|
return { ergebnis: 'mehrdeutig', kandidaten };
|
|
41
42
|
}
|
|
42
43
|
async lesen(pfad) {
|
|
43
|
-
const daten = JSON.parse(await readFile(pfad, 'utf8'));
|
|
44
|
+
const daten = JSON.parse(ohneBom(await readFile(pfad, 'utf8')));
|
|
44
45
|
const eintrag = daten.extra?.hmgr ?? {};
|
|
45
46
|
const verzeichnis = path.dirname(pfad);
|
|
46
47
|
return {
|
|
@@ -50,7 +51,7 @@ export class ComposerZeiger {
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
async schreiben(pfad, zeiger) {
|
|
53
|
-
const daten = JSON.parse(await readFile(pfad, 'utf8'));
|
|
54
|
+
const daten = JSON.parse(ohneBom(await readFile(pfad, 'utf8')));
|
|
54
55
|
const eintrag = {
|
|
55
56
|
sources: zeiger.quellen.map(quelleAlsEintrag),
|
|
56
57
|
skills: Object.fromEntries(zeiger.skills),
|
|
@@ -64,7 +65,7 @@ export class ComposerZeiger {
|
|
|
64
65
|
* und ihre Reihenfolge bleiben, der Rest der Datei bleibt unberührt. Idempotent.
|
|
65
66
|
*/
|
|
66
67
|
async scriptEintragen(pfad) {
|
|
67
|
-
const daten = JSON.parse(await readFile(pfad, 'utf8'));
|
|
68
|
+
const daten = JSON.parse(ohneBom(await readFile(pfad, 'utf8')));
|
|
68
69
|
const scripts = { ...(daten.scripts ?? {}) };
|
|
69
70
|
let geaendert = false;
|
|
70
71
|
for (const schluessel of SCRIPT_SCHLUESSEL) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Gemeinsames Modul der Adapter: Text aus Dateien, die von Hand geschrieben sein können.
|
|
2
|
+
/**
|
|
3
|
+
* Streift eine führende UTF-8-BOM ab (E65/B11). Windows-Editoren und PowerShell 5.1
|
|
4
|
+
* (`Set-Content -Encoding utf8`) schreiben sie an den Dateianfang. `JSON.parse` bricht daran ab
|
|
5
|
+
* („Unexpected token"), und wo dieser Fehler in einem `catch` landet, wirkt die Datei leer statt
|
|
6
|
+
* kaputt: `hmgr mcp migrate` meldete deshalb „steht nicht in der Datei", obwohl der Eintrag in der
|
|
7
|
+
* `.mcp.json` stand.
|
|
8
|
+
*
|
|
9
|
+
* Nur JSON braucht das. Der YAML-Parser kennt die BOM, und `String.trim()` streift sie mit ab —
|
|
10
|
+
* beides geprüft, deshalb bleiben config.yaml, mcp.yaml, `.gitignore` und `.env` unberührt.
|
|
11
|
+
*
|
|
12
|
+
* Beim Zurückschreiben geht die BOM verloren: hmgr schreibt jede JSON-Datei, die es ändert,
|
|
13
|
+
* vollständig neu (`JSON.stringify`). Das ist gewollt — die Datei bleibt gültiges UTF-8 und ist
|
|
14
|
+
* danach auch für Werkzeuge lesbar, die über die BOM stolpern.
|
|
15
|
+
*/
|
|
16
|
+
export function ohneBom(text) {
|
|
17
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
18
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { lstat, mkdir, readFile, readdir, readlink, rm, stat, symlink, unlink } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { NameBelegt } from "../kern/modell.js";
|
|
5
|
+
import { LinkAnlegenFehlgeschlagen, NameBelegt, } from "../kern/modell.js";
|
|
6
6
|
import { checkoutZerlegen, quelleZerlegen } from "./checkout-pfad.js";
|
|
7
|
+
import { ohneBom } from "./datei-text.js";
|
|
7
8
|
import { LOKAL_REF } from "./git.js";
|
|
8
9
|
import { SKILL_DATEI, skillAusPfad } from "./skill-md.js";
|
|
9
10
|
/** Dateien eines Ordners rekursiv, `.git` und `node_modules` ausgenommen, Symlinks nicht gefolgt. */
|
|
@@ -19,6 +20,31 @@ async function ordnerDateien(wurzel, unter, ergebnis) {
|
|
|
19
20
|
ergebnis.push({ relativerPfad: rel, inhalt: await readFile(path.join(wurzel, rel)) });
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Typ-Argument für `fs.symlink` (E66). Unter Windows eine Junction: einen Verzeichnis-Symlink
|
|
25
|
+
* verweigert das System dem unprivilegierten Nutzer, eine Junction legt es ohne Sonderrechte an
|
|
26
|
+
* (HARNESS-FAKTEN „Windows"). Sonst `undefined` — das ist für Node dasselbe wie kein drittes
|
|
27
|
+
* Argument, geprüft an Node 22: beide Aufrufe legen denselben Link.
|
|
28
|
+
*/
|
|
29
|
+
export function symlinkTyp(plattform = process.platform) {
|
|
30
|
+
return plattform === 'win32' ? 'junction' : undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Streift das Windows-Präfix `\\?\` von einem Pfad ab (E66). `fs.readlink` gibt das Ziel einer
|
|
34
|
+
* Junction mitsamt diesem Präfix zurück; unverkürzt verglichen sähe derselbe Link auf einen
|
|
35
|
+
* anderen Pfad aus, und `deuten` hielte einen eigenen Link für fremd. Gekürzt wird nur die
|
|
36
|
+
* Laufwerksform: bei `\\?\UNC\server\share` gehört das Präfix zum Pfad. Auf POSIX ändert die
|
|
37
|
+
* Funktion nichts, dort kommt das Präfix nicht vor.
|
|
38
|
+
*/
|
|
39
|
+
export function ohneWindowsPraefix(pfad) {
|
|
40
|
+
return /^\\\\\?\\[A-Za-z]:/.test(pfad) ? pfad.slice(4) : pfad;
|
|
41
|
+
}
|
|
42
|
+
/** Was der Nutzer tun kann, wenn das Anlegen eines Links gescheitert ist (E66). */
|
|
43
|
+
export function linkFehlerHinweis(plattform = process.platform) {
|
|
44
|
+
return plattform === 'win32'
|
|
45
|
+
? 'Aktiviere den Windows-Entwicklermodus (Einstellungen → System → Für Entwickler) oder starte hmgr mit Administratorrechten.'
|
|
46
|
+
: 'Prüfe die Schreibrechte auf das Zielverzeichnis und ob das Linkziel vorhanden ist.';
|
|
47
|
+
}
|
|
22
48
|
export class DateisystemZiele {
|
|
23
49
|
wurzel;
|
|
24
50
|
zustand;
|
|
@@ -84,7 +110,9 @@ export class DateisystemZiele {
|
|
|
84
110
|
const info = await lstat(pfad).catch(() => undefined);
|
|
85
111
|
if (!info || !info.isSymbolicLink())
|
|
86
112
|
return undefined;
|
|
87
|
-
|
|
113
|
+
// Junctions meldet Node als Symlink, ihr Ziel aber mit `\\?\`-Präfix (E66): erst kürzen, dann
|
|
114
|
+
// auflösen, sonst ergäbe `path.resolve` einen Pfad, der zu keinem Soll-Ziel passt.
|
|
115
|
+
return path.resolve(verzeichnis, ohneWindowsPraefix(await readlink(pfad)));
|
|
88
116
|
}
|
|
89
117
|
/**
|
|
90
118
|
* Ein Skill ist ein Verzeichnis. Dateien wie .DS_Store zählen nicht als fremder Eintrag,
|
|
@@ -137,7 +165,17 @@ export class DateisystemZiele {
|
|
|
137
165
|
else
|
|
138
166
|
throw new NameBelegt(ziel, name);
|
|
139
167
|
}
|
|
140
|
-
|
|
168
|
+
// Eine Junction verlangt ein absolutes Ziel (E66). `checkout.pfad` ist es in der Praxis immer
|
|
169
|
+
// — die Wurzel unter dem hmgr-Home, eine lokale Quelle über `path.resolve` in `quelle-text` —,
|
|
170
|
+
// nur garantiert das nichts: aus einer von Hand geänderten zustand.json käme auch ein
|
|
171
|
+
// relativer Pfad. `path.resolve` kostet nichts und macht die Zusage hier fest.
|
|
172
|
+
const checkoutZiel = path.resolve(checkout.pfad, skill.pfad);
|
|
173
|
+
try {
|
|
174
|
+
await symlink(checkoutZiel, pfad, symlinkTyp());
|
|
175
|
+
}
|
|
176
|
+
catch (ursache) {
|
|
177
|
+
throw new LinkAnlegenFehlgeschlagen(pfad, checkoutZiel, ursache instanceof Error ? ursache.message : String(ursache), linkFehlerHinweis());
|
|
178
|
+
}
|
|
141
179
|
}
|
|
142
180
|
async linkEntfernen(_ziel, verzeichnis, name) {
|
|
143
181
|
const zielPfad = await this.linkZiel(verzeichnis, name);
|
|
@@ -175,7 +213,7 @@ export class DateisystemZiele {
|
|
|
175
213
|
/** Eintrag zu `name` aus dem Lockfile-Inhalt. Kaputtes JSON oder fehlende Felder: undefined. */
|
|
176
214
|
lockEintragAusInhalt(roh, name) {
|
|
177
215
|
try {
|
|
178
|
-
const geparst = JSON.parse(roh);
|
|
216
|
+
const geparst = JSON.parse(ohneBom(roh));
|
|
179
217
|
const eintrag = geparst.skills?.[name];
|
|
180
218
|
if (eintrag === undefined)
|
|
181
219
|
return undefined;
|
package/dist/adapter/git.js
CHANGED
|
@@ -28,13 +28,17 @@ async function existiert(pfad) {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Aus Dateipfaden die Skill-Verzeichnisse bilden: ein Verzeichnis mit SKILL.md ist ein Skill.
|
|
31
|
-
* Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32).
|
|
31
|
+
* Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32). Eine
|
|
32
|
+
* SKILL.md direkt an der Wurzel (`teile.length === 1`) ergibt den Pfad `''`: das Repo ist selbst
|
|
33
|
+
* ein Skill (E11-Nachtrag 2026-09-05). `''` ist die einzige interne Schreibweise dafür, nie `.` —
|
|
34
|
+
* `checkoutZerlegen`, `skillAusPfad` und die Sparse-Checkout-Muster (unten) sind darauf abgestimmt.
|
|
35
|
+
* Wurzel-Skill und `skills/`-Unterordner schließen sich nicht aus, beide landen in der Liste.
|
|
32
36
|
*/
|
|
33
37
|
export function skillPfadeAusDateien(dateien) {
|
|
34
38
|
const verzeichnisse = new Set();
|
|
35
39
|
for (const datei of dateien) {
|
|
36
40
|
const teile = datei.split('/');
|
|
37
|
-
if (teile.at(-1) !== SKILL_DATEI
|
|
41
|
+
if (teile.at(-1) !== SKILL_DATEI)
|
|
38
42
|
continue;
|
|
39
43
|
verzeichnisse.add(teile.slice(0, -1).join('/'));
|
|
40
44
|
}
|
|
@@ -94,6 +98,14 @@ async function inhalteAufPlatte(wurzel, pfade) {
|
|
|
94
98
|
}
|
|
95
99
|
return ergebnis;
|
|
96
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Ersatzname für einen Skill, dessen Pfad kein Segment hergibt (Wurzel-Skill, E11-Nachtrag): der
|
|
103
|
+
* Repo-Name der Quelle. Ohne ihn hieße der Skill „" und ein Link auf ihn zeigte nicht auf einen
|
|
104
|
+
* Eintrag im Ziel, sondern auf das Zielverzeichnis (E32).
|
|
105
|
+
*/
|
|
106
|
+
function ersatznameDerQuelle(quelle) {
|
|
107
|
+
return quelle.art === 'lokal' ? path.basename(quelle.pfad) : quelleZerlegen(quelle).repo;
|
|
108
|
+
}
|
|
97
109
|
async function dateienAufPlatte(wurzel, unter = '') {
|
|
98
110
|
const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
|
|
99
111
|
const dateien = [];
|
|
@@ -180,24 +192,32 @@ export class GitQuellen {
|
|
|
180
192
|
async skillMdInhalte(basis, ref, pfade) {
|
|
181
193
|
if (pfade.length === 0)
|
|
182
194
|
return new Map();
|
|
183
|
-
|
|
195
|
+
// Ein Wurzel-Skill hat den Pfad `''`: `${ref}:${p}/${SKILL_DATEI}` ergäbe `ref:/SKILL.md` mit
|
|
196
|
+
// führendem Schrägstrich, den git als eigenen (ungültigen) Pfad läse statt als Wurzel (E39).
|
|
197
|
+
const eingabe = `${pfade.map((p) => `${ref}:${p.length === 0 ? SKILL_DATEI : `${p}/${SKILL_DATEI}`}`).join('\n')}\n`;
|
|
184
198
|
const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
|
|
185
199
|
if (ausgabe === undefined)
|
|
186
200
|
return new Map();
|
|
187
201
|
return batchInhalte(ausgabe, pfade);
|
|
188
202
|
}
|
|
189
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* Skills der Quelle, Namen aus dem Frontmatter der SKILL.md (E32). Ein Wurzel-Skill (Pfad `''`,
|
|
205
|
+
* E11-Nachtrag) hat keinen Verzeichnisnamen, aus dem `skillAusPfad` einen Namen ableiten könnte:
|
|
206
|
+
* der Repo-Name ist der Ersatzname, nie der leere String, sonst zeigte ein Link ohne Frontmatter
|
|
207
|
+
* `name` auf einen Eintrag mit leerem Namen im Ziel (E32).
|
|
208
|
+
*/
|
|
190
209
|
async skills(quelle, ref) {
|
|
210
|
+
const ersatzname = ersatznameDerQuelle(quelle);
|
|
191
211
|
if (quelle.art === 'lokal') {
|
|
192
212
|
const pfade = skillPfadeAusDateien(await dateienAufPlatte(quelle.pfad));
|
|
193
213
|
const inhalte = await inhalteAufPlatte(quelle.pfad, pfade);
|
|
194
|
-
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
|
|
214
|
+
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
|
|
195
215
|
}
|
|
196
216
|
const basis = await this.basis(quelle);
|
|
197
217
|
const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
|
|
198
218
|
const pfade = skillPfadeAusDateien(ausgabe.split('\n').filter((z) => z.length > 0));
|
|
199
219
|
const inhalte = await this.skillMdInhalte(basis, ref, pfade);
|
|
200
|
-
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
|
|
220
|
+
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
|
|
201
221
|
}
|
|
202
222
|
/**
|
|
203
223
|
* Marketplace einer Quelle (E24). Der Inhalt kommt aus dem Git-Baum, nicht aus einem Checkout:
|
|
@@ -227,7 +247,9 @@ export class GitQuellen {
|
|
|
227
247
|
plugins: gelesen.plugins.map((p) => ({
|
|
228
248
|
name: p.name,
|
|
229
249
|
dependencies: p.dependencies,
|
|
230
|
-
|
|
250
|
+
// Auch hier kann ein Pfad leer sein (`"source": "."` mit `"skills": ["."]`): derselbe
|
|
251
|
+
// Ersatzname wie im Katalog, damit nie ein Skill ohne Namen entsteht (E32).
|
|
252
|
+
skills: p.skills.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatznameDerQuelle(quelle))),
|
|
231
253
|
})),
|
|
232
254
|
};
|
|
233
255
|
}
|
|
@@ -287,7 +309,11 @@ export class GitQuellen {
|
|
|
287
309
|
if (!WURZEL_MUSTER.includes(eintrag))
|
|
288
310
|
muster.add(eintrag);
|
|
289
311
|
// Ein Wurzel-Skill (E48 a, leerer Pfad) ist der ganze Baum: `/**` nimmt ihn nach `!/*/` wieder
|
|
290
|
-
// rekursiv auf, ein Muster `//` wäre kein gültiges Sparse-Checkout-Muster.
|
|
312
|
+
// rekursiv auf, ein Muster `//` wäre kein gültiges Sparse-Checkout-Muster. Der Link, den
|
|
313
|
+
// `linkLegen` darauf setzt, zeigt dann auf die Worktree-Wurzel selbst (E11-Nachtrag 2026-09-05):
|
|
314
|
+
// die ganze Arbeitskopie samt `.git` wird sichtbar, nicht nur die SKILL.md und ihre
|
|
315
|
+
// Unterordner. Ein Harness liest ohnehin nur SKILL.md und referenzierte Unterordner, das `.git`
|
|
316
|
+
// stört nicht — wohl aber sähe ein Nutzer, der den Link im Finder öffnet, den vollen Worktree.
|
|
291
317
|
for (const skill of skills)
|
|
292
318
|
muster.add(skill.pfad === '' ? '/**' : `/${skill.pfad}/`);
|
|
293
319
|
await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
|
package/dist/adapter/hermes.js
CHANGED
|
@@ -4,8 +4,9 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
4
4
|
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { Document, isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
7
|
-
import { OrtNichtUnterstuetzt, SkillOrdnerUngueltig } from "../kern/modell.js";
|
|
7
|
+
import { McpAbweichenderEintrag, OrtNichtUnterstuetzt, SkillOrdnerUngueltig, } from "../kern/modell.js";
|
|
8
8
|
import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
|
|
9
|
+
import { tiefGleich } from "./vergleich.js";
|
|
9
10
|
const HERMES_VERZEICHNIS = '.hermes';
|
|
10
11
|
const PROFILE_VERZEICHNIS = 'profiles';
|
|
11
12
|
const CONFIG = 'config.yaml';
|
|
@@ -46,9 +47,39 @@ function trustedProjectDirs(daten) {
|
|
|
46
47
|
return [];
|
|
47
48
|
return dirs.filter((d) => typeof d === 'string');
|
|
48
49
|
}
|
|
50
|
+
/** Liest mcp_servers aus einer geparsten config.yaml (E61). Fehlt der Abschnitt, ist er leer. */
|
|
51
|
+
function mcpServers(daten) {
|
|
52
|
+
if (typeof daten !== 'object' || daten === null)
|
|
53
|
+
return {};
|
|
54
|
+
const mcp = daten['mcp_servers'];
|
|
55
|
+
if (typeof mcp !== 'object' || mcp === null || Array.isArray(mcp))
|
|
56
|
+
return {};
|
|
57
|
+
return mcp;
|
|
58
|
+
}
|
|
59
|
+
/** Der neutrale Konfigblock als reines Objekt, für die config.yaml und für den Vergleich (E61). */
|
|
60
|
+
function mcpKonfigAlsObjekt(konfig) {
|
|
61
|
+
if ('command' in konfig)
|
|
62
|
+
return {
|
|
63
|
+
command: konfig.command,
|
|
64
|
+
...(konfig.args === undefined ? {} : { args: konfig.args }),
|
|
65
|
+
...(konfig.env === undefined ? {} : { env: konfig.env }),
|
|
66
|
+
};
|
|
67
|
+
return { url: konfig.url, ...(konfig.headers === undefined ? {} : { headers: konfig.headers }) };
|
|
68
|
+
}
|
|
49
69
|
export class Hermes {
|
|
50
70
|
name = 'hermes';
|
|
51
71
|
home;
|
|
72
|
+
/**
|
|
73
|
+
* MCP-Server (E61): nur Home und Profil, denn Hermes kennt keine Projektebene für MCPs. Bindung
|
|
74
|
+
* über Arrow-Funktionen auf `this`, damit die Fähigkeit ohne eigene Klasse auskommt.
|
|
75
|
+
*/
|
|
76
|
+
mcp = {
|
|
77
|
+
ebenen: () => ['home', 'profil'],
|
|
78
|
+
einhaengen: async (mcpName, konfig, ort) => this.mcpEinhaengen(ort, mcpName, konfig),
|
|
79
|
+
aushaengen: async (mcpName, ort) => this.mcpAushaengen(ort, mcpName),
|
|
80
|
+
eingehaengte: async (ort) => this.mcpEingehaengte(ort),
|
|
81
|
+
envDatei: (ort) => path.join(this.mcpHermesHome(ort), '.env'),
|
|
82
|
+
};
|
|
52
83
|
/** `home` ist das HOME des Nutzers, das Hermes-Home liegt darunter als `.hermes`. */
|
|
53
84
|
constructor(home) {
|
|
54
85
|
this.home = home;
|
|
@@ -358,6 +389,47 @@ export class Hermes {
|
|
|
358
389
|
await mkdir(hermesHome, { recursive: true });
|
|
359
390
|
await writeFile(pfad, doc.toString());
|
|
360
391
|
}
|
|
392
|
+
/** Home oder Profil als Hermes-Home (E61): mcp kennt nur diese beiden Orte. */
|
|
393
|
+
mcpHermesHome(ort) {
|
|
394
|
+
if (ort.art !== 'home' && ort.art !== 'profil')
|
|
395
|
+
throw new OrtNichtUnterstuetzt(this.name, ort.art);
|
|
396
|
+
return this.hermesHome(ort);
|
|
397
|
+
}
|
|
398
|
+
async mcpEingehaengte(ort) {
|
|
399
|
+
return Object.keys(mcpServers(this.gelesen(this.mcpHermesHome(ort))));
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Trägt `mcp_servers.<name>` ein (E61). Steht dort schon ein Eintrag, der vom Katalogblock
|
|
403
|
+
* abweicht (egal, wer ihn gelegt hat), bricht es ab, ohne stillschweigend zu überschreiben; ein
|
|
404
|
+
* gleicher Eintrag ist idempotent. Kommentare bleiben erhalten.
|
|
405
|
+
*/
|
|
406
|
+
async mcpEinhaengen(ort, mcpName, konfig) {
|
|
407
|
+
const hermesHome = this.mcpHermesHome(ort);
|
|
408
|
+
const pfad = path.join(hermesHome, CONFIG);
|
|
409
|
+
const text = await readFile(pfad, 'utf8').catch(() => undefined);
|
|
410
|
+
const doc = text === undefined ? new Document({}) : parseDocument(text);
|
|
411
|
+
const bestehend = mcpServers(doc.toJS())[mcpName];
|
|
412
|
+
const wert = mcpKonfigAlsObjekt(konfig);
|
|
413
|
+
if (bestehend !== undefined && !tiefGleich(bestehend, wert))
|
|
414
|
+
throw new McpAbweichenderEintrag({ harness: this.name, ort }, mcpName);
|
|
415
|
+
doc.setIn(['mcp_servers', mcpName], wert);
|
|
416
|
+
await mkdir(hermesHome, { recursive: true });
|
|
417
|
+
await writeFile(pfad, doc.toString());
|
|
418
|
+
}
|
|
419
|
+
/** Entfernt genau diesen Eintrag aus mcp_servers (E61). Kommentare bleiben erhalten. */
|
|
420
|
+
async mcpAushaengen(ort, mcpName) {
|
|
421
|
+
const hermesHome = this.mcpHermesHome(ort);
|
|
422
|
+
const pfad = path.join(hermesHome, CONFIG);
|
|
423
|
+
const text = await readFile(pfad, 'utf8').catch(() => undefined);
|
|
424
|
+
if (text === undefined)
|
|
425
|
+
return;
|
|
426
|
+
const doc = parseDocument(text);
|
|
427
|
+
const mcp = doc.get('mcp_servers');
|
|
428
|
+
if (!isMap(mcp) || !mcp.has(mcpName))
|
|
429
|
+
return;
|
|
430
|
+
mcp.delete(mcpName);
|
|
431
|
+
await writeFile(pfad, doc.toString());
|
|
432
|
+
}
|
|
361
433
|
/** Fähigkeit Profile entdecken: Verzeichnisse mit config.yaml, bei jedem Aufruf frisch gelesen (E19). */
|
|
362
434
|
async profile() {
|
|
363
435
|
const basis = path.join(this.home, HERMES_VERZEICHNIS, PROFILE_VERZEICHNIS);
|