hmgr 0.0.5 → 0.0.7

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 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
@@ -0,0 +1,87 @@
1
+ // Liest eine Bundle-Definition aus einer Quelle (E69). hmgr schreibt solche Dateien nie.
2
+ import { parseDocument } from 'yaml';
3
+ import { BundleDateiFehler } from "../kern/modell.js";
4
+ import { quelleAusText } from "./quelle-text.js";
5
+ /** Konvention für Bundle-Definitionen in einer Quelle (E69): `bundles/<name>.yaml`. */
6
+ export const BUNDLE_VERZEICHNIS = 'bundles';
7
+ /**
8
+ * Ob ein Pfad in der Quelle eine Bundle-Definition ist: eine Datei direkt unter `bundles/`, mit
9
+ * Endung `.yaml` oder `.yml`. Nichts Verschachteltes — die Konvention nennt genau eine Ebene, und
10
+ * ein Unterordner wäre eine stille zweite Bedeutung.
11
+ */
12
+ export function istBundleDatei(pfad) {
13
+ return /^bundles\/[^/]+\.(?:yaml|yml)$/.test(pfad);
14
+ }
15
+ /** Der Dateiname ohne Verzeichnis und Endung: Ersatzname, wenn das Feld `name` fehlt. */
16
+ export function bundleDateiName(pfad) {
17
+ return pfad.replace(/^bundles\//, '').replace(/\.(?:yaml|yml)$/, '');
18
+ }
19
+ function fehlertext(fehler) {
20
+ return fehler instanceof Error ? fehler.message : String(fehler);
21
+ }
22
+ function istObjekt(wert) {
23
+ return typeof wert === 'object' && wert !== null && !Array.isArray(wert);
24
+ }
25
+ /** Erlaubte Felder auf oberster Ebene und je Mitglied. Alles andere ist ein Fehler mit Feldnamen. */
26
+ const FELDER = new Set(['name', 'description', 'skills']);
27
+ const MITGLIED_FELDER = new Set(['source', 'path']);
28
+ function mitgliedLesen(datei, roh, i) {
29
+ if (!istObjekt(roh))
30
+ throw new BundleDateiFehler(datei, `skills[${i}] muss ein Objekt mit source und path sein`);
31
+ for (const feld of Object.keys(roh))
32
+ if (!MITGLIED_FELDER.has(feld))
33
+ throw new BundleDateiFehler(datei, `skills[${i}]: unbekanntes Feld „${feld}"`);
34
+ const source = roh['source'];
35
+ if (typeof source !== 'string' || source.trim().length === 0)
36
+ throw new BundleDateiFehler(datei, `skills[${i}].source muss die Adresse einer Quelle sein (URL oder Pfad)`);
37
+ const pfad = roh['path'];
38
+ if (typeof pfad !== 'string' || pfad.trim().length === 0)
39
+ throw new BundleDateiFehler(datei, `skills[${i}].path muss der Pfad des Skills in der Quelle sein`);
40
+ // Führende und abschließende Schrägstriche fallen weg: `skills/x/` und `skills/x` meinen denselben
41
+ // Ordner, und der Katalog führt ihn ohne (E11). Ein Wurzel-Skill (leerer Pfad) ist nicht
42
+ // adressierbar — er hieße in der Definition „", das ist kein Text und fliegt oben schon auf.
43
+ return { quelle: quelleAusText(source.trim()), pfad: pfad.trim().replace(/^\/+/, '').replace(/\/+$/, '') };
44
+ }
45
+ /**
46
+ * Eine Bundle-Definition aus dem YAML-Text lesen und validieren (E69). `datei` ist der Pfad in der
47
+ * Quelle, er dient als Ersatzname (ohne Verzeichnis und Endung), wenn das Feld `name` fehlt: der
48
+ * Dateiname ist Konvention, das Feld ist die Aussage des Autors — dieselbe Rangfolge wie beim
49
+ * Skill-Namen (E32). `quelle` ist die Quelle, in der die Datei liegt.
50
+ */
51
+ export function bundleLesen(text, datei, quelle) {
52
+ let daten;
53
+ try {
54
+ const dokument = parseDocument(text, { strict: true });
55
+ // `parseDocument` sammelt Syntaxfehler, statt zu werfen: ohne diese Prüfung käme eine kaputte
56
+ // Datei als halb gelesenes Objekt durch und der Nutzer bekäme eine Meldung über ein Feld statt
57
+ // über die Datei.
58
+ const erster = dokument.errors[0];
59
+ if (erster !== undefined)
60
+ throw new BundleDateiFehler(datei, `kaputtes YAML: ${erster.message}`);
61
+ daten = dokument.toJS();
62
+ }
63
+ catch (fehler) {
64
+ if (fehler instanceof BundleDateiFehler)
65
+ throw fehler;
66
+ throw new BundleDateiFehler(datei, `kaputtes YAML: ${fehlertext(fehler)}`);
67
+ }
68
+ if (!istObjekt(daten))
69
+ throw new BundleDateiFehler(datei, 'die Datei muss ein Objekt mit name, description und skills sein');
70
+ for (const feld of Object.keys(daten))
71
+ if (!FELDER.has(feld))
72
+ throw new BundleDateiFehler(datei, `unbekanntes Feld „${feld}"`);
73
+ const rohName = daten['name'];
74
+ if (rohName !== undefined && (typeof rohName !== 'string' || rohName.trim().length === 0))
75
+ throw new BundleDateiFehler(datei, 'name muss nicht leerer Text sein');
76
+ const name = typeof rohName === 'string' ? rohName.trim() : bundleDateiName(datei);
77
+ const beschreibung = daten['description'];
78
+ if (typeof beschreibung !== 'string' || beschreibung.trim().length === 0)
79
+ throw new BundleDateiFehler(datei, 'description muss nicht leerer Text sein');
80
+ const skills = daten['skills'];
81
+ if (!Array.isArray(skills))
82
+ throw new BundleDateiFehler(datei, 'skills muss eine Liste sein');
83
+ if (skills.length === 0)
84
+ throw new BundleDateiFehler(datei, 'skills darf nicht leer sein');
85
+ const mitglieder = skills.map((roh, i) => mitgliedLesen(datei, roh, i));
86
+ return { name, beschreibung: beschreibung.trim(), mitglieder, quelle, datei };
87
+ }
@@ -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(url.trim()).split(path.sep));
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 { spawn } from 'node:child_process';
3
- import { mkdir, stat } from 'node:fs/promises';
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-Fähigkeit
159
- * kommt dazu, sobald `claude --version` durchläuft: ohne CLI gibt es keine Plugins.
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
  }
@@ -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
- return path.resolve(verzeichnis, await readlink(pfad));
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
- await symlink(path.join(checkout.pfad, skill.pfad), pfad);
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;