hmgr 0.0.4 → 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.
Files changed (41) hide show
  1. package/README.md +10 -0
  2. package/dist/adapter/checkout-pfad.js +10 -2
  3. package/dist/adapter/claude-code.js +251 -23
  4. package/dist/adapter/composer.js +4 -3
  5. package/dist/adapter/datei-text.js +18 -0
  6. package/dist/adapter/dateisystem.js +146 -8
  7. package/dist/adapter/git.js +129 -17
  8. package/dist/adapter/hermes.js +235 -10
  9. package/dist/adapter/marketplace-json.js +2 -1
  10. package/dist/adapter/mcp-katalog.js +124 -0
  11. package/dist/adapter/quelle-text.js +10 -1
  12. package/dist/adapter/skill-md.js +96 -19
  13. package/dist/adapter/terminal.js +1 -1
  14. package/dist/adapter/umgebung.js +43 -0
  15. package/dist/adapter/vergleich.js +20 -0
  16. package/dist/adapter/zustand-json.js +6 -1
  17. package/dist/cli/hilfe.js +227 -26
  18. package/dist/cli/main.js +362 -20
  19. package/dist/gui/beobachter.js +108 -0
  20. package/dist/gui/icons.js +114 -0
  21. package/dist/gui/login-pfad.js +120 -0
  22. package/dist/gui/main.js +823 -0
  23. package/dist/gui/mcp-ansicht.js +57 -0
  24. package/dist/gui/oeffnen.js +33 -0
  25. package/dist/gui/preload.cjs +70 -0
  26. package/dist/gui/renderer/fonts/Inter-LICENSE.txt +92 -0
  27. package/dist/gui/renderer/fonts/InterVariable.woff2 +0 -0
  28. package/dist/gui/renderer/fonts/JetBrainsMono-Medium.woff2 +0 -0
  29. package/dist/gui/renderer/fonts/JetBrainsMono-OFL.txt +93 -0
  30. package/dist/gui/renderer/fonts/JetBrainsMono-Regular.woff2 +0 -0
  31. package/dist/gui/renderer/index.html +581 -0
  32. package/dist/gui/renderer/renderer.js +3227 -0
  33. package/dist/gui/renderer/styles.css +701 -0
  34. package/dist/kern/anwendungsfaelle.js +859 -65
  35. package/dist/kern/modell.js +297 -0
  36. package/dist/kern/quelle-ort.js +14 -1
  37. package/dist/ports/harness.js +16 -0
  38. package/dist/ports/mcp-katalog.js +1 -0
  39. package/dist/ports/umgebung.js +1 -0
  40. package/dist/ports/zustand.js +1 -1
  41. package/package.json +47 -2
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(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,26 +1,42 @@
1
- // Claude Code: Grundverhalten plus Plugins über die claude-CLI (E16, E24, E27).
2
- import { execFile } 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 { promisify } from 'node:util';
6
- 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";
7
8
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
9
+ import { tiefGleich } from "./vergleich.js";
8
10
  const GITIGNORE_ZEILE = '.claude/';
9
- const ausfuehrenRoh = promisify(execFile);
10
- /** Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme. */
11
- export const claudeAusfuehrer = async (args, cwd) => {
12
- try {
13
- const { stdout } = await ausfuehrenRoh('claude', args, {
14
- ...(cwd === undefined ? {} : { cwd }),
15
- maxBuffer: 32 * 1024 * 1024,
16
- });
17
- return { stdout, code: 0 };
18
- }
19
- catch (fehler) {
20
- const f = fehler;
21
- return { stdout: `${f.stdout ?? ''}${f.stderr ?? ''}`, code: typeof f.code === 'number' ? f.code : 1 };
22
- }
23
- };
11
+ /**
12
+ * Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme.
13
+ * stdin ist zu, sonst hängt eine CLI, die auf Eingabe wartet, den ganzen Ablauf; der Timeout
14
+ * fängt alles Übrige (beobachtet: 13 Minuten ohne Rückkehr in der GUI). SIGKILL, weil die
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.
23
+ */
24
+ export const claudeAusfuehrer = (args, cwd) => new Promise((aufloesen) => {
25
+ const kind = spawn('claude', args, {
26
+ ...(cwd === undefined ? {} : { cwd }),
27
+ stdio: ['ignore', 'pipe', 'pipe'],
28
+ timeout: 120_000,
29
+ killSignal: 'SIGKILL',
30
+ });
31
+ let stdout = '';
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.
35
+ kind.stdout.on('data', (d) => (stdout += d));
36
+ kind.stderr.on('data', (d) => (stderr += d));
37
+ kind.on('error', (fehler) => aufloesen({ stdout: fehler.message, code: 1 }));
38
+ kind.on('close', (code) => aufloesen({ stdout: code === 0 ? stdout : `${stdout}${stderr}`, code: code ?? 1 }));
39
+ });
24
40
  export class PluginListeUnbekannteForm extends Error {
25
41
  constructor(text) {
26
42
  super(`claude plugin list --json: ${text}`);
@@ -139,28 +155,240 @@ class ClaudePlugins {
139
155
  .map((eintrag) => pluginSchluessel(eintrag));
140
156
  }
141
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
+ }
142
366
  export class ClaudeCode {
143
367
  name = 'claude-code';
144
368
  home;
145
369
  ausfuehrer;
146
370
  /** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt. */
147
371
  plugins;
372
+ /** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt (E61). */
373
+ mcp;
148
374
  geprueft = false;
149
375
  constructor(home, ausfuehrer = claudeAusfuehrer) {
150
376
  this.home = home;
151
377
  this.ausfuehrer = ausfuehrer;
152
378
  }
153
379
  /**
154
- * Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin-Fähigkeit
155
- * 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.
156
382
  */
157
383
  async vorhanden() {
158
384
  const da = (await stat(path.join(this.home, '.claude')).catch(() => undefined)) !== undefined;
159
385
  if (!this.geprueft) {
160
386
  this.geprueft = true;
161
387
  const { code } = await this.ausfuehrer(['--version']).catch(() => ({ code: 1 }));
162
- if (code === 0)
388
+ if (code === 0) {
163
389
  this.plugins = new ClaudePlugins(this, this.ausfuehrer);
390
+ this.mcp = new ClaudeMcp(this, this.ausfuehrer, this.home);
391
+ }
164
392
  }
165
393
  return da;
166
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
+ }
@@ -1,16 +1,59 @@
1
1
  // Ziele im Dateisystem: Links legen, lesen und entfernen. Fremde Einträge bleiben unangetastet (E12).
2
+ import { createHash } from 'node:crypto';
2
3
  import { lstat, mkdir, readFile, readdir, readlink, rm, stat, symlink, unlink } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
- import { NameBelegt } from "../kern/modell.js";
5
+ import { LinkAnlegenFehlgeschlagen, NameBelegt, } from "../kern/modell.js";
5
6
  import { checkoutZerlegen, quelleZerlegen } from "./checkout-pfad.js";
7
+ import { ohneBom } from "./datei-text.js";
6
8
  import { LOKAL_REF } from "./git.js";
7
- import { SKILL_DATEI, skillName } from "./skill-md.js";
9
+ import { SKILL_DATEI, skillAusPfad } from "./skill-md.js";
10
+ /** Dateien eines Ordners rekursiv, `.git` und `node_modules` ausgenommen, Symlinks nicht gefolgt. */
11
+ async function ordnerDateien(wurzel, unter, ergebnis) {
12
+ const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
13
+ for (const eintrag of eintraege) {
14
+ if (eintrag.name === '.git' || eintrag.name === 'node_modules')
15
+ continue;
16
+ const rel = unter ? `${unter}/${eintrag.name}` : eintrag.name;
17
+ if (eintrag.isDirectory())
18
+ await ordnerDateien(wurzel, rel, ergebnis);
19
+ else if (eintrag.isFile())
20
+ ergebnis.push({ relativerPfad: rel, inhalt: await readFile(path.join(wurzel, rel)) });
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
+ }
8
48
  export class DateisystemZiele {
9
49
  wurzel;
10
50
  zustand;
11
- constructor(wurzel, zustand) {
51
+ /** $XDG_STATE_HOME, falls gesetzt und nicht leer, für den vorrangigen Lockfile-Ort (E50). */
52
+ xdgStateHome;
53
+ constructor(wurzel, zustand, xdgStateHome) {
12
54
  this.wurzel = wurzel;
13
55
  this.zustand = zustand;
56
+ this.xdgStateHome = xdgStateHome;
14
57
  }
15
58
  async quellen() {
16
59
  return (await this.zustand.lesen()).quellen;
@@ -18,12 +61,14 @@ export class DateisystemZiele {
18
61
  /**
19
62
  * Der Skill hinter einem Linkziel. Der Name kommt aus der SKILL.md am Linkziel, nicht aus dem
20
63
  * Namen des Eintrags im Ziel: derselbe Skill soll überall gleich heißen, auch wenn ein Eintrag
21
- * von Hand umbenannt wurde (E32).
64
+ * von Hand umbenannt wurde (E32). Über `skillAusPfad` kommt zugleich die Beschreibung (E39) und
65
+ * die übrigen Frontmatter-Felder (E55) mit, die die Ziel-Tabellen der GUI zeigen (E60) — vorher
66
+ * fehlten sie hier, weil nur der Name gelesen wurde. Ein Aufruf, ein Lesen, ein Parsen des
67
+ * Frontmatters: bei vielen Links summiert sich jedes zweite Parsen derselben Datei.
22
68
  */
23
69
  async skill(zielPfad, skillPfad, ersatz) {
24
- const verzeichnisname = skillPfad.split('/').filter((t) => t.length > 0).at(-1) ?? ersatz;
25
70
  const inhalt = await readFile(path.join(zielPfad, SKILL_DATEI), 'utf8').catch(() => undefined);
26
- return { name: skillName(verzeichnisname, inhalt), pfad: skillPfad };
71
+ return skillAusPfad(skillPfad, inhalt, ersatz);
27
72
  }
28
73
  /** Liest aus dem Linkziel Quelle, Ref und Skill zurück. Undefined heißt: kein eigener Link. */
29
74
  async deuten(zielPfad) {
@@ -65,7 +110,9 @@ export class DateisystemZiele {
65
110
  const info = await lstat(pfad).catch(() => undefined);
66
111
  if (!info || !info.isSymbolicLink())
67
112
  return undefined;
68
- 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)));
69
116
  }
70
117
  /**
71
118
  * Ein Skill ist ein Verzeichnis. Dateien wie .DS_Store zählen nicht als fremder Eintrag,
@@ -101,6 +148,10 @@ export class DateisystemZiele {
101
148
  return ergebnis;
102
149
  }
103
150
  async linkLegen(ziel, verzeichnis, name, checkout, skill, ersetzen) {
151
+ // Ein leerer Name oder einer mit Pfadtrenner ergäbe keinen Eintrag im Ziel, sondern einen Pfad:
152
+ // `path.join` fiele auf das Zielverzeichnis selbst zurück, und `ersetzen` löschte es (E12, E32).
153
+ if (name.length === 0 || name.includes('/') || name.includes(path.sep))
154
+ throw new Error(`Ungültiger Name für einen Link: „${name}"`);
104
155
  await mkdir(verzeichnis, { recursive: true });
105
156
  const pfad = path.join(verzeichnis, name);
106
157
  const info = await lstat(pfad).catch(() => undefined);
@@ -114,7 +165,17 @@ export class DateisystemZiele {
114
165
  else
115
166
  throw new NameBelegt(ziel, name);
116
167
  }
117
- 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
+ }
118
179
  }
119
180
  async linkEntfernen(_ziel, verzeichnis, name) {
120
181
  const zielPfad = await this.linkZiel(verzeichnis, name);
@@ -123,4 +184,81 @@ export class DateisystemZiele {
123
184
  if (await this.deuten(zielPfad))
124
185
  await unlink(path.join(verzeichnis, name));
125
186
  }
187
+ /**
188
+ * Löscht einen fremden Eintrag von der Platte (E46): ein Symlink wird als Link entfernt, ohne
189
+ * ihm zu folgen, die Arbeitskopie dahinter bleibt unangetastet; ein Verzeichnis wird rekursiv
190
+ * gelöscht. Ob der Eintrag tatsächlich fremd ist, hat der Kern schon geprüft.
191
+ */
192
+ async eintragLoeschen(_ziel, verzeichnis, name) {
193
+ const pfad = path.join(verzeichnis, name);
194
+ const info = await lstat(pfad).catch(() => undefined);
195
+ if (info === undefined)
196
+ return;
197
+ if (info.isSymbolicLink())
198
+ await unlink(pfad);
199
+ else
200
+ await rm(pfad, { recursive: true, force: true });
201
+ }
202
+ /**
203
+ * Lockfile-Kandidaten in Suchreihenfolge (E50): zuerst $XDG_STATE_HOME/skills/.skill-lock.json,
204
+ * nur wenn die Variable gesetzt und nicht leer ist, sonst wie bisher neben `skillsVerzeichnis`.
205
+ */
206
+ lockPfadKandidaten(skillsVerzeichnis) {
207
+ const kandidaten = [];
208
+ if (this.xdgStateHome !== undefined && this.xdgStateHome.length > 0)
209
+ kandidaten.push(path.join(this.xdgStateHome, 'skills', '.skill-lock.json'));
210
+ kandidaten.push(path.join(path.dirname(skillsVerzeichnis), '.skill-lock.json'));
211
+ return kandidaten;
212
+ }
213
+ /** Eintrag zu `name` aus dem Lockfile-Inhalt. Kaputtes JSON oder fehlende Felder: undefined. */
214
+ lockEintragAusInhalt(roh, name) {
215
+ try {
216
+ const geparst = JSON.parse(ohneBom(roh));
217
+ const eintrag = geparst.skills?.[name];
218
+ if (eintrag === undefined)
219
+ return undefined;
220
+ const sourceUrl = eintrag.sourceUrl;
221
+ const skillFolderHash = eintrag.skillFolderHash;
222
+ if (typeof sourceUrl !== 'string' || typeof skillFolderHash !== 'string')
223
+ return undefined;
224
+ const ref = eintrag.ref;
225
+ const skillPath = eintrag.skillPath;
226
+ return {
227
+ sourceUrl,
228
+ skillFolderHash,
229
+ ...(typeof ref === 'string' ? { ref } : {}),
230
+ ...(typeof skillPath === 'string' ? { skillPath } : {}),
231
+ };
232
+ }
233
+ catch {
234
+ return undefined;
235
+ }
236
+ }
237
+ /**
238
+ * Lockfile der skills-CLI (E48 b, E50, HARNESS-FAKTEN). Fehlt die Datei am ersten Kandidaten,
239
+ * wird der nächste versucht; ist sie da, aber kein gültiges JSON oder trägt der Eintrag nicht die
240
+ * nötigen Felder, gibt es undefined zurück statt zu werfen: ein kaputtes Lockfile bricht die
241
+ * Erhebung nicht ab.
242
+ */
243
+ async skillLockEintrag(skillsVerzeichnis, name) {
244
+ for (const lockPfad of this.lockPfadKandidaten(skillsVerzeichnis)) {
245
+ const roh = await readFile(lockPfad, 'utf8').catch(() => undefined);
246
+ if (roh === undefined)
247
+ continue;
248
+ return this.lockEintragAusInhalt(roh, name);
249
+ }
250
+ return undefined;
251
+ }
252
+ /** SHA-256 über relativen Pfad und Inhalt aller Dateien, wie `computeSkillFolderHash` (E48 b). */
253
+ async ordnerHash(verzeichnis, name) {
254
+ const dateien = [];
255
+ await ordnerDateien(path.join(verzeichnis, name), '', dateien);
256
+ dateien.sort((a, b) => a.relativerPfad.localeCompare(b.relativerPfad));
257
+ const hash = createHash('sha256');
258
+ for (const datei of dateien) {
259
+ hash.update(datei.relativerPfad);
260
+ hash.update(datei.inhalt);
261
+ }
262
+ return hash.digest('hex');
263
+ }
126
264
  }