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.
@@ -2,6 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { readFile, readdir, mkdir, realpath, rm, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import { bundleLesen, istBundleDatei } from "./bundle-yaml.js";
5
6
  import { marketplaceLesen } from "./marketplace-json.js";
6
7
  import { SKILL_DATEI, skillAusPfad, skillName } from "./skill-md.js";
7
8
  import { BASIS_NAME, basisPfad, quelleZerlegen, refDekodieren, worktreePfad } from "./checkout-pfad.js";
@@ -28,13 +29,17 @@ async function existiert(pfad) {
28
29
  }
29
30
  /**
30
31
  * 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).
32
+ * Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32). Eine
33
+ * SKILL.md direkt an der Wurzel (`teile.length === 1`) ergibt den Pfad `''`: das Repo ist selbst
34
+ * ein Skill (E11-Nachtrag 2026-09-05). `''` ist die einzige interne Schreibweise dafür, nie `.` —
35
+ * `checkoutZerlegen`, `skillAusPfad` und die Sparse-Checkout-Muster (unten) sind darauf abgestimmt.
36
+ * Wurzel-Skill und `skills/`-Unterordner schließen sich nicht aus, beide landen in der Liste.
32
37
  */
33
38
  export function skillPfadeAusDateien(dateien) {
34
39
  const verzeichnisse = new Set();
35
40
  for (const datei of dateien) {
36
41
  const teile = datei.split('/');
37
- if (teile.at(-1) !== SKILL_DATEI || teile.length < 2)
42
+ if (teile.at(-1) !== SKILL_DATEI)
38
43
  continue;
39
44
  verzeichnisse.add(teile.slice(0, -1).join('/'));
40
45
  }
@@ -94,6 +99,14 @@ async function inhalteAufPlatte(wurzel, pfade) {
94
99
  }
95
100
  return ergebnis;
96
101
  }
102
+ /**
103
+ * Ersatzname für einen Skill, dessen Pfad kein Segment hergibt (Wurzel-Skill, E11-Nachtrag): der
104
+ * Repo-Name der Quelle. Ohne ihn hieße der Skill „" und ein Link auf ihn zeigte nicht auf einen
105
+ * Eintrag im Ziel, sondern auf das Zielverzeichnis (E32).
106
+ */
107
+ function ersatznameDerQuelle(quelle) {
108
+ return quelle.art === 'lokal' ? path.basename(quelle.pfad) : quelleZerlegen(quelle).repo;
109
+ }
97
110
  async function dateienAufPlatte(wurzel, unter = '') {
98
111
  const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
99
112
  const dateien = [];
@@ -180,24 +193,32 @@ export class GitQuellen {
180
193
  async skillMdInhalte(basis, ref, pfade) {
181
194
  if (pfade.length === 0)
182
195
  return new Map();
183
- const eingabe = `${pfade.map((p) => `${ref}:${p}/${SKILL_DATEI}`).join('\n')}\n`;
196
+ // Ein Wurzel-Skill hat den Pfad `''`: `${ref}:${p}/${SKILL_DATEI}` ergäbe `ref:/SKILL.md` mit
197
+ // führendem Schrägstrich, den git als eigenen (ungültigen) Pfad läse statt als Wurzel (E39).
198
+ const eingabe = `${pfade.map((p) => `${ref}:${p.length === 0 ? SKILL_DATEI : `${p}/${SKILL_DATEI}`}`).join('\n')}\n`;
184
199
  const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
185
200
  if (ausgabe === undefined)
186
201
  return new Map();
187
202
  return batchInhalte(ausgabe, pfade);
188
203
  }
189
- /** Skills der Quelle, Namen aus dem Frontmatter der SKILL.md (E32). */
204
+ /**
205
+ * Skills der Quelle, Namen aus dem Frontmatter der SKILL.md (E32). Ein Wurzel-Skill (Pfad `''`,
206
+ * E11-Nachtrag) hat keinen Verzeichnisnamen, aus dem `skillAusPfad` einen Namen ableiten könnte:
207
+ * der Repo-Name ist der Ersatzname, nie der leere String, sonst zeigte ein Link ohne Frontmatter
208
+ * `name` auf einen Eintrag mit leerem Namen im Ziel (E32).
209
+ */
190
210
  async skills(quelle, ref) {
211
+ const ersatzname = ersatznameDerQuelle(quelle);
191
212
  if (quelle.art === 'lokal') {
192
213
  const pfade = skillPfadeAusDateien(await dateienAufPlatte(quelle.pfad));
193
214
  const inhalte = await inhalteAufPlatte(quelle.pfad, pfade);
194
- return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
215
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
195
216
  }
196
217
  const basis = await this.basis(quelle);
197
218
  const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
198
219
  const pfade = skillPfadeAusDateien(ausgabe.split('\n').filter((z) => z.length > 0));
199
220
  const inhalte = await this.skillMdInhalte(basis, ref, pfade);
200
- return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
221
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
201
222
  }
202
223
  /**
203
224
  * Marketplace einer Quelle (E24). Der Inhalt kommt aus dem Git-Baum, nicht aus einem Checkout:
@@ -227,10 +248,61 @@ export class GitQuellen {
227
248
  plugins: gelesen.plugins.map((p) => ({
228
249
  name: p.name,
229
250
  dependencies: p.dependencies,
230
- skills: p.skills.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad))),
251
+ // Auch hier kann ein Pfad leer sein (`"source": "."` mit `"skills": ["."]`): derselbe
252
+ // Ersatzname wie im Katalog, damit nie ein Skill ohne Namen entsteht (E32).
253
+ skills: p.skills.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatznameDerQuelle(quelle))),
231
254
  })),
232
255
  };
233
256
  }
257
+ /**
258
+ * Inhalte beliebiger Dateien eines Refs in einem Aufruf, dasselbe Bündel-Muster wie
259
+ * `skillMdInhalte`: `git cat-file --batch` holt beim Partial Clone die fehlenden Blobs gebündelt.
260
+ * Was nicht lesbar ist, fehlt in der Karte.
261
+ */
262
+ async dateiInhalte(basis, ref, pfade) {
263
+ if (pfade.length === 0)
264
+ return new Map();
265
+ const eingabe = `${pfade.map((p) => `${ref}:${p}`).join('\n')}\n`;
266
+ const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
267
+ if (ausgabe === undefined)
268
+ return new Map();
269
+ return batchInhalte(ausgabe, pfade);
270
+ }
271
+ /**
272
+ * Bundle-Definitionen der Quelle (E69): jede Datei unter `bundles/`, gelesen wie der Marketplace
273
+ * über den Git-Baum statt über einen Checkout. Eine kaputte Datei ergibt einen Fehler-Eintrag,
274
+ * keinen Abbruch: die übrigen Bundles derselben Quelle bleiben nutzbar.
275
+ */
276
+ async bundles(quelle, ref) {
277
+ let dateien;
278
+ let inhalte;
279
+ if (quelle.art === 'lokal') {
280
+ dateien = (await dateienAufPlatte(quelle.pfad)).filter(istBundleDatei).sort();
281
+ inhalte = new Map();
282
+ for (const datei of dateien) {
283
+ const roh = await readFile(path.join(quelle.pfad, datei), 'utf8').catch(() => undefined);
284
+ if (roh !== undefined)
285
+ inhalte.set(datei, roh);
286
+ }
287
+ }
288
+ else {
289
+ const basis = await this.basis(quelle);
290
+ const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
291
+ dateien = ausgabe.split('\n').filter(istBundleDatei).sort();
292
+ inhalte = await this.dateiInhalte(basis, ref, dateien);
293
+ }
294
+ return dateien.map((datei) => {
295
+ const inhalt = inhalte.get(datei);
296
+ if (inhalt === undefined)
297
+ return { art: 'fehler', datei, fehler: 'Datei nicht lesbar' };
298
+ try {
299
+ return { art: 'bundle', datei, bundle: bundleLesen(inhalt, datei, quelle) };
300
+ }
301
+ catch (fehler) {
302
+ return { art: 'fehler', datei, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
303
+ }
304
+ });
305
+ }
234
306
  async refs(quelle) {
235
307
  if (quelle.art === 'lokal')
236
308
  return [LOKAL_REF];
@@ -287,7 +359,11 @@ export class GitQuellen {
287
359
  if (!WURZEL_MUSTER.includes(eintrag))
288
360
  muster.add(eintrag);
289
361
  // 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.
362
+ // rekursiv auf, ein Muster `//` wäre kein gültiges Sparse-Checkout-Muster. Der Link, den
363
+ // `linkLegen` darauf setzt, zeigt dann auf die Worktree-Wurzel selbst (E11-Nachtrag 2026-09-05):
364
+ // die ganze Arbeitskopie samt `.git` wird sichtbar, nicht nur die SKILL.md und ihre
365
+ // Unterordner. Ein Harness liest ohnehin nur SKILL.md und referenzierte Unterordner, das `.git`
366
+ // stört nicht — wohl aber sähe ein Nutzer, der den Link im Finder öffnet, den vollen Worktree.
291
367
  for (const skill of skills)
292
368
  muster.add(skill.pfad === '' ? '/**' : `/${skill.pfad}/`);
293
369
  await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
@@ -348,6 +424,104 @@ export class GitQuellen {
348
424
  await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
349
425
  return { ergebnis: 'aktualisiert', von, nach };
350
426
  }
427
+ /**
428
+ * Geänderte Pfade zwischen zwei Commits (E70). `--no-renames` aus zwei Gründen: eine Umbenennung
429
+ * soll hier als Änderung an beiden Pfaden zählen, und die Ähnlichkeitsprüfung bräuchte die Blobs,
430
+ * die ein Partial Clone (`--filter=blob:none`) erst aus dem Netz holen müsste. Eine lokale Quelle
431
+ * hat keinen Checkout und damit keine zwei Commits (B11), sie kommt hier nie an.
432
+ */
433
+ async geaenderteDateien(quelle, von, nach) {
434
+ if (quelle.art === 'lokal')
435
+ return [];
436
+ try {
437
+ const basis = await this.basis(quelle);
438
+ const ausgabe = await this.git(['diff', '--name-only', '--no-renames', von, nach], basis);
439
+ return ausgabe.split('\n').filter((z) => z.length > 0);
440
+ }
441
+ catch {
442
+ return [];
443
+ }
444
+ }
445
+ /**
446
+ * Skills, die in diesem Checkout auf der Platte liegen (E70): dieselbe Regel wie bei einer
447
+ * lokalen Quelle, nur auf das Worktree-Verzeichnis angewandt. Der Sparse-Checkout hat dort nur
448
+ * die gewählten Skills materialisiert (E22), also nennt der Update-Bericht auch nur diese.
449
+ */
450
+ async ausgecheckteSkills(checkout) {
451
+ const pfade = skillPfadeAusDateien(await dateienAufPlatte(checkout.pfad));
452
+ const inhalte = await inhalteAufPlatte(checkout.pfad, pfade);
453
+ const ersatzname = ersatznameDerQuelle(checkout.quelle);
454
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
455
+ }
456
+ /**
457
+ * Ein `git log --name-only` je Quelle statt eines Aufrufs je Skill (E70). Die Ausgabe wird
458
+ * gelesen, während sie entsteht, und der Lauf abgebrochen, sobald jeder gesuchte Pfad sein Datum
459
+ * hat: der jüngste Commit steht vorn, alles Ältere ist unnötig. Gemessen an einem Repo mit 20
460
+ * Skills und 401 Commits: 6 ms gegen 110 ms für 20 einzelne `git log -1`.
461
+ *
462
+ * Format: `%x00%cI` stellt jeder Datumszeile ein NUL voran, das in keinem Dateipfad vorkommt —
463
+ * daran trennen sich Kopf und Dateiliste. `--no-renames` hält die Ähnlichkeitsprüfung heraus, die
464
+ * beim Partial Clone Blobs nachladen würde. Scheitert der Aufruf (kein Repo, unbekannter Ref),
465
+ * bleibt die Karte leer, das ist kein Fehler.
466
+ */
467
+ async logDaten(cwd, ref, pfade) {
468
+ const daten = new Map();
469
+ const offen = new Set(pfade);
470
+ if (offen.size === 0)
471
+ return daten;
472
+ return await new Promise((erfuellen) => {
473
+ const kind = spawn('git', ['log', '--format=%x00%cI', '--name-only', '--no-renames', ref], {
474
+ cwd,
475
+ stdio: ['ignore', 'pipe', 'ignore'],
476
+ timeout: 300_000,
477
+ killSignal: 'SIGKILL',
478
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
479
+ });
480
+ let rest = '';
481
+ let datum;
482
+ const fertig = () => erfuellen(daten);
483
+ kind.on('error', fertig);
484
+ kind.on('close', fertig);
485
+ // Nach dem Abbruch schreibt git in eine Pipe, die niemand mehr liest: der Fehler darf den
486
+ // Prozess nicht mitnehmen, das Ergebnis steht da schon fest.
487
+ kind.stdout.on('error', () => { });
488
+ kind.stdout.on('data', (teil) => {
489
+ rest += teil.toString('utf8');
490
+ const zeilen = rest.split('\n');
491
+ rest = zeilen.pop() ?? '';
492
+ for (const zeile of zeilen) {
493
+ if (zeile.startsWith('\0')) {
494
+ datum = zeile.slice(1);
495
+ continue;
496
+ }
497
+ if (zeile.length === 0 || datum === undefined)
498
+ continue;
499
+ for (const pfad of [...offen])
500
+ // Ein Wurzel-Skill (Pfad `''`, E11-Nachtrag) ist das ganze Repo: jede Datei zählt.
501
+ if (pfad === '' || zeile === pfad || zeile.startsWith(`${pfad}/`)) {
502
+ daten.set(pfad, datum);
503
+ offen.delete(pfad);
504
+ }
505
+ }
506
+ if (offen.size === 0)
507
+ kind.kill('SIGTERM');
508
+ });
509
+ });
510
+ }
511
+ /**
512
+ * Änderungsdatum je Skill (E70, F70=O1). Eine lokale Quelle hat keinen Basis-Clone, wohl aber
513
+ * meist eine Arbeitskopie (B11): dort zählt der HEAD-Stand auf der Platte, wie überall sonst bei
514
+ * lokalen Quellen. Ist der Pfad kein Git-Repo, bleibt die Karte leer und der Katalog nennt für
515
+ * diese Quelle kein Datum.
516
+ */
517
+ async letzteAenderungen(quelle, ref, pfade) {
518
+ if (quelle.art === 'lokal')
519
+ return await this.logDaten(quelle.pfad, 'HEAD', pfade);
520
+ const basis = await this.basis(quelle).catch(() => undefined);
521
+ if (basis === undefined)
522
+ return new Map();
523
+ return await this.logDaten(basis, ref, pfade);
524
+ }
351
525
  /**
352
526
  * Checkout von der Platte nehmen. Ein Worktree steht im Basis-Clone eingetragen, ein blankes
353
527
  * Löschen des Verzeichnisses ließe den Eintrag stehen und ein späterer `worktree add` auf
@@ -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);
@@ -1,5 +1,6 @@
1
1
  // .claude-plugin/marketplace.json lesen (E24, HARNESS-FAKTEN „Plugins und Marketplaces").
2
2
  // Nur die beiden dokumentierten Formen, alles andere wird abgelehnt statt geraten.
3
+ import { ohneBom } from "./datei-text.js";
3
4
  /** Standardordner für die Skills eines Plugins, relativ zu seiner Quelle. */
4
5
  const SKILL_ORDNER = 'skills';
5
6
  export class MarketplaceUnbekannteForm extends Error {
@@ -80,7 +81,7 @@ function pluginLesen(roh, dateien) {
80
81
  export function marketplaceLesen(text, dateien) {
81
82
  let daten;
82
83
  try {
83
- daten = JSON.parse(text);
84
+ daten = JSON.parse(ohneBom(text));
84
85
  }
85
86
  catch (fehler) {
86
87
  throw new MarketplaceUnbekannteForm(`kein gültiges JSON (${fehler instanceof Error ? fehler.message : fehler})`);
@@ -0,0 +1,124 @@
1
+ // Liest den persönlichen MCP-Katalog ~/.hmgr/mcp.yaml (E61). hmgr schreibt ihn nie.
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parseDocument } from 'yaml';
4
+ import { McpKatalogFehler } from "../kern/modell.js";
5
+ const STDIO_FELDER = new Set(['command', 'args', 'env']);
6
+ const URL_FELDER = new Set(['url', 'headers']);
7
+ function fehlertext(fehler) {
8
+ return fehler instanceof Error ? fehler.message : String(fehler);
9
+ }
10
+ function istObjekt(wert) {
11
+ return typeof wert === 'object' && wert !== null && !Array.isArray(wert);
12
+ }
13
+ /** Ein optionales Textfeld: fehlt es oder ist es leer, bleibt es weg, kein Fehler. */
14
+ function feldText(pfad, name, o, feld) {
15
+ if (!(feld in o))
16
+ return undefined;
17
+ const wert = o[feld];
18
+ if (typeof wert !== 'string')
19
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": Feld „${feld}" muss Text sein`);
20
+ const getrimmt = wert.trim();
21
+ return getrimmt.length === 0 ? undefined : wert;
22
+ }
23
+ /** Eine Zeichenkette-zu-Zeichenkette-Map wie `env` oder `headers`, oder undefined ohne das Feld. */
24
+ function mapLesen(pfad, name, wert, feld) {
25
+ if (wert === undefined)
26
+ return undefined;
27
+ if (!istObjekt(wert))
28
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.${feld} muss ein Objekt sein`);
29
+ const ergebnis = {};
30
+ for (const [k, v] of Object.entries(wert)) {
31
+ if (typeof v !== 'string')
32
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.${feld}.${k} muss Text sein`);
33
+ ergebnis[k] = v;
34
+ }
35
+ return ergebnis;
36
+ }
37
+ /** Eine Liste von Zeichenketten wie `args`, oder undefined ohne das Feld. */
38
+ function argsLesen(pfad, name, wert) {
39
+ if (wert === undefined)
40
+ return undefined;
41
+ if (!Array.isArray(wert))
42
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.args muss eine Liste sein`);
43
+ return wert.map((eintrag, i) => {
44
+ if (typeof eintrag !== 'string')
45
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.args[${i}] muss Text sein`);
46
+ return eintrag;
47
+ });
48
+ }
49
+ function konfigLesen(pfad, name, k) {
50
+ const hatCommand = 'command' in k;
51
+ const hatUrl = 'url' in k;
52
+ if (hatCommand && hatUrl)
53
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig darf nicht zugleich „command" und „url" haben`);
54
+ if (hatCommand) {
55
+ for (const feld of Object.keys(k))
56
+ if (!STDIO_FELDER.has(feld))
57
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": unbekanntes Feld „${feld}" in konfig`);
58
+ const command = k['command'];
59
+ if (typeof command !== 'string' || command.trim().length === 0)
60
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.command muss nicht leerer Text sein`);
61
+ const args = argsLesen(pfad, name, k['args']);
62
+ const env = mapLesen(pfad, name, k['env'], 'env');
63
+ return { command, ...(args === undefined ? {} : { args }), ...(env === undefined ? {} : { env }) };
64
+ }
65
+ if (hatUrl) {
66
+ for (const feld of Object.keys(k))
67
+ if (!URL_FELDER.has(feld))
68
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": unbekanntes Feld „${feld}" in konfig`);
69
+ const url = k['url'];
70
+ if (typeof url !== 'string' || url.trim().length === 0)
71
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.url muss nicht leerer Text sein`);
72
+ const headers = mapLesen(pfad, name, k['headers'], 'headers');
73
+ return { url, ...(headers === undefined ? {} : { headers }) };
74
+ }
75
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig braucht entweder „command" oder „url"`);
76
+ }
77
+ function eintragLesen(pfad, name, roh) {
78
+ if (!istObjekt(roh))
79
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}" ist kein Objekt`);
80
+ const beschreibung = roh['beschreibung'];
81
+ if (typeof beschreibung !== 'string' || beschreibung.trim().length === 0)
82
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}" braucht eine Beschreibung (Feld „beschreibung")`);
83
+ const homepage = feldText(pfad, name, roh, 'homepage');
84
+ const notizen = feldText(pfad, name, roh, 'notizen');
85
+ const konfigRoh = roh['konfig'];
86
+ if (!istObjekt(konfigRoh))
87
+ throw new McpKatalogFehler(pfad, `Eintrag „${name}" braucht einen Konfigblock (Feld „konfig")`);
88
+ const konfig = konfigLesen(pfad, name, konfigRoh);
89
+ return {
90
+ name,
91
+ beschreibung,
92
+ ...(homepage === undefined ? {} : { homepage }),
93
+ ...(notizen === undefined ? {} : { notizen }),
94
+ konfig,
95
+ };
96
+ }
97
+ export class DateiMcpKatalog {
98
+ pfad;
99
+ constructor(pfad) {
100
+ this.pfad = pfad;
101
+ }
102
+ /** Fehlende Datei: leerer Katalog, kein Fehler. Kaputtes YAML oder falsches Format: Fehler mit Pfad. */
103
+ async lesen() {
104
+ let text;
105
+ try {
106
+ text = await readFile(this.pfad, 'utf8');
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ let daten;
112
+ try {
113
+ daten = parseDocument(text, { strict: true }).toJS();
114
+ }
115
+ catch (fehler) {
116
+ throw new McpKatalogFehler(this.pfad, `kaputtes YAML: ${fehlertext(fehler)}`);
117
+ }
118
+ if (daten === null || daten === undefined)
119
+ return [];
120
+ if (!istObjekt(daten))
121
+ throw new McpKatalogFehler(this.pfad, 'die Datei muss ein Objekt mit einem Eintrag je MCP-Name sein');
122
+ return Object.entries(daten).map(([name, roh]) => eintragLesen(this.pfad, name, roh));
123
+ }
124
+ }
@@ -0,0 +1,25 @@
1
+ // Die Version des Pakets, gelesen aus seiner package.json (E72). Keine Kopie im Code: eine
2
+ // zweite Stelle mit derselben Zahl geht früher oder später auseinander.
3
+ import { readFileSync } from 'node:fs';
4
+ /**
5
+ * Die package.json liegt in jeder Lage zwei Ebenen über diesem Modul: in der Quelle
6
+ * `src/adapter/` → Wurzel, im Build `dist/adapter/` → Wurzel, in der npm-Installation
7
+ * `node_modules/hmgr/dist/adapter/` → `node_modules/hmgr/`, im gepackten App-Bundle
8
+ * `app.asar/dist/adapter/` → `app.asar/`. Aufgelöst über `import.meta.url`, damit weder das
9
+ * Arbeitsverzeichnis noch der Aufrufweg hineinspielt.
10
+ *
11
+ * Bewusst kein Import mit `with { type: 'json' }`: der zöge die package.json beim Bauen nach
12
+ * `dist` und legte damit eine zweite Wahrheit an.
13
+ */
14
+ const PAKET_JSON = new URL('../../package.json', import.meta.url);
15
+ let gemerkt;
16
+ /** Die Versionsnummer aus der package.json des Pakets, etwa „0.0.6". */
17
+ export function paketVersion() {
18
+ if (gemerkt === undefined) {
19
+ const inhalt = JSON.parse(readFileSync(PAKET_JSON, 'utf8'));
20
+ if (typeof inhalt.version !== 'string')
21
+ throw new Error(`Die package.json unter ${PAKET_JSON.pathname} nennt keine Version.`);
22
+ gemerkt = inhalt.version;
23
+ }
24
+ return gemerkt;
25
+ }
@@ -1,8 +1,17 @@
1
1
  import path from 'node:path';
2
+ import { istWindowsLaufwerkspfad } from "../kern/quelle-ort.js";
2
3
  export { quelleAlsText } from "../kern/modell.js";
3
- /** URL heißt Git-Quelle mit Checkout, ein nackter Pfad heißt lokale Arbeitskopie ohne Clone (B11). */
4
+ /**
5
+ * URL heißt Git-Quelle mit Checkout, ein nackter Pfad heißt lokale Arbeitskopie ohne Clone (B11).
6
+ *
7
+ * Ein Windows-Laufwerkspfad ist ein nackter Pfad (E65/B12): `C:\work\quelle` traf sonst die
8
+ * scp-Regel `host:pfad` — Host „C" — und `hmgr source add C:\work\quelle` legte die Arbeitskopie
9
+ * als Git-Quelle mit Remote an statt als lokale. Sichtbar wurde das am Anzeigenamen „\work\quelle".
10
+ */
4
11
  export function quelleAusText(text) {
5
12
  const roh = text.trim();
13
+ if (istWindowsLaufwerkspfad(roh))
14
+ return { art: 'lokal', pfad: path.resolve(roh) };
6
15
  if (roh.includes('://') || /^(?:[^@/\s]+@)?[^@/:\s]+:[^/\s]/.test(roh))
7
16
  return { art: 'git', url: roh };
8
17
  return { art: 'lokal', pfad: path.resolve(roh) };
@@ -1,9 +1,10 @@
1
1
  // Name, Beschreibung und Besonderheiten eines Skills aus dem Frontmatter der SKILL.md (E32, E39, E55).
2
2
  import { parse } from 'yaml';
3
+ import { ohneBom } from "./datei-text.js";
3
4
  export const SKILL_DATEI = 'SKILL.md';
4
5
  /** Der Block zwischen der ersten Zeile `---` und der nächsten Zeile `---`. Fehlt er, undefined. */
5
6
  function frontmatter(inhalt) {
6
- const zeilen = inhalt.replace(/^\uFEFF/, '').split('\n');
7
+ const zeilen = ohneBom(inhalt).split('\n');
7
8
  if (zeilen[0]?.trimEnd() !== '---')
8
9
  return undefined;
9
10
  for (let i = 1; i < zeilen.length; i += 1)
@@ -0,0 +1,43 @@
1
+ // Prozess-Umgebung und .env-Dateien, für die F39-Prüfung beim MCP-Einhängen (E61).
2
+ import { readFile } from 'node:fs/promises';
3
+ export class ProzessUmgebung {
4
+ env;
5
+ constructor(env = process.env) {
6
+ this.env = env;
7
+ }
8
+ variablen() {
9
+ const ergebnis = {};
10
+ for (const [name, wert] of Object.entries(this.env))
11
+ if (wert !== undefined)
12
+ ergebnis[name] = wert;
13
+ return ergebnis;
14
+ }
15
+ /**
16
+ * Liest NAME=wert-Zeilen. Leere Zeilen und solche, die mit „#" beginnen, zählen nicht. Werte in
17
+ * einfachen oder doppelten Anführungszeichen werden davon befreit, sonst unverändert übernommen.
18
+ */
19
+ async envDatei(pfad) {
20
+ let text;
21
+ try {
22
+ text = await readFile(pfad, 'utf8');
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ const ergebnis = {};
28
+ for (const zeile of text.split('\n')) {
29
+ const getrimmt = zeile.trim();
30
+ if (getrimmt.length === 0 || getrimmt.startsWith('#'))
31
+ continue;
32
+ const trenner = getrimmt.indexOf('=');
33
+ if (trenner <= 0)
34
+ continue;
35
+ const name = getrimmt.slice(0, trenner).trim();
36
+ let wert = getrimmt.slice(trenner + 1).trim();
37
+ if (wert.length >= 2 && ((wert.startsWith('"') && wert.endsWith('"')) || (wert.startsWith("'") && wert.endsWith("'"))))
38
+ wert = wert.slice(1, -1);
39
+ ergebnis[name] = wert;
40
+ }
41
+ return ergebnis;
42
+ }
43
+ }
@@ -0,0 +1,20 @@
1
+ // Gemeinsames Modul der Adapter: Vergleich zweier aus YAML oder JSON gelesener Werte.
2
+ /**
3
+ * Tiefer Vergleich, ohne Rücksicht auf die Schlüsselreihenfolge. Gebraucht, wo ein Adapter einen
4
+ * schon vorhandenen MCP-Eintrag gegen den Katalogblock hält (E61): gleicher Block heißt „schon
5
+ * eingehängt", ein abweichender wird nie stillschweigend überschrieben.
6
+ */
7
+ export function tiefGleich(a, b) {
8
+ if (a === b)
9
+ return true;
10
+ if (Array.isArray(a) || Array.isArray(b))
11
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => tiefGleich(v, b[i]));
12
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
13
+ const ak = Object.keys(a).sort();
14
+ const bk = Object.keys(b).sort();
15
+ if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i]))
16
+ return false;
17
+ return ak.every((k) => tiefGleich(a[k], b[k]));
18
+ }
19
+ return false;
20
+ }
@@ -1,7 +1,8 @@
1
1
  // Lokaler Zustand als eine JSON-Datei, Standard ~/.hmgr/state.json (E29).
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { leererZustand } from "../ports/zustand.js";
4
+ import { ZUSTAND_FASSUNG, leererZustand, } from "../ports/zustand.js";
5
+ import { ohneBom } from "./datei-text.js";
5
6
  export class ZustandJson {
6
7
  pfad;
7
8
  constructor(pfad) {
@@ -11,8 +12,11 @@ export class ZustandJson {
11
12
  const roh = await readFile(this.pfad, 'utf8').catch(() => undefined);
12
13
  if (roh === undefined)
13
14
  return leererZustand();
14
- const daten = JSON.parse(roh);
15
+ const daten = JSON.parse(ohneBom(roh));
15
16
  return {
17
+ // Fehlt das Feld (state.json vor E69), ist es die erste Fassung: alles ohne Bundles.
18
+ // Geschrieben wird danach in der aktuellen Fassung, ein Rückweg ist nicht vorgesehen.
19
+ fassung: typeof daten.fassung === 'number' ? daten.fassung : 1,
16
20
  quellen: (daten.quellen ?? []),
17
21
  arbeitsverzeichnisse: (daten.arbeitsverzeichnisse ?? []),
18
22
  bindungen: (daten.bindungen ?? []),
@@ -20,10 +24,14 @@ export class ZustandJson {
20
24
  aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
21
25
  // Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
22
26
  ignorierteEintraege: (daten.ignorierteEintraege ?? []),
27
+ // Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert.
28
+ bundleInstallationen: (daten.bundleInstallationen ?? []),
23
29
  };
24
30
  }
25
31
  async schreiben(zustand) {
26
32
  await mkdir(path.dirname(this.pfad), { recursive: true });
27
- await writeFile(this.pfad, `${JSON.stringify(zustand, null, 2)}\n`);
33
+ // Immer in der aktuellen Fassung, egal welche gelesen wurde: das Lesen hat den Bestand schon
34
+ // auf die aktuelle Form gebracht, ein alter Vermerk in der Datei wäre danach eine Lüge.
35
+ await writeFile(this.pfad, `${JSON.stringify({ ...zustand, fassung: ZUSTAND_FASSUNG }, null, 2)}\n`);
28
36
  }
29
37
  }