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.
@@ -12,16 +12,34 @@
12
12
  return el;
13
13
  }
14
14
 
15
- // Home-Verzeichnis, einmal beim Laden über hmgr:home geholt (siehe ladeHome unten). Nur für die
16
- // Anzeige: fließt nie in einen IPC-Aufruf oder einen zielKey zurück.
15
+ // Home-Verzeichnis und Plattform, einmal beim Laden über hmgr:home und hmgr:plattform geholt
16
+ // (siehe ladeUmgebung unten). Nur für die Anzeige: beides fließt nie in einen IPC-Aufruf oder
17
+ // einen zielKey zurück.
17
18
  var HOME = '';
18
- /** Kürzt einen absoluten Pfad auf ~, wenn er mit dem Home-Verzeichnis beginnt (Grenze am „/"). */
19
+ // Pfadtrenner der Anzeige (E68). Aus der Plattform und nicht aus dem Home-Pfad abgeleitet: die
20
+ // Pfade, die hier angezeigt werden, baut der Kern mit `node:path`, also mit dem Trenner der
21
+ // Plattform — auch dann, wenn HOME selbst in POSIX-Schreibweise gesetzt ist (MSYS/Git-Bash).
22
+ var TRENNER = '/';
23
+ /** Trenner einer Plattform, wie `process.platform` sie nennt. Rein, nur für die Anzeige. */
24
+ function plattformTrenner(plattform) { return plattform === 'win32' ? '\\' : '/'; }
25
+ /**
26
+ * Kürzt einen absoluten Pfad auf ~, wenn er mit dem Home-Verzeichnis beginnt. Grenze ist einer
27
+ * der beiden Trenner (E68): unter Windows steht dort „\", und der Rest des Pfads bleibt wörtlich
28
+ * stehen, die Kürzung heißt dort also von selbst `~\…` statt `~/…`.
29
+ */
19
30
  function pfadKurz(p) {
20
31
  if (!HOME || typeof p !== 'string') return p;
21
32
  if (p === HOME) return '~';
22
- if (p.indexOf(HOME + '/') === 0) return '~' + p.slice(HOME.length);
33
+ var grenze = p.charAt(HOME.length);
34
+ if (p.indexOf(HOME) === 0 && (grenze === '/' || grenze === '\\')) return '~' + p.slice(HOME.length);
23
35
  return p;
24
36
  }
37
+ /**
38
+ * Schreibt einen fest im Quelltext stehenden POSIX-Pfad in der Schreibweise der Plattform (E68),
39
+ * damit `~/.claude.json` unter Windows nicht neben `~\Code\…` steht. Nur für Anzeigetexte, die
40
+ * hmgr selbst formuliert — nie für einen Pfad, der aus dem Kern kommt.
41
+ */
42
+ function pfadSchreibweise(p) { return TRENNER === '/' ? p : p.split('/').join(TRENNER); }
25
43
 
26
44
  function melde(text) {
27
45
  $('meldungtext').textContent = text;
@@ -163,10 +181,13 @@
163
181
  * dasselbe (`quelleAlsText`), aber nur so findet auch die Einstellungen-Liste ihr Icon.
164
182
  */
165
183
  function quelleAdresse(quelle) { return quelle.text != null ? quelle.text : quelleKurz(quelle); }
166
- /** Letztes nicht leeres Segment eines Pfads oder einer URL, ohne abschließende Schrägstriche. */
184
+ /**
185
+ * Letztes nicht leeres Segment eines Pfads oder einer URL, ohne abschließende Schrägstriche.
186
+ * Trenner sind `/` und `\`, wie in `letztesSegment` im Kern (E65/B8, dort führend).
187
+ */
167
188
  function letztesSegment(text) {
168
- var bereinigt = text.replace(/\/+$/, '');
169
- var segmente = bereinigt.split('/').filter(function (s) { return s.length > 0; });
189
+ var bereinigt = text.replace(/[/\\]+$/, '');
190
+ var segmente = bereinigt.split(/[/\\]/).filter(function (s) { return s.length > 0; });
170
191
  return segmente.length > 0 ? segmente[segmente.length - 1] : bereinigt;
171
192
  }
172
193
  /**
@@ -256,7 +277,7 @@
256
277
  /**
257
278
  * Eine Quelle, angeschrieben mit ihrem Namen und anklickbar (E60): keine Unterstreichung, das
258
279
  * kleine Pfeilsymbol dahinter trägt das Signalorange. Klick öffnet über den Main-Prozess die
259
- * Repo-Seite im Browser oder den Ordner im Finder (`window.hmgr.quelleOeffnen`), still im
280
+ * Repo-Seite im Browser oder den Ordner im Dateimanager (`window.hmgr.quelleOeffnen`), still im
260
281
  * Fehlerfall statt in der Meldungsleiste, ein Öffnen-Fehlschlag soll die Ansicht nicht stören.
261
282
  */
262
283
  function quelleLinkElement(quelle) {
@@ -265,7 +286,9 @@
265
286
  if (lokal) el.type = 'button'; else el.href = '#';
266
287
  el.className = 'qlink';
267
288
  el.title = quelleAdresse(quelle);
268
- el.setAttribute('aria-label', quelleAnzeigename(quelle) + ' ' + (lokal ? 'im Finder öffnen' : 'im Browser öffnen'));
289
+ // „Dateimanager" statt „Finder" (E68): die GUI läuft auch unter Windows, wo das Ding Explorer
290
+ // heißt, und unter Linux gibt es weder das eine noch das andere.
291
+ el.setAttribute('aria-label', quelleAnzeigename(quelle) + ' ' + (lokal ? 'im Dateimanager öffnen' : 'im Browser öffnen'));
269
292
  el.appendChild(document.createTextNode(quelleAnzeigename(quelle)));
270
293
  el.appendChild(externSvg());
271
294
  el.addEventListener('click', function (ev) {
@@ -280,10 +303,28 @@
280
303
  var ZIELE = [];
281
304
  var SKILLS = [];
282
305
  var MARKETPLACES = [];
306
+ // Bundles aller bekannten Quellen (E69), entdeckt beim Quellen-Scan: kein eigener Katalog, keine
307
+ // eigene Ansicht. Grundlage der Bundle-Sektion im Dialog „Skills hinzufügen" (F68).
308
+ var BUNDLES = [];
309
+ // Definitionen, die sich nicht lesen ließen: genannt, damit eine kaputte Datei nicht stumm fehlt.
310
+ var BUNDLES_KAPUTT = [];
283
311
  var QUELLEN = [];
284
312
  var HARNESSE_ALLE = [];
285
313
  var AKTIVE_HARNESSE = [];
286
314
  var HARNESSE_NICHT_INSTALLIERT = [];
315
+ // MCP-Katalog (E61-Folgerunde): je Eintrag { eintrag, eingehaengt, fehlendeVariablen }, dazu
316
+ // Einträge in Harness-Konfigurationen, die nicht im Katalog stehen (nur genannt). Seit F46 nur
317
+ // noch Grundlage für den MCP-Abschnitt der Ziel-Karten (mcpZeilenFuerZiel), die MCP-Ansicht selbst
318
+ // zeigt keine Fundorte mehr.
319
+ var MCP_KATALOG = [];
320
+ var MCP_FREMD = [];
321
+ // Nicht-Katalog-Einträge aus geteilten, nur lesbaren Fundorten (E61-Nachtrag), etwa der
322
+ // .mcp.json einer Projektwurzel.
323
+ var MCP_FREMD_GETEILT = [];
324
+ // Alle Orte, an denen grundsätzlich ein MCP-Server hängen könnte (E61-GUI-Nachtrag), roh aus dem
325
+ // Kern (F42, E16): Grundlage für den MCP-Abschnitt und den „MCP hinzufügen …"-Knopf an den
326
+ // Ziel-Karten — nie am Harness-Namen entschieden, sondern an dieser Liste.
327
+ var MCP_PLAETZE = [];
287
328
  var quellenFilter = 'alle';
288
329
  var zielFilter = 'alle';
289
330
  // Standard-Ref je Quelle (E60), aus dem Katalog abgeleitet (SKILLS trägt ihn schon je Skill): ein
@@ -340,9 +381,18 @@
340
381
 
341
382
  /* ---------------- Laden ---------------- */
342
383
 
343
- /** Home-Verzeichnis einmal beim Start holen (nur für pfadKurz, siehe oben). */
344
- async function ladeHome() {
345
- HOME = await rufe(function () { return window.hmgr.home(); });
384
+ /**
385
+ * Home-Verzeichnis und Plattform einmal beim Start holen (nur für die Anzeige, siehe oben).
386
+ * Danach stehen die fest formulierten Pfade im Bild in der Schreibweise der Plattform (E68).
387
+ */
388
+ async function ladeUmgebung() {
389
+ var werte = await Promise.all([
390
+ rufe(function () { return window.hmgr.home(); }),
391
+ rufe(function () { return window.hmgr.plattform(); }),
392
+ ]);
393
+ HOME = werte[0];
394
+ TRENNER = plattformTrenner(werte[1]);
395
+ $('mcpkatalogpfad').textContent = pfadSchreibweise('~/.hmgr/mcp.yaml');
346
396
  }
347
397
 
348
398
  async function ladeAlles() {
@@ -357,6 +407,9 @@
357
407
  rufe(function () { return window.hmgr.harnessTypen(); }),
358
408
  rufe(function () { return window.hmgr.aktiveHarnesses(); }),
359
409
  rufe(function () { return window.hmgr.nichtVorhandeneHarnesses(); }),
410
+ rufe(function () { return window.hmgr.mcpUebersicht(); }),
411
+ rufe(function () { return window.hmgr.mcpPlaetze(); }),
412
+ rufe(function () { return window.hmgr.bundles(); }),
360
413
  ]);
361
414
  ZIELE = ergebnisse[0];
362
415
  SKILLS = ergebnisse[1];
@@ -367,6 +420,13 @@
367
420
  HARNESSE_ALLE = ergebnisse[4];
368
421
  AKTIVE_HARNESSE = ergebnisse[5];
369
422
  HARNESSE_NICHT_INSTALLIERT = ergebnisse[6];
423
+ MCP_KATALOG = ergebnisse[7].katalog;
424
+ MCP_FREMD = ergebnisse[7].unbekannt;
425
+ MCP_FREMD_GETEILT = ergebnisse[7].unbekannteGeteilteFundorte;
426
+ MCP_PLAETZE = ergebnisse[8];
427
+ BUNDLES = ergebnisse[9].bundles;
428
+ BUNDLES.sort(function (a, b) { return a.name.localeCompare(b.name, 'de') || a.quelleText.localeCompare(b.quelleText, 'de'); });
429
+ BUNDLES_KAPUTT = ergebnisse[9].kaputte;
370
430
  STANDARD_REF = {};
371
431
  SKILLS.forEach(function (s) { if (STANDARD_REF[s.quelleText] === undefined) STANDARD_REF[s.quelleText] = s.ref; });
372
432
 
@@ -375,6 +435,7 @@
375
435
  baueQuellenfilter();
376
436
  baueMarketplaces();
377
437
  baueEinstellungen();
438
+ baueMcp();
378
439
  aktualisiereKopf();
379
440
  }
380
441
 
@@ -425,7 +486,7 @@
425
486
 
426
487
  /* ---------------- Navigation ---------------- */
427
488
 
428
- var ANSICHTEN = ['ziele', 'katalog', 'update', 'plugins'];
489
+ var ANSICHTEN = ['ziele', 'katalog', 'update', 'plugins', 'mcp'];
429
490
  function zeigeAnsicht(name) {
430
491
  document.querySelectorAll('.plakette').forEach(function (a) {
431
492
  if (a.dataset.ansicht === name) a.setAttribute('aria-current', 'page');
@@ -600,6 +661,110 @@
600
661
  return kz;
601
662
  }
602
663
 
664
+ /** Defekte Links (ohne Quelle) zuerst, sie brauchen Aufmerksamkeit (E45), sonst alphabetisch. */
665
+ function sortiereEintraege(eintraege) {
666
+ return eintraege.slice().sort(function (a, b) {
667
+ var da = a.art === 'link-ohne-quelle', db = b.art === 'link-ohne-quelle';
668
+ if (da !== db) return da ? -1 : 1;
669
+ var na = a.art === 'link' ? a.link.name : a.name;
670
+ var nb = b.art === 'link' ? b.link.name : b.name;
671
+ return na.localeCompare(nb, 'de');
672
+ });
673
+ }
674
+
675
+ /** Eine Zeile der Link-Tabelle. `bundle` gesetzt heißt: die Zeile steht in einer Bundle-Gruppe
676
+ (E69), das Entfernen zerbricht dort das Bundle und der Dialog sagt es. */
677
+ function linkZeile(ei, z, bundle) {
678
+ var name = ei.art === 'link' ? ei.link.name : ei.name;
679
+ var li = e('li');
680
+
681
+ if (ei.art === 'link') li.appendChild(repoIcon(ei.link.checkout.quelle));
682
+ else li.appendChild(repoIcon({ art: 'lokal', pfad: ei.checkoutPfad }));
683
+
684
+ li.appendChild(e('span', 'name', name));
685
+
686
+ // Die Beschreibung aus der SKILL.md (E39): die Frage, wegen der die Zeile gelesen wird,
687
+ // deshalb die breiteste Spalte. Ein Link ohne bekannte Quelle kennt seinen Skill nicht.
688
+ li.appendChild(wasZelle(ei.art === 'link' ? ei.link.skill : null));
689
+
690
+ // Quelle: der Name, anklickbar, dahinter nur, was vom Normalfall abweicht — ein Ref, das
691
+ // vom Standard-Ref der Quelle abweicht, und der Grund „über Zeiger" (E57). „Manuell" (der
692
+ // Normalfall) entfällt ersatzlos, ein Plugin-Grund steht stattdessen in der Aktionsspalte.
693
+ var qz = e('span', 'quelle');
694
+ if (ei.art === 'link') {
695
+ var einschraenkung = z.einschraenkungen[name];
696
+ if (einschraenkung) qz.appendChild(document.createTextNode(einschraenkung + ' → '));
697
+ qz.appendChild(quelleLinkElement(ei.link.checkout.quelle));
698
+ var standardRef = STANDARD_REF[quelleKurz(ei.link.checkout.quelle)];
699
+ if (ei.link.checkout.ref && standardRef && ei.link.checkout.ref !== standardRef) {
700
+ qz.appendChild(e('span', 'ref', '@' + ei.link.checkout.ref));
701
+ }
702
+ } else {
703
+ qz.appendChild(e('span', 'mono', pfadKurz(ei.checkoutPfad)));
704
+ }
705
+ var grund = z.gruende[name];
706
+ if (grund && grund.art === 'zeiger') qz.appendChild(e('span', 'grundmarke', 'über Zeiger'));
707
+ li.appendChild(qz);
708
+
709
+ // Aus einem Plugin verlinkt: kein Entfernen-Knopf, das geht nur über das Plugin — der
710
+ // Grund erklärt hier zugleich, warum der Knopf fehlt.
711
+ if (grund && grund.art === 'plugin') {
712
+ li.appendChild(e('span', 'kein-weg', 'nur über Plugin „' + grund.plugin.split('@')[0] + '“'));
713
+ } else {
714
+ var weg = e('button', 'knopf leise klein', 'Entfernen');
715
+ weg.type = 'button';
716
+ weg.setAttribute('aria-label', 'Link auf ' + name + ' entfernen');
717
+ weg.addEventListener('click', function () { frageEntfernen(name, z.ziel, bundle ? bundle.name : null); });
718
+ li.appendChild(weg);
719
+ }
720
+ return li;
721
+ }
722
+
723
+ /** Ein installiertes Bundle als Gruppe über seinen Skill-Zeilen (E69, F68): Kopf mit Name, Quelle
724
+ und „Entfernen" für das Ganze, darunter die Zeilen seiner Mitglieder. */
725
+ function bundleGruppe(b, z, eintraege) {
726
+ var gruppe = e('div', 'bundlegruppe');
727
+ var kopf = e('div', 'bundlekopf');
728
+ var titel = e('span', 'bundletitel');
729
+ titel.appendChild(e('span', 'etikett', 'Bundle'));
730
+ titel.appendChild(e('b', 'bundlename', b.name));
731
+ kopf.appendChild(titel);
732
+ var quelleEl = e('span', 'mono bundlequelle', b.quelleName);
733
+ quelleEl.title = b.quelleText;
734
+ kopf.appendChild(quelleEl);
735
+ kopf.appendChild(e('span', 'bundlezahl', zahlwort(b.skills.length, 'Skill', 'Skills')));
736
+ var weg = e('button', 'knopf leise klein', 'Entfernen');
737
+ weg.type = 'button';
738
+ weg.setAttribute('aria-label', 'Bundle ' + b.name + ' aus ' + zielTitel(z) + ' entfernen');
739
+ weg.addEventListener('click', function () { frageBundleEntfernen(b, z.ziel); });
740
+ kopf.appendChild(weg);
741
+ gruppe.appendChild(kopf);
742
+ var ul = e('ul', 'linkliste');
743
+ eintraege.forEach(function (ei) { ul.appendChild(linkZeile(ei, z, b)); });
744
+ gruppe.appendChild(ul);
745
+ return gruppe;
746
+ }
747
+
748
+ /** Ein Bundle als einzelner Streifen (Chip „Alle" einer Verzeichnis-Karte, E69/E53): Harness-
749
+ Zeichen, Name, seine Mitglieder als Text, Entfernen. Ohne eigene Tabelle. */
750
+ function bundleStreifen(b, z) {
751
+ var streifen = e('div', 'bundlegruppe');
752
+ var kopf = e('div', 'bundlekopf');
753
+ var titel = e('span', 'bundletitel');
754
+ titel.appendChild(harnessZeichen(z.ziel.harness));
755
+ titel.appendChild(e('span', 'etikett', 'Bundle'));
756
+ titel.appendChild(e('b', 'bundlename', b.name));
757
+ kopf.appendChild(titel);
758
+ kopf.appendChild(e('span', 'bundlemitglieder', b.skills.join(', ')));
759
+ var weg = e('button', 'knopf leise klein', 'Entfernen');
760
+ weg.type = 'button';
761
+ weg.setAttribute('aria-label', 'Bundle ' + b.name + ' aus ' + zielTitel(z) + ' entfernen');
762
+ weg.addEventListener('click', function () { frageBundleEntfernen(b, z.ziel); });
763
+ kopf.appendChild(weg);
764
+ streifen.appendChild(kopf);
765
+ return streifen;
766
+ }
767
+
603
768
  function linkTeil(z) {
604
769
  var teil = document.createDocumentFragment();
605
770
  var eigene = z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; });
@@ -628,11 +793,12 @@
628
793
  }
629
794
  }
630
795
 
796
+ var fussContainer;
631
797
  if (eigene.length === 0) {
632
798
  var leer = e('div', 'leerzeile');
633
799
  leer.appendChild(e('p', 'ohne', 'Hier liegt noch nichts.'));
634
- leer.appendChild(skillsKnopf(z));
635
800
  teil.appendChild(leer);
801
+ fussContainer = leer;
636
802
  } else {
637
803
  // Fünf Spalten (E60): Icon, Skill, Beschreibung (die breiteste, sie beantwortet die Frage,
638
804
  // wegen der die Tabelle gelesen wird), Quelle, Aktion.
@@ -644,76 +810,48 @@
644
810
  kopf.appendChild(e('span'));
645
811
  teil.appendChild(kopf);
646
812
 
647
- var ul = e('ul', 'linkliste');
648
- // Defekte Links (ohne Quelle) zuerst, sie brauchen Aufmerksamkeit (E45), sonst alphabetisch.
649
- eigene
813
+ // Bundles zuerst (E69): sie stehen als Gruppe über den Einzel-Zeilen, ihre Mitglieder liegen
814
+ // in der Gruppe statt in der allgemeinen Liste. Was zum Bundle gehört, sagt der Kern.
815
+ var bundles = (z.bundles || []).filter(function (b) { return b.skills.length > 0; });
816
+ var bundleVon = {};
817
+ bundles.forEach(function (b) { b.skills.forEach(function (n) { bundleVon[n] = b; }); });
818
+ bundles
650
819
  .slice()
651
- .sort(function (a, b) {
652
- var da = a.art === 'link-ohne-quelle', db = b.art === 'link-ohne-quelle';
653
- if (da !== db) return da ? -1 : 1;
654
- var na = a.art === 'link' ? a.link.name : a.name;
655
- var nb = b.art === 'link' ? b.link.name : b.name;
656
- return na.localeCompare(nb, 'de');
657
- })
658
- .forEach(function (ei) {
659
- var name = ei.art === 'link' ? ei.link.name : ei.name;
660
- var li = e('li');
661
-
662
- if (ei.art === 'link') li.appendChild(repoIcon(ei.link.checkout.quelle));
663
- else li.appendChild(repoIcon({ art: 'lokal', pfad: ei.checkoutPfad }));
664
-
665
- li.appendChild(e('span', 'name', name));
666
-
667
- // Die Beschreibung aus der SKILL.md (E39): die Frage, wegen der die Zeile gelesen wird,
668
- // deshalb die breiteste Spalte. Ein Link ohne bekannte Quelle kennt seinen Skill nicht.
669
- var beschreibung = ei.art === 'link' ? ei.link.skill.beschreibung : undefined;
670
- if (beschreibung) {
671
- var wasEl = e('span', 'was', beschreibung);
672
- if (beschreibung.length > 88) wasEl.title = beschreibung;
673
- li.appendChild(wasEl);
674
- } else {
675
- li.appendChild(e('span', 'was fehlt', 'keine Beschreibung in der SKILL.md'));
676
- }
677
-
678
- // Quelle: der Name, anklickbar, dahinter nur, was vom Normalfall abweicht — ein Ref, das
679
- // vom Standard-Ref der Quelle abweicht, und der Grund „über Zeiger" (E57). „Manuell" (der
680
- // Normalfall) entfällt ersatzlos, ein Plugin-Grund steht stattdessen in der Aktionsspalte.
681
- var qz = e('span', 'quelle');
682
- if (ei.art === 'link') {
683
- var einschraenkung = z.einschraenkungen[name];
684
- if (einschraenkung) qz.appendChild(document.createTextNode(einschraenkung + ' → '));
685
- qz.appendChild(quelleLinkElement(ei.link.checkout.quelle));
686
- var standardRef = STANDARD_REF[quelleKurz(ei.link.checkout.quelle)];
687
- if (ei.link.checkout.ref && standardRef && ei.link.checkout.ref !== standardRef) {
688
- qz.appendChild(e('span', 'ref', '@' + ei.link.checkout.ref));
689
- }
690
- } else {
691
- qz.appendChild(e('span', 'mono', pfadKurz(ei.checkoutPfad)));
692
- }
693
- var grund = z.gruende[name];
694
- if (grund && grund.art === 'zeiger') qz.appendChild(e('span', 'grundmarke', 'über Zeiger'));
695
- li.appendChild(qz);
696
-
697
- // Aus einem Plugin verlinkt: kein Entfernen-Knopf, das geht nur über das Plugin — der
698
- // Grund erklärt hier zugleich, warum der Knopf fehlt.
699
- if (grund && grund.art === 'plugin') {
700
- li.appendChild(e('span', 'kein-weg', 'nur über Plugin „' + grund.plugin.split('@')[0] + '“'));
701
- } else {
702
- var weg = e('button', 'knopf leise klein', 'Entfernen');
703
- weg.type = 'button';
704
- weg.setAttribute('aria-label', 'Link auf ' + name + ' entfernen');
705
- weg.addEventListener('click', function () { frageEntfernen(name, z.ziel); });
706
- li.appendChild(weg);
707
- }
708
- ul.appendChild(li);
820
+ .sort(function (a, b) { return a.name.localeCompare(b.name, 'de'); })
821
+ .forEach(function (b) {
822
+ var mitglieder = eigene.filter(function (ei) {
823
+ return b.skills.indexOf(ei.art === 'link' ? ei.link.name : ei.name) > -1;
824
+ });
825
+ teil.appendChild(bundleGruppe(b, z, sortiereEintraege(mitglieder)));
709
826
  });
710
- teil.appendChild(ul);
827
+
828
+ var ul = e('ul', 'linkliste');
829
+ sortiereEintraege(
830
+ eigene.filter(function (ei) { return !bundleVon[ei.art === 'link' ? ei.link.name : ei.name]; }),
831
+ ).forEach(function (ei) { ul.appendChild(linkZeile(ei, z, null)); });
832
+ if (ul.childNodes.length > 0) teil.appendChild(ul);
833
+
834
+ // MCP-Abschnitt (E61-GUI-Nachtrag): unter der Skill-Link-Tabelle, vor dem Kartenfuß mit den
835
+ // Knöpfen, Gegenrichtung zur MCP-Ansicht.
836
+ var mcpAbschnMitTabelle = mcpAbschnitt(z.ziel);
837
+ if (mcpAbschnMitTabelle) teil.appendChild(mcpAbschnMitTabelle);
711
838
 
712
839
  var fuss = e('div', 'zielfuss');
713
- fuss.appendChild(skillsKnopf(z));
714
840
  teil.appendChild(fuss);
841
+ fussContainer = fuss;
842
+ }
843
+
844
+ // Ohne eigene Links (leer-Fall) steht der MCP-Abschnitt hinter der Leerzeile, die dort schon den
845
+ // Kartenfuß bildet (Nachricht + Knöpfe in einer Zeile, wie überall sonst im leeren Zustand).
846
+ if (eigene.length === 0) {
847
+ var mcpAbschnOhneTabelle = mcpAbschnitt(z.ziel);
848
+ if (mcpAbschnOhneTabelle) teil.appendChild(mcpAbschnOhneTabelle);
715
849
  }
716
850
 
851
+ fussContainer.appendChild(skillsKnopf(z));
852
+ var mcpKnopf = mcpHinzufuegenKnopf(z.ziel);
853
+ if (mcpKnopf) fussContainer.appendChild(mcpKnopf);
854
+
717
855
  return teil;
718
856
  }
719
857
 
@@ -739,6 +877,137 @@
739
877
  return knopf;
740
878
  }
741
879
 
880
+ /* ---------------- MCP an der Ziel-Karte (E61-GUI-Nachtrag) ---------------- */
881
+ /* Gegenrichtung zur MCP-Ansicht (E47-Muster): ob ein Harness an einem Ort MCP kann, entscheidet
882
+ nie der Harness-Name (E16), sondern einzig, ob MCP_PLAETZE (roh aus dem Kern, F42) diesen Ort
883
+ nennt. */
884
+ function mcpKannAn(ziel) {
885
+ var zk = zielKey(ziel);
886
+ return MCP_PLAETZE.some(function (p) { return zielKey(p) === zk; });
887
+ }
888
+
889
+ /** Die MCP-Zeilen eines Ziels, aus den drei Quellen der MCP-Ansicht zusammengesucht: eingehängte
890
+ Katalog-Einträge (mit Entfernen-Knopf), ihre Fundorte in der geteilten .mcp.json des Projekts
891
+ (E61-Nachtrag, seit E64 mit Übernehmen-Knopf) und Einträge, die dort hängen, aber nicht im
892
+ Katalog stehen (stiller Vermerk). */
893
+ function mcpZeilenFuerZiel(ziel) {
894
+ var zk = zielKey(ziel);
895
+ var zeilen = [];
896
+ MCP_KATALOG.forEach(function (k) {
897
+ if (k.eingehaengt.some(function (z) { return zielKey(z) === zk; })) {
898
+ zeilen.push({ art: 'katalog', name: k.eintrag.name, beschreibung: k.eintrag.beschreibung, eintrag: k.eintrag });
899
+ }
900
+ k.geteilteFundorte.forEach(function (g) {
901
+ if (zielKey(g.ziel) === zk)
902
+ zeilen.push({ art: 'geteilt', name: k.eintrag.name, beschreibung: k.eintrag.beschreibung, datei: g.datei });
903
+ });
904
+ });
905
+ MCP_FREMD.forEach(function (x) { if (zielKey(x.ziel) === zk) zeilen.push({ art: 'fremd', name: x.name }); });
906
+ // Ein Eintrag aus der geteilten .mcp.json, den der Katalog nicht kennt: „nicht im Katalog" allein
907
+ // läse sich wie eine Einladung, ihn in die mcp.yaml zu schreiben. Wo er wirklich steht, gehört
908
+ // daneben — und übernehmen lässt er sich auch ohne Katalog-Eintrag (E64, der Block kommt aus der
909
+ // Datei).
910
+ MCP_FREMD_GETEILT.forEach(function (x) {
911
+ if (zielKey(x.ziel) === zk) zeilen.push({ art: 'fremd-geteilt', name: x.name, datei: x.datei });
912
+ });
913
+ zeilen.sort(function (a, b) { return a.name.localeCompare(b.name, 'de'); });
914
+ return zeilen;
915
+ }
916
+
917
+ /** Der MCP-Abschnitt unter der Skill-Link-Tabelle einer Ziel-Karte (E61-GUI-Nachtrag): nur, wenn
918
+ dort etwas eingehängt ist oder der „hinzufügen"-Knopf ohnehin sichtbar wäre (mcpKannAn). */
919
+ function mcpAbschnitt(ziel) {
920
+ var zeilen = mcpZeilenFuerZiel(ziel);
921
+ if (zeilen.length === 0 && !mcpKannAn(ziel)) return null;
922
+ var abschnitt = e('div', 'mcpkarteabschnitt');
923
+ abschnitt.appendChild(e('span', 'etikett', 'MCP'));
924
+ if (zeilen.length === 0) {
925
+ abschnitt.appendChild(e('p', 'ohne mcpkarteleer', 'Noch nichts eingehängt.'));
926
+ return abschnitt;
927
+ }
928
+ var ul = e('ul', 'mcpkartezeilen');
929
+ zeilen.forEach(function (row) {
930
+ var li = e('li');
931
+ li.appendChild(e('span', 'name', row.name));
932
+ if (row.art === 'katalog') {
933
+ li.appendChild(e('span', 'was', row.beschreibung || ''));
934
+ var weg = e('button', 'knopf leise klein', 'Entfernen');
935
+ weg.type = 'button';
936
+ weg.setAttribute('aria-label', row.name + ' aus ' + zielTitel({ ziel: ziel }) + ' aushängen');
937
+ weg.addEventListener('click', function () { frageMcpAus(row.eintrag, ziel); });
938
+ li.appendChild(weg);
939
+ } else if (row.art === 'geteilt' || row.art === 'fremd-geteilt') {
940
+ // E64: der Vermerk nennt die Bedeutung („steht im Projekt, nicht bei dir"), nicht mehr bloß
941
+ // den Dateinamen — und daneben steht der Weg, das zu ändern.
942
+ li.appendChild(row.art === 'geteilt'
943
+ ? e('span', 'was', row.beschreibung || '')
944
+ : e('span', 'was fehlt', 'nicht im Katalog'));
945
+ li.appendChild(e('span', 'geteiltvermerk', 'in der .mcp.json des Projekts'));
946
+ var her = e('button', 'knopf leise klein', 'Übernehmen …');
947
+ her.type = 'button';
948
+ her.setAttribute('aria-label', row.name + ' aus der .mcp.json des Projekts in deine private Konfiguration übernehmen');
949
+ her.addEventListener('click', function () { frageMcpUebernahme(row.name, ziel, row.datei); });
950
+ li.appendChild(her);
951
+ } else {
952
+ li.appendChild(e('span', 'was fehlt', 'nicht im Katalog'));
953
+ li.appendChild(e('span'));
954
+ }
955
+ ul.appendChild(li);
956
+ });
957
+ abschnitt.appendChild(ul);
958
+ return abschnitt;
959
+ }
960
+
961
+ /** Knopf „MCP hinzufügen …" im Kartenfuß neben „Skills hinzufügen" (E61-GUI-Nachtrag, Gegenrichtung
962
+ zur MCP-Ansicht): nur, wo der Harness an diesem Ort MCP kann (mcpKannAn, E16). */
963
+ function mcpHinzufuegenKnopf(ziel) {
964
+ if (!mcpKannAn(ziel)) return null;
965
+ var knopf = e('button', 'knopf leise klein mitsymbol', 'MCP hinzufügen …');
966
+ knopf.type = 'button';
967
+ knopf.insertBefore(harnessZeichen(ziel.harness), knopf.firstChild);
968
+ knopf.setAttribute('aria-label', 'MCP-Server zu ' + zielTitel({ ziel: ziel }) + ' hinzufügen (' + harnessName(ziel.harness) + ')');
969
+ knopf.addEventListener('click', function () { oeffneMcpHinzufuegen(ziel); });
970
+ return knopf;
971
+ }
972
+
973
+ /** Eigene Links eines Verzeichnisses über alle Harnesses gebündelt: Schlüssel Name+Quelle,
974
+ hängt ein Name je Harness aus verschiedener Quelle, gibt es je Quelle eine eigene Zeile
975
+ (E53 Pkt. 2). Einzige Zählregel für Skills: der Körper zeilt daraus, die Kartenköpfe zählen
976
+ dieselbe Struktur (E71). Ein einzelnes Ziel kommt als einelementige Liste.
977
+ Fremde Einträge bleiben draußen, defekte Links (ohne Quelle) zählen mit. */
978
+ function buendleEigene(teile) {
979
+ var zeilen = {};
980
+ var reihenfolge = [];
981
+ teile.forEach(function (z) {
982
+ z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).forEach(function (ei) {
983
+ var defekt = ei.art === 'link-ohne-quelle';
984
+ var name = defekt ? ei.name : ei.link.name;
985
+ var quelleSchluessel = defekt ? ('defekt\u0000' + ei.checkoutPfad)
986
+ : (quelleKurz(ei.link.checkout.quelle) + '@' + ei.link.checkout.ref);
987
+ // NUL als Trenner, kein Leerzeichen: Skill-Namen dürfen Leerzeichen enthalten, Quellen-Namen
988
+ // seit E59 auch, ein lokaler Pfad ohnehin — „a b“ + „x“ und „a“ + „b x“ ergäben sonst
989
+ // denselben Schlüssel. Immer als Escape geschrieben, nie als rohes Byte im Quelltext.
990
+ var key = name + '\u0000' + quelleSchluessel;
991
+ if (!zeilen[key]) {
992
+ zeilen[key] = {
993
+ name: name,
994
+ defekt: defekt,
995
+ icon: defekt ? { art: 'lokal', pfad: ei.checkoutPfad } : ei.link.checkout.quelle,
996
+ checkoutPfad: defekt ? ei.checkoutPfad : null,
997
+ quelle: defekt ? null : ei.link.checkout.quelle,
998
+ ref: defekt ? null : ei.link.checkout.ref,
999
+ einschraenkung: defekt ? null : z.einschraenkungen[name],
1000
+ skill: defekt ? null : ei.link.skill,
1001
+ teile: [],
1002
+ };
1003
+ reihenfolge.push(key);
1004
+ }
1005
+ zeilen[key].teile.push(z);
1006
+ });
1007
+ });
1008
+ return { zeilen: zeilen, reihenfolge: reihenfolge };
1009
+ }
1010
+
742
1011
  function zielKarte(z) {
743
1012
  var karte = e('div', 'ziel');
744
1013
  var kopf = e('div', 'zielkopf');
@@ -762,8 +1031,10 @@
762
1031
  warnspan.setAttribute('aria-label', 'braucht Aufmerksamkeit');
763
1032
  kopf.appendChild(warnspan);
764
1033
  }
765
- var zahl = z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).length;
766
- kopf.appendChild(e('span', 'zahl', zahl + (zahl === 1 ? ' Link' : ' Links')));
1034
+ // Der Kopf zählt Skills, nicht Einträge (E71), über dieselbe Bündelung, aus der der Körper zeilt.
1035
+ // Bei einem Harness fällt die Zahl mit der Zahl eigener Einträge zusammen, die Regel bleibt eine.
1036
+ var zahl = buendleEigene([z]).reihenfolge.length;
1037
+ kopf.appendChild(e('span', 'zahl', zahl + (zahl === 1 ? ' Skill' : ' Skills')));
767
1038
  var pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
768
1039
  pfeil.setAttribute('viewBox', '0 0 16 16');
769
1040
  pfeil.setAttribute('class', 'akkordeonpfeil');
@@ -826,46 +1097,47 @@
826
1097
  if (beachteteAlle.length > 0) teil.appendChild(fremdKasten(beachteteAlle, true));
827
1098
  if (ignorierteAlle.length > 0) teil.appendChild(ignoriertZeile(ignorierteAlle, true));
828
1099
 
829
- // Eigene Links über alle Harnesses gebündelt: Schlüssel Name+Quelle, hängt ein Name je Harness
830
- // aus verschiedener Quelle, gibt es je Quelle eine eigene Zeile (E53 Pkt. 2).
831
- var zeilen = {};
832
- var reihenfolge = [];
833
- teile.forEach(function (z) {
834
- z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).forEach(function (ei) {
835
- var defekt = ei.art === 'link-ohne-quelle';
836
- var name = defekt ? ei.name : ei.link.name;
837
- var quelleSchluessel = defekt ? ('defekt\u0000' + ei.checkoutPfad)
838
- : (quelleKurz(ei.link.checkout.quelle) + '@' + ei.link.checkout.ref);
839
- // NUL als Trenner, kein Leerzeichen: Skill-Namen dürfen Leerzeichen enthalten, Quellen-Namen
840
- // seit E59 auch, ein lokaler Pfad ohnehin — „a b“ + „x“ und „a“ + „b x“ ergäben sonst
841
- // denselben Schlüssel. Immer als Escape geschrieben, nie als rohes Byte im Quelltext.
842
- var key = name + '\u0000' + quelleSchluessel;
843
- if (!zeilen[key]) {
844
- zeilen[key] = {
845
- name: name,
846
- defekt: defekt,
847
- icon: defekt ? { art: 'lokal', pfad: ei.checkoutPfad } : ei.link.checkout.quelle,
848
- checkoutPfad: defekt ? ei.checkoutPfad : null,
849
- quelle: defekt ? null : ei.link.checkout.quelle,
850
- ref: defekt ? null : ei.link.checkout.ref,
851
- einschraenkung: defekt ? null : z.einschraenkungen[name],
852
- beschreibung: defekt ? undefined : ei.link.skill.beschreibung,
853
- teile: [],
854
- };
855
- reihenfolge.push(key);
856
- }
857
- zeilen[key].teile.push(z);
858
- });
1100
+ var gebuendelt = buendleEigene(teile);
1101
+ var zeilen = gebuendelt.zeilen;
1102
+ var reihenfolge = gebuendelt.reihenfolge;
1103
+
1104
+ // MCPs hängen am Verzeichnis, nicht am Harness-Chip (E61-GUI-Nachtrag): der Abschnitt unter
1105
+ // „Alle" zeigt jedes Ziel, das an genau diesem Pfad MCP kann — dieselbe Fähigkeitsfrage wie unter
1106
+ // dem Harness-Chip, allein über den Ort aus MCP_PLAETZE gestellt. Kein Harness-Name im Filter
1107
+ // (E16): heute liefert das nur Claude Code, weil Hermes keine Projektebene für MCP kennt; käme
1108
+ // ein Harness mit Projektebene dazu, stünde er hier von selbst und fiele nicht stumm weg.
1109
+ var verzMcpZiele = MCP_PLAETZE.filter(function (p) {
1110
+ return p.ort.art === 'arbeitsverzeichnis' && p.ort.pfad === pfad;
859
1111
  });
860
1112
 
861
1113
  if (reihenfolge.length === 0) {
862
1114
  var leer = e('div', 'leerzeile');
863
1115
  leer.appendChild(e('p', 'ohne', 'Hier liegt noch nichts.'));
864
1116
  leer.appendChild(alleSkillsKnopf(pfad, teile));
1117
+ verzMcpZiele.forEach(function (mz) {
1118
+ var mcpKnopfLeer = mcpHinzufuegenKnopf(mz);
1119
+ if (mcpKnopfLeer) leer.appendChild(mcpKnopfLeer);
1120
+ });
865
1121
  teil.appendChild(leer);
1122
+ verzMcpZiele.forEach(function (mz) {
1123
+ var mcpAbschnLeer = mcpAbschnitt(mz);
1124
+ if (mcpAbschnLeer) teil.appendChild(mcpAbschnLeer);
1125
+ });
866
1126
  return teil;
867
1127
  }
868
1128
 
1129
+ // Bundles unter „Alle" (E69): die Zeilen bleiben nach Name und Quelle gebündelt (E53), ein
1130
+ // Bundle gehört aber zu genau einem Harness — deshalb hier ein Streifen je Bundle mit seinem
1131
+ // Harness-Zeichen statt einer Umgruppierung der Tabelle. Unter dem Harness-Chip zeigt linkTeil
1132
+ // die volle Gruppe über den Zeilen.
1133
+ teile.forEach(function (z) {
1134
+ (z.bundles || [])
1135
+ .filter(function (b) { return b.skills.length > 0; })
1136
+ .slice()
1137
+ .sort(function (a, b) { return a.name.localeCompare(b.name, 'de'); })
1138
+ .forEach(function (b) { teil.appendChild(bundleStreifen(b, z)); });
1139
+ });
1140
+
869
1141
  var kopf = e('div', 'linkkopf');
870
1142
  kopf.appendChild(e('span'));
871
1143
  kopf.appendChild(e('span', 'etikett', 'Skill'));
@@ -896,13 +1168,7 @@
896
1168
  nameSpan.appendChild(document.createTextNode(row.name));
897
1169
  li.appendChild(nameSpan);
898
1170
 
899
- if (row.beschreibung) {
900
- var wasEl = e('span', 'was', row.beschreibung);
901
- if (row.beschreibung.length > 88) wasEl.title = row.beschreibung;
902
- li.appendChild(wasEl);
903
- } else {
904
- li.appendChild(e('span', 'was fehlt', 'keine Beschreibung in der SKILL.md'));
905
- }
1171
+ li.appendChild(wasZelle(row.skill));
906
1172
 
907
1173
  var qz = e('span', 'quelle');
908
1174
  if (row.defekt) {
@@ -949,8 +1215,19 @@
949
1215
  });
950
1216
  teil.appendChild(ul);
951
1217
 
1218
+ // MCP-Abschnitt (E61-GUI-Nachtrag): auf Kartenebene unter „Alle", wie unter dem Harness-Chip,
1219
+ // der an diesem Pfad MCP kann.
1220
+ verzMcpZiele.forEach(function (mz) {
1221
+ var mcpAbschnAlle = mcpAbschnitt(mz);
1222
+ if (mcpAbschnAlle) teil.appendChild(mcpAbschnAlle);
1223
+ });
1224
+
952
1225
  var fuss = e('div', 'zielfuss');
953
1226
  fuss.appendChild(alleSkillsKnopf(pfad, teile));
1227
+ verzMcpZiele.forEach(function (mz) {
1228
+ var mcpKnopfAlle = mcpHinzufuegenKnopf(mz);
1229
+ if (mcpKnopfAlle) fuss.appendChild(mcpKnopfAlle);
1230
+ });
954
1231
  teil.appendChild(fuss);
955
1232
 
956
1233
  return teil;
@@ -996,10 +1273,10 @@
996
1273
  warnspan.setAttribute('aria-label', 'braucht Aufmerksamkeit');
997
1274
  kopf.appendChild(warnspan);
998
1275
  }
999
- var gesamtZahl = teile.reduce(function (n, z) {
1000
- return n + z.eintraege.filter(function (ei) { return ei.art !== 'fremd'; }).length;
1001
- }, 0);
1002
- kopf.appendChild(e('span', 'zahl', gesamtZahl + (gesamtZahl === 1 ? ' Link' : ' Links')));
1276
+ // Skills statt Einträge (E71): eine Summe über die Harness-Abschnitte zählte jeden Skill so oft,
1277
+ // wie ihn Harnesses am selben Verzeichnis führen — der Körper bündelt sie zu einer Zeile.
1278
+ var gesamtZahl = buendleEigene(teile).reihenfolge.length;
1279
+ kopf.appendChild(e('span', 'zahl', gesamtZahl + (gesamtZahl === 1 ? ' Skill' : ' Skills')));
1003
1280
  var pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1004
1281
  pfeil.setAttribute('viewBox', '0 0 16 16');
1005
1282
  pfeil.setAttribute('class', 'akkordeonpfeil');
@@ -1048,7 +1325,10 @@
1048
1325
  // gehört zum gewählten Harness, nicht zur Karte: er steht rechts in der Chip-Leiste, relativ
1049
1326
  // zum Verzeichnis, mit der Inaktiv-Marke daneben.
1050
1327
  if (gewaehlt) {
1051
- var unterhalb = gewaehlt.verzeichnis.indexOf(pfad + '/') === 0;
1328
+ // Nur die Anzeige: liegt der gelesene Ordner unterhalb des Verzeichnisses, steht er
1329
+ // relativ, sonst gekürzt. Beide Trenner, sonst greift das unter Windows nie (E68).
1330
+ var unterhalb = gewaehlt.verzeichnis.indexOf(pfad + '/') === 0
1331
+ || gewaehlt.verzeichnis.indexOf(pfad + '\\') === 0;
1052
1332
  chipleiste.appendChild(e('span', 'kartenordner mono',
1053
1333
  unterhalb ? gewaehlt.verzeichnis.slice(pfad.length + 1) : pfadKurz(gewaehlt.verzeichnis)));
1054
1334
  if (!harnessAktiv(aktivChip)) chipleiste.appendChild(e('span', 'ruhend', 'Harness inaktiv'));
@@ -1146,10 +1426,24 @@
1146
1426
  // entfernenKontext.eintraege: [{harness, ort}], immer eines bei frageEntfernen (Einzel-Ziel),
1147
1427
  // mehrere bei frageEntfernenAlle (E53 Pkt. 2, „Alle"-Zeile über mehrere Harnesses).
1148
1428
  var entfernenKontext = null;
1149
- function frageEntfernen(name, ziel) {
1150
- entfernenKontext = { name: name, eintraege: [{ harness: ziel.harness, ort: ziel.ort }] };
1429
+ /** `bundleName` gesetzt heißt: der Link gehört zu einem Bundle, das Entfernen zerbricht es (E69). */
1430
+ function frageEntfernen(name, ziel, bundleName) {
1431
+ entfernenKontext = { art: 'link', name: name, eintraege: [{ harness: ziel.harness, ort: ziel.ort }] };
1151
1432
  $('dx-titel').textContent = 'Link auf ' + name + ' entfernen?';
1152
- $('dx-text').textContent = 'Der Link in ' + zielTitel({ ziel: ziel }) + ' verschwindet. Der Checkout bleibt liegen, bis kein Link mehr darauf zeigt.';
1433
+ var text = 'Der Link in ' + zielTitel({ ziel: ziel }) + ' verschwindet. Der Checkout bleibt liegen, bis kein Link mehr darauf zeigt.';
1434
+ if (bundleName) {
1435
+ text += ' Damit zerbricht das Bundle „' + bundleName + '“ hier: seine übrigen Links bleiben liegen, aber nicht mehr als Bundle.';
1436
+ }
1437
+ $('dx-text').textContent = text;
1438
+ zeige($('d-entfernen'));
1439
+ }
1440
+
1441
+ /** Entfernen des ganzen Bundles an einem Ziel (E69): alle seine Links auf einmal. */
1442
+ function frageBundleEntfernen(b, ziel) {
1443
+ entfernenKontext = { art: 'bundle', bundle: b.name, ziel: ziel };
1444
+ $('dx-titel').textContent = 'Bundle ' + b.name + ' entfernen?';
1445
+ $('dx-text').textContent = zahlwort(b.skills.length, 'Link', 'Links') + ' aus ' + zielTitel({ ziel: ziel })
1446
+ + ' verschwinden: ' + b.skills.join(', ') + '. Die Checkouts bleiben liegen, bis kein Link mehr darauf zeigt.';
1153
1447
  zeige($('d-entfernen'));
1154
1448
  }
1155
1449
  /** Entfernen einer „Alle"-Zeile (E53 Pkt. 2): entfernbareZiele sind die Ziele (voller z.ziel), in
@@ -1157,6 +1451,7 @@
1157
1451
  und liegen bleibt. Der Dialog nennt beides. */
1158
1452
  function frageEntfernenAlle(name, pfad, entfernbareZiele, nichtEntfernbareHarnesse) {
1159
1453
  entfernenKontext = {
1454
+ art: 'link',
1160
1455
  name: name,
1161
1456
  eintraege: entfernbareZiele.map(function (z) { return { harness: z.ziel.harness, ort: z.ziel.ort }; }),
1162
1457
  };
@@ -1173,6 +1468,19 @@
1173
1468
  nachDialog($('d-entfernen'), async function (wert) {
1174
1469
  if (wert !== 'ja' || entfernenKontext === null) return;
1175
1470
  var kontext = entfernenKontext;
1471
+ // Bundle als Ganzes (E69): ein Aufruf, der Kern nimmt alle Links des Bundles weg.
1472
+ if (kontext.art === 'bundle') {
1473
+ var wegB = await rufe(function () {
1474
+ return window.hmgr.bundleEntfernen({
1475
+ bundle: kontext.bundle,
1476
+ harness: kontext.ziel.harness,
1477
+ ort: kontext.ziel.ort,
1478
+ });
1479
+ });
1480
+ await ladeAlles();
1481
+ melde('Bundle ' + kontext.bundle + ' entfernt, ' + zahlwort(wegB.entfernt.length, 'Link', 'Links') + ' weniger.');
1482
+ return;
1483
+ }
1176
1484
  // Ein Fehlschlag in einem Harness hält die übrigen nicht auf: rufe hat ihn schon gemeldet, die
1177
1485
  // Schleife läuft weiter, und die Liste wird danach in jedem Fall neu geladen. Sonst stünde nach
1178
1486
  // einem Fehler die halb erledigte Arbeit unsichtbar in einer veralteten Liste.
@@ -1215,7 +1523,7 @@
1215
1523
  function frageLoeschen(name, ziel, verzeichnis) {
1216
1524
  loeschenKontext = { name: name, ziel: ziel };
1217
1525
  $('dl-titel').textContent = '„' + name + '“ löschen?';
1218
- $('dl-text').textContent = 'hmgr löscht ' + pfadKurz(verzeichnis) + '/' + name
1526
+ $('dl-text').textContent = 'hmgr löscht ' + pfadKurz(verzeichnis) + TRENNER + name
1219
1527
  + ' unwiderruflich von der Platte, ein Ordner mit allem Inhalt oder ein Link. Das lässt sich nicht rückgängig machen.';
1220
1528
  zeige($('d-loeschen'));
1221
1529
  }
@@ -1400,7 +1708,7 @@
1400
1708
  chip.addEventListener('click', function () { setzeQuellenfilter(s.quelleSchluessel); });
1401
1709
 
1402
1710
  // Quellen-Chip-Paar (E60): links filtert der Name, rechts öffnet das Pfeilsymbol die Quelle
1403
- // (Repo-Seite im Browser, lokale Arbeitskopie im Finder). Kein <a> im <button>, deshalb
1711
+ // (Repo-Seite im Browser, lokale Arbeitskopie im Dateimanager). Kein <a> im <button>, deshalb
1404
1712
  // stehen sie nebeneinander in einer gemeinsamen Hülle.
1405
1713
  var quelle = QUELLEN.filter(function (q) { return q.text === s.quelleText; })[0];
1406
1714
  if (quelle) {
@@ -1411,7 +1719,7 @@
1411
1719
  if (lokal) oeffnen.type = 'button'; else oeffnen.href = '#';
1412
1720
  oeffnen.className = 'chipoeffnen';
1413
1721
  oeffnen.title = quelle.text;
1414
- oeffnen.setAttribute('aria-label', s.quelleName + ' ' + (lokal ? 'im Finder öffnen' : 'im Browser öffnen'));
1722
+ oeffnen.setAttribute('aria-label', s.quelleName + ' ' + (lokal ? 'im Dateimanager öffnen' : 'im Browser öffnen'));
1415
1723
  oeffnen.appendChild(externSvg());
1416
1724
  oeffnen.addEventListener('click', function (ev) {
1417
1725
  ev.preventDefault();
@@ -1455,6 +1763,24 @@
1455
1763
  return box;
1456
1764
  }
1457
1765
 
1766
+ /** Beschreibungs-Zelle der Ziel-Tabellen: die Beschreibung (E39) und darunter die immer
1767
+ sichtbaren Frontmatter-Marken (E55), derselbe Helfer wie im Katalog. Ohne Skill (Link ohne
1768
+ Quelle) bleibt nur die Fehlt-Zeile. */
1769
+ function wasZelle(skill) {
1770
+ var feld = e('span', 'wasfeld');
1771
+ var beschreibung = skill ? skill.beschreibung : undefined;
1772
+ if (beschreibung) {
1773
+ var wasEl = e('span', 'was', beschreibung);
1774
+ if (beschreibung.length > 88) wasEl.title = beschreibung;
1775
+ feld.appendChild(wasEl);
1776
+ } else {
1777
+ feld.appendChild(e('span', 'was fehlt', 'keine Beschreibung in der SKILL.md'));
1778
+ }
1779
+ var marken = skill ? skillMarkenBox(skill) : null;
1780
+ if (marken) feld.appendChild(marken);
1781
+ return feld;
1782
+ }
1783
+
1458
1784
  /** Zeilen des Aufklappbereichs einer Skill-Zeile aus dem Frontmatter (E55), nur wenn vorhanden. */
1459
1785
  function skillDetails(skill) {
1460
1786
  var zeilen = [];
@@ -1465,6 +1791,14 @@
1465
1791
  return zeilen;
1466
1792
  }
1467
1793
 
1794
+ /** ISO-Zeitpunkt als deutsches Datum (E70). Ungültiges oder fehlendes Datum ergibt null. */
1795
+ function datumDeutsch(iso) {
1796
+ if (!iso) return null;
1797
+ var d = new Date(iso);
1798
+ if (isNaN(d.getTime())) return null;
1799
+ return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
1800
+ }
1801
+
1468
1802
  function baueSkills() {
1469
1803
  var liste = $('katalog');
1470
1804
  liste.textContent = '';
@@ -1488,12 +1822,17 @@
1488
1822
  // title. Fehlt die Quelle in der Liste (Katalog und Quellen kämen auseinander), bleibt es
1489
1823
  // beim reinen Text — angeschrieben ist die Zeile so oder so.
1490
1824
  var herkunft = e('p', 'herkunft mono');
1825
+ // Das Änderungsdatum steht hinter dem Ref (E70, F70=O1): der jüngste Commit, der den
1826
+ // Skill-Ordner berührt. Nicht der Pull-Zeitpunkt — der steht als „Zuletzt geprüft" in der
1827
+ // Update-Ansicht und wäre für alle Skills einer Quelle derselbe.
1828
+ var geaendert = datumDeutsch(skill.geaendert);
1829
+ var nachRef = ' · Ref ' + skill.ref + (geaendert ? ' · geändert ' + geaendert : '');
1491
1830
  if (herkunftQuelle) {
1492
1831
  herkunft.appendChild(quelleLinkElement(herkunftQuelle));
1493
- herkunft.appendChild(e('b', null, ' · Ref ' + skill.ref));
1832
+ herkunft.appendChild(e('b', null, nachRef));
1494
1833
  } else {
1495
1834
  herkunft.title = skill.quelleText;
1496
- herkunft.appendChild(e('b', null, skill.quelleName + ' · Ref ' + skill.ref));
1835
+ herkunft.appendChild(e('b', null, skill.quelleName + nachRef));
1497
1836
  }
1498
1837
  links.appendChild(herkunft);
1499
1838
 
@@ -1629,7 +1968,7 @@
1629
1968
  li.appendChild(e('span', r.ok ? 'ok' : 'aus', r.ok ? 'Link' : 'Fehler'));
1630
1969
  var s = e('span');
1631
1970
  s.appendChild(beschriftet(e('b', null, zielTitel(r.ziel)), r.ziel.ziel.harness));
1632
- s.appendChild(e('span', 'mono', r.ok ? pfadKurz(r.ziel.verzeichnis) + '/' + skill.name : r.fehler));
1971
+ s.appendChild(e('span', 'mono', r.ok ? pfadKurz(r.ziel.verzeichnis) + TRENNER + skill.name : r.fehler));
1633
1972
  li.appendChild(s);
1634
1973
  ul.appendChild(li);
1635
1974
  });
@@ -1642,7 +1981,8 @@
1642
1981
  Skill nur aus, wenn er in ALLEN beteiligten Harnesses hängt, und verlinkt je Harness, wo er fehlt. */
1643
1982
  var skillsKontext = null;
1644
1983
  function dskBilanzAktualisieren() {
1645
- var n = $('dsk-liste').querySelectorAll('input:checked').length;
1984
+ var n = $('dsk-liste').querySelectorAll('input:checked').length
1985
+ + $('dsk-bundles').querySelectorAll('input:checked').length;
1646
1986
  $('dsk-bilanz').textContent = zahlwort(n, 'ausgewählt', 'ausgewählt');
1647
1987
  }
1648
1988
  function filtereDskListe() {
@@ -1651,6 +1991,60 @@
1651
1991
  var skill = SKILLS[+label.dataset.index];
1652
1992
  label.hidden = !!suche && skill.name.toLowerCase().indexOf(suche) === -1;
1653
1993
  });
1994
+ // Die Suche gilt auch für Bundles (E69): sonst bliebe die Sektion bei jeder Eingabe voll stehen.
1995
+ document.querySelectorAll('#dsk-bundles .wahlzeile').forEach(function (label) {
1996
+ var bundle = BUNDLES[+label.dataset.index];
1997
+ label.hidden = !!suche && bundle.name.toLowerCase().indexOf(suche) === -1;
1998
+ });
1999
+ }
2000
+
2001
+ /**
2002
+ * Bundle-Sektion des Dialogs (E69, F68): je Bundle eine Wahlzeile mit Beschreibung, Zahl seiner
2003
+ * Mitglieder und Herkunft. `installiertIn(bundle)` sagt, in welchen der angesteuerten Ziele es
2004
+ * schon hängt — hängt es überall, ist die Zeile ausgegraut, wie bei einem Skill.
2005
+ */
2006
+ function fuelleBundleSektion(installiertIn, zieleZahl) {
2007
+ var box = $('dsk-bundles');
2008
+ box.textContent = '';
2009
+ BUNDLES.forEach(function (bundle, i) {
2010
+ var schon = installiertIn(bundle);
2011
+ var ueberall = schon >= zieleZahl;
2012
+ var label = e('label', 'wahlzeile quellfarbe');
2013
+ label.dataset.index = i;
2014
+ var idx = quellenIndex(bundle.quelleText);
2015
+ label.style.borderLeftColor = 'var(--q' + (idx + 1) + ')';
2016
+ var input = document.createElement('input');
2017
+ input.type = 'checkbox';
2018
+ input.value = String(i);
2019
+ input.disabled = ueberall;
2020
+ input.addEventListener('change', dskBilanzAktualisieren);
2021
+ label.appendChild(input);
2022
+ var txt = e('span');
2023
+ txt.appendChild(e('b', null, bundle.name));
2024
+ txt.appendChild(e('span', 'notiz', bundle.beschreibung));
2025
+ var herkunft = e('span', 'mono', zahlwort(bundle.mitglieder, 'Skill', 'Skills')
2026
+ + ' aus ' + zahlwort(bundle.quellen.length, 'Quelle', 'Quellen') + ' · ' + bundle.quelleName);
2027
+ herkunft.title = bundle.quellen.join('\n');
2028
+ txt.appendChild(herkunft);
2029
+ if (ueberall) txt.appendChild(e('span', 'notiz', zieleZahl > 1 ? 'Hängt schon überall.' : 'Hängt hier schon.'));
2030
+ else if (schon > 0) txt.appendChild(e('span', 'notiz', 'Hängt schon in ' + schon + ' von ' + zieleZahl + ' Harnesses.'));
2031
+ label.appendChild(txt);
2032
+ box.appendChild(label);
2033
+ });
2034
+ $('dsk-bundlebox').hidden = BUNDLES.length === 0 && BUNDLES_KAPUTT.length === 0;
2035
+ // Eine Definition, die sich nicht lesen ließ, wird genannt statt verschluckt (E69).
2036
+ var fehlerZeile = $('dsk-bundlefehler');
2037
+ fehlerZeile.hidden = BUNDLES_KAPUTT.length === 0;
2038
+ fehlerZeile.textContent = BUNDLES_KAPUTT.map(function (k) {
2039
+ return k.quelleName + ': ' + k.datei + ' — ' + k.fehler;
2040
+ }).join('\n');
2041
+ }
2042
+
2043
+ /** Wie oft ein Bundle in den angesteuerten Zielen schon installiert ist (Name plus Quelle, E69). */
2044
+ function bundleHaengtIn(teile, bundle) {
2045
+ return teile.filter(function (z) {
2046
+ return (z.bundles || []).some(function (b) { return b.name === bundle.name && b.quelleText === bundle.quelleText; });
2047
+ }).length;
1654
2048
  }
1655
2049
  $('dsk-suche').addEventListener('input', filtereDskListe);
1656
2050
 
@@ -1685,6 +2079,7 @@
1685
2079
  label.appendChild(txt);
1686
2080
  box.appendChild(label);
1687
2081
  });
2082
+ fuelleBundleSektion(function (bundle) { return bundleHaengtIn([z], bundle); }, 1);
1688
2083
  dskBilanzAktualisieren();
1689
2084
  zeige($('d-skills'));
1690
2085
  $('dsk-suche').focus();
@@ -1729,6 +2124,7 @@
1729
2124
  label.appendChild(txt);
1730
2125
  box.appendChild(label);
1731
2126
  });
2127
+ fuelleBundleSektion(function (bundle) { return bundleHaengtIn(teile, bundle); }, teile.length);
1732
2128
  dskBilanzAktualisieren();
1733
2129
  zeige($('d-skills'));
1734
2130
  $('dsk-suche').focus();
@@ -1736,7 +2132,8 @@
1736
2132
 
1737
2133
  $('d-skills').addEventListener('submit', function (ev) {
1738
2134
  if (ev.submitter && ev.submitter.value === 'los') {
1739
- var gewaehlt = Array.from($('dsk-liste').querySelectorAll('input:checked'));
2135
+ var gewaehlt = Array.from($('dsk-liste').querySelectorAll('input:checked'))
2136
+ .concat(Array.from($('dsk-bundles').querySelectorAll('input:checked')));
1740
2137
  if (gewaehlt.length === 0) ev.preventDefault();
1741
2138
  }
1742
2139
  });
@@ -1745,6 +2142,7 @@
1745
2142
  if (wert !== 'los' || skillsKontext === null) return;
1746
2143
  var kontext = skillsKontext;
1747
2144
  var indizes = Array.from($('dsk-liste').querySelectorAll('input:checked')).map(function (i) { return +i.value; });
2145
+ var bundleIndizes = Array.from($('dsk-bundles').querySelectorAll('input:checked')).map(function (i) { return +i.value; });
1748
2146
 
1749
2147
  if (kontext.modus === 'einzeln') {
1750
2148
  var z = kontext.z;
@@ -1754,6 +2152,26 @@
1754
2152
  // Der zweite gehört daher als Fehler in die Bilanz, sonst meldet der Dialog Links, die es nicht gibt.
1755
2153
  var vergeben = [];
1756
2154
  var hinweiseZiel = [];
2155
+ // Bundles zuerst (E69): sie wirken als Ganzes und bringen ihre Mitglieder mit, ein danach
2156
+ // einzeln gewählter Skill gleichen Namens würde den eben gelegten Link stumm ersetzen.
2157
+ for (var bi = 0; bi < bundleIndizes.length; bi++) {
2158
+ var bundle = BUNDLES[bundleIndizes[bi]];
2159
+ try {
2160
+ var erg = await rufe(function () {
2161
+ return window.hmgr.bundleInstallieren({
2162
+ bundle: bundle.name,
2163
+ harness: z.ziel.harness,
2164
+ ort: z.ziel.ort,
2165
+ quelle: bundle.quelleText,
2166
+ });
2167
+ });
2168
+ hinweiseZiel.push(erg.hinweis);
2169
+ erg.gelegt.concat(erg.uebernommen).forEach(function (n) { vergeben.push(n); });
2170
+ ergebnisZeilen.push({ ok: true, bundle: bundle, erg: erg });
2171
+ } catch (fehler) {
2172
+ ergebnisZeilen.push({ ok: false, bundle: bundle, fehler: fehler.message });
2173
+ }
2174
+ }
1757
2175
  for (var n = 0; n < indizes.length; n++) {
1758
2176
  var skill = SKILLS[indizes[n]];
1759
2177
  if (vergeben.indexOf(skill.name) > -1) {
@@ -1779,18 +2197,30 @@
1779
2197
  // Arbeitsverzeichnis ist das die gebündelte Verzeichnis-Karte, nicht das Ziel allein (E49).
1780
2198
  ZIEL_OFFEN[kartenKey(z.ziel)] = true;
1781
2199
  await ladeAlles();
1782
- var erfolge = ergebnisZeilen.filter(function (r) { return r.ok; }).length;
2200
+ // Ein Bundle zählt als eines, egal wie viele Links es mitbringt: die Zeile darunter nennt sie.
2201
+ var erfolge = ergebnisZeilen.filter(function (r) { return r.ok && !r.bundle; }).length;
2202
+ var erfolgeBundle = ergebnisZeilen.filter(function (r) { return r.ok && r.bundle; }).length;
1783
2203
  $('de-titel').textContent = 'Verlinkt';
1784
- $('de-text').textContent = zahlwort(erfolge, 'Skill hängt', 'Skills hängen') + ' jetzt in ' + zielTitel(z) + '.';
2204
+ $('de-text').textContent = (erfolgeBundle > 0 ? zahlwort(erfolgeBundle, 'Bundle', 'Bundles') + ' und ' : '')
2205
+ + zahlwort(erfolge, 'Skill hängt', 'Skills hängen') + ' jetzt in ' + zielTitel(z) + '.';
1785
2206
  setzeErfolgHinweise(hinweiseZiel);
1786
2207
  var ul = $('de-liste');
1787
2208
  ul.textContent = '';
1788
2209
  ergebnisZeilen.forEach(function (r) {
1789
2210
  var li = e('li');
1790
- li.appendChild(e('span', r.ok ? 'ok' : 'aus', r.ok ? 'Link' : 'Fehler'));
2211
+ li.appendChild(e('span', r.ok ? 'ok' : 'aus', r.ok ? (r.bundle ? 'Bundle' : 'Link') : 'Fehler'));
1791
2212
  var s = e('span');
1792
- s.appendChild(e('b', null, r.skill.name));
2213
+ s.appendChild(e('b', null, r.bundle ? r.bundle.name : r.skill.name));
1793
2214
  if (!r.ok) s.appendChild(e('span', 'mono', r.fehler));
2215
+ else if (r.bundle) {
2216
+ var teileText = r.erg.gelegt.concat(r.erg.uebernommen).join(', ');
2217
+ if (r.erg.neueQuellen.length > 0) {
2218
+ // Neue Quellen (F66) gehören genannt: hmgr hat sie beim Installieren selbst angelegt.
2219
+ teileText += ' · neue ' + zahlwort(r.erg.neueQuellen.length, 'Quelle', 'Quellen') + ': '
2220
+ + r.erg.neueQuellen.join(', ');
2221
+ }
2222
+ s.appendChild(e('span', 'mono', teileText));
2223
+ }
1794
2224
  li.appendChild(s);
1795
2225
  ul.appendChild(li);
1796
2226
  });
@@ -1809,6 +2239,32 @@
1809
2239
  // den eben gelegten Link sonst stumm.
1810
2240
  var vergebenJeHarness = {};
1811
2241
  var hinweiseAlle = [];
2242
+ // Bundles zuerst, je Harness, in dem sie noch nicht hängen (E69): ein Bundle gehört zu genau
2243
+ // einem Ziel, unter „Alle" also einmal je beteiligtem Harness.
2244
+ for (var bj = 0; bj < bundleIndizes.length; bj++) {
2245
+ var bundleAlle = BUNDLES[bundleIndizes[bj]];
2246
+ for (var bt = 0; bt < teile.length; bt++) {
2247
+ var zb = teile[bt];
2248
+ if (bundleHaengtIn([zb], bundleAlle) > 0) continue;
2249
+ try {
2250
+ var ergAlle = await rufe(function () {
2251
+ return window.hmgr.bundleInstallieren({
2252
+ bundle: bundleAlle.name,
2253
+ harness: zb.ziel.harness,
2254
+ ort: zb.ziel.ort,
2255
+ quelle: bundleAlle.quelleText,
2256
+ });
2257
+ });
2258
+ hinweiseAlle.push(ergAlle.hinweis);
2259
+ ergAlle.gelegt.concat(ergAlle.uebernommen).forEach(function (n) {
2260
+ vergebenJeHarness[zb.ziel.harness + '\u0000' + n] = true;
2261
+ });
2262
+ zeilenErgebnis.push({ ok: true, bundle: bundleAlle, harness: zb.ziel.harness, erg: ergAlle });
2263
+ } catch (fehler) {
2264
+ zeilenErgebnis.push({ ok: false, bundle: bundleAlle, harness: zb.ziel.harness, fehler: fehler.message });
2265
+ }
2266
+ }
2267
+ }
1812
2268
  for (var m = 0; m < indizes.length; m++) {
1813
2269
  var sk = SKILLS[indizes[m]];
1814
2270
  for (var t = 0; t < teile.length; t++) {
@@ -1891,15 +2347,34 @@
1891
2347
  if (c.aktualisierung.ergebnis === 'aktualisiert') quellen[quelleKurz(c.checkout.quelle)] = true;
1892
2348
  });
1893
2349
  var neue = Object.keys(quellen).length;
2350
+ // Skill-Ebene (E70): Namen über alle Checkouts, doppelte zählen einmal. Damit sagt der
2351
+ // Standknopf „5 Skills aktualisiert" statt nur „1 Quelle mit Neuem".
2352
+ var geaenderteSkills = {};
2353
+ ergebnis.checkouts.forEach(function (c) {
2354
+ (c.geaenderteSkills || []).forEach(function (name) { geaenderteSkills[name] = true; });
2355
+ });
2356
+ var skillZahl = Object.keys(geaenderteSkills).length;
2357
+ // Ein nachgezogenes Bundle ist Neues wie ein neuer Commit (E69, F64=O1): sonst bliebe der
2358
+ // Standknopf still, obwohl sich am Ziel gerade Links geändert haben.
2359
+ var bundlesNeu = (ergebnis.bundles || []).filter(function (b) { return b.ergebnis === 'aktualisiert'; }).length;
1894
2360
  var fehlgeschlagen = ergebnis.checkouts.filter(function (c) { return c.aktualisierung.ergebnis === 'fehlgeschlagen'; }).length;
1895
2361
  var gesamt = ergebnis.checkouts.length;
1896
2362
  standFehlerZustand = fehlgeschlagen === 0 ? 'keine' : fehlgeschlagen === gesamt ? 'alle' : 'gemischt';
1897
2363
  // Von Hand läuft es in der Update-Ansicht, der Bericht ist schon sichtbar: nichts anzukündigen.
1898
- if (neue === 0 || !automatisch || !$('ansicht-update').hidden) return standRuhig();
2364
+ if ((neue === 0 && bundlesNeu === 0) || !automatisch || !$('ansicht-update').hidden) return standRuhig();
1899
2365
  var p = $('standtext');
1900
2366
  p.textContent = '';
1901
- p.appendChild(e('b', null, zahlwort(neue, 'Quelle', 'Quellen')));
1902
- p.appendChild(document.createTextNode(' mit Neuem'));
2367
+ // Ist die Skill-Zahl bekannt, sagt sie mehr als die Zahl der Quellen (E70). Sonst bleibt es
2368
+ // beim alten Signal: eine Quelle kann Neues haben, ohne dass ein Skill davon betroffen ist.
2369
+ if (skillZahl > 0) {
2370
+ p.appendChild(e('b', null, zahlwort(skillZahl, 'Skill', 'Skills')));
2371
+ p.appendChild(document.createTextNode(' aktualisiert'));
2372
+ } else {
2373
+ p.appendChild(e('b', null, neue > 0
2374
+ ? zahlwort(neue, 'Quelle', 'Quellen')
2375
+ : zahlwort(bundlesNeu, 'Bundle', 'Bundles')));
2376
+ p.appendChild(document.createTextNode(' mit Neuem'));
2377
+ }
1903
2378
  $('standknopf').classList.add('neu');
1904
2379
  $('standknopf').textContent = 'Ansehen';
1905
2380
  }
@@ -1939,6 +2414,35 @@
1939
2414
  });
1940
2415
  window.hmgr.onAktualisierenAnstossen(function () { aktualisierenLauf(true); });
1941
2416
 
2417
+ // Stilles Neuladen bei Änderungen außerhalb der GUI (E62, Fokus-Wechsel und Datei-Watcher): nur
2418
+ // ladeAlles, keine Meldung, kein Spinner-Zwang. Nachrutsch-sicher: höchstens ein Durchlauf läuft,
2419
+ // höchstens ein weiterer ist vorgemerkt, ein laufender Durchlauf wird nie von einem zweiten
2420
+ // überholt. Fehler landen wie sonst über rufe() in der Meldungsleiste, kein stiller Absturz.
2421
+ var stillerReloadLaeuft = false;
2422
+ var stillerReloadVorgemerkt = false;
2423
+ async function stillerReload() {
2424
+ // Läuft gerade ein Aktualisieren-Lauf, wird nicht parallel geladen: er endet ohnehin mit
2425
+ // ladeAlles und nimmt die Änderung mit. Sonst bauten zwei Läufe gleichzeitig die Ansicht auf.
2426
+ if ($('updatestart').disabled) return;
2427
+ if (stillerReloadLaeuft) {
2428
+ stillerReloadVorgemerkt = true;
2429
+ return;
2430
+ }
2431
+ stillerReloadLaeuft = true;
2432
+ try {
2433
+ await ladeAlles();
2434
+ } catch (fehler) {
2435
+ // rufe() hat den Fehler schon gemeldet, hier bleibt nur: den Durchlauf nicht wiederholen.
2436
+ } finally {
2437
+ stillerReloadLaeuft = false;
2438
+ if (stillerReloadVorgemerkt) {
2439
+ stillerReloadVorgemerkt = false;
2440
+ void stillerReload();
2441
+ }
2442
+ }
2443
+ }
2444
+ window.hmgr.onNeuLadenAnstossen(function () { void stillerReload(); });
2445
+
1942
2446
  function baueBericht(ergebnis) {
1943
2447
  var box = $('bericht');
1944
2448
  box.textContent = '';
@@ -1957,7 +2461,11 @@
1957
2461
  : a.ergebnis === 'fehlgeschlagen' ? 'fehlgeschlagen (' + a.fehler + ')'
1958
2462
  : 'übersprungen (lokale Änderung)';
1959
2463
  li.appendChild(e('span', 'marke-' + art, text));
1960
- li.appendChild(quelleSpan('pfad', c.checkout.quelle, ' @ ' + c.checkout.ref));
2464
+ // Skill-Ebene an der Checkout-Zeile (E70): sonst steht hier nur, dass die Quelle neue
2465
+ // Commits hat, und wer sein eigenes Push sucht, findet es nicht wieder.
2466
+ var skills = c.geaenderteSkills || [];
2467
+ li.appendChild(quelleSpan('pfad', c.checkout.quelle, ' @ ' + c.checkout.ref
2468
+ + (skills.length > 0 ? ' · ' + zahlwort(skills.length, 'Skill', 'Skills') + ': ' + skills.join(', ') : '')));
1961
2469
  cul.appendChild(li);
1962
2470
  });
1963
2471
  checkoutBlock.appendChild(cul);
@@ -1977,6 +2485,33 @@
1977
2485
  box.appendChild(pluginBlock);
1978
2486
  }
1979
2487
 
2488
+ // Bundles im selben Lauf (E69, F64=O1): eine geänderte Definition wird beim Aktualisieren
2489
+ // nachgezogen, eine Zeile je Bundle im Stil der Checkout-Zeilen darüber.
2490
+ var bundleEintraege = ergebnis.bundles || [];
2491
+ if (bundleEintraege.length > 0) {
2492
+ var bundleBlock = e('div', 'block');
2493
+ bundleBlock.appendChild(e('span', 'etikett', 'Bundles'));
2494
+ var bul = e('ul');
2495
+ bundleEintraege.forEach(function (b) {
2496
+ var li = e('li');
2497
+ var text = b.ergebnis === 'aktualisiert' ? 'aktualisiert'
2498
+ : b.ergebnis === 'unveraendert' ? 'unverändert'
2499
+ : 'fehlgeschlagen (' + (b.fehler || '').split('\n')[0] + ')';
2500
+ li.appendChild(e('span', b.ergebnis === 'fehlgeschlagen' ? 'marke-aus' : 'marke-ok', text));
2501
+ // Was sich geändert hat, steht hinter dem Namen; ein Fehlschlag sagt, dass nichts geschah.
2502
+ var teile = [];
2503
+ if (b.gelegt.length > 0) teile.push('+ ' + b.gelegt.join(', '));
2504
+ if (b.entfernt.length > 0) teile.push('− ' + b.entfernt.join(', '));
2505
+ if (b.neueQuellen.length > 0) teile.push('neue Quellen: ' + b.neueQuellen.length);
2506
+ if (b.ergebnis === 'fehlgeschlagen') teile.push('bleibt auf dem alten Stand');
2507
+ li.appendChild(e('span', 'pfad', b.bundle + ' · ' + harnessName(b.ziel.harness) + ' · ' + ortName(b.ziel.ort)
2508
+ + (teile.length > 0 ? ' · ' + teile.join(' · ') : '')));
2509
+ bul.appendChild(li);
2510
+ });
2511
+ bundleBlock.appendChild(bul);
2512
+ box.appendChild(bundleBlock);
2513
+ }
2514
+
1980
2515
  if (ergebnis.aufraeumen.verwaiste.length > 0) {
1981
2516
  var raeumBlock = e('div', 'block entfernt');
1982
2517
  raeumBlock.appendChild(e('span', 'etikett', 'Verwaiste Checkouts'));
@@ -2030,6 +2565,391 @@
2030
2565
  });
2031
2566
  }
2032
2567
 
2568
+ /* ================= MCP (E61-Folgerunde, F46: reiner Katalog) ================= */
2569
+ /* Der Katalog aus ~/.hmgr/mcp.yaml wird hier nicht bearbeitet, hmgr liest ihn nur: kein Anlegen,
2570
+ kein Ändern, nur Einhängen (entwurf/gui.html). Abweichung vom Entwurf (F43): kein Erfolgsdialog
2571
+ und kein Wirkungs-Vermerk an der Orts-Zeile, Erfolg läuft über die bestehende Meldungsleiste
2572
+ (melde()), bei Claude Code mit dem Hinweis des Kerns zur nächsten Sitzung angehängt, bei Hermes
2573
+ ohne (der Kern liefert den Hinweis nur für Claude Code).
2574
+ F46 („die Ziele gehören nicht in die MCP, sondern die MCP in die Ziele"): wo ein Server hängt und
2575
+ das Aushängen zeigen seither nur noch die Ziel-Karten (mcpAbschnitt weiter unten), diese Ansicht
2576
+ bleibt reiner Katalog — Name, Beschreibung, Homepage, Konfigblock, F39-Warnzeile, Notizen,
2577
+ Einhängen. mcpOrtDatei/-Scope/-Spur bleiben hier stehen, sie tragen weiterhin den Einhängen-
2578
+ Dialog (baueMcpWahl) und den Aushängen-Text (frageMcpAus), beide von den Ziel-Karten geteilt. */
2579
+
2580
+ /* Wohin der Eintrag geschrieben wird, rein zur Anzeige: Claude Code führt Projekt- wie globale
2581
+ Server in ~/.claude.json (die geteilte .mcp.json der Projektwurzel bleibt unangetastet, E57),
2582
+ Hermes je Ort in seiner config.yaml. */
2583
+ function mcpOrtDatei(ziel) {
2584
+ if (ziel.harness === 'hermes')
2585
+ return ziel.ort.art === 'profil'
2586
+ ? pfadSchreibweise('~/.hermes/profiles/' + ziel.ort.name + '/config.yaml')
2587
+ : pfadSchreibweise('~/.hermes/config.yaml');
2588
+ return pfadSchreibweise('~/.claude.json');
2589
+ }
2590
+ function mcpOrtScope(ziel) {
2591
+ if (ziel.harness !== 'claude-code') return null;
2592
+ return ziel.ort.art === 'arbeitsverzeichnis' ? 'Scope local' : 'Scope user';
2593
+ }
2594
+ /** Was in der Ort-Zeile hinter dem Namen steht: die Datei mit dem Scope. Anders als im Entwurf
2595
+ auch beim Arbeitsverzeichnis, denn der Ortsname der App trägt den Pfad schon (`ortName`); im
2596
+ Entwurf hieß er nur „Arbeitsverzeichnis", weshalb der Pfad dort hier stand. Jede Angabe
2597
+ einmal: Pfad im Namen, Datei und Scope daneben. */
2598
+ function mcpOrtSpur(ziel) {
2599
+ var scope = mcpOrtScope(ziel);
2600
+ return mcpOrtDatei(ziel) + (scope ? ' · ' + scope : '');
2601
+ }
2602
+
2603
+ function mcpKonfigArt(konfig) { return 'command' in konfig ? 'stdio' : 'HTTP'; }
2604
+ /** Der Konfigblock im Wortlaut, so wie er in der mcp.yaml steht: die ${VAR}-Verweise darin bleiben
2605
+ sichtbar, nur sie sind der Grund für die Warnzeile darunter. Nie ein Klartext-Secret (F41). */
2606
+ function mcpKonfigWortlaut(konfig) {
2607
+ if ('command' in konfig) {
2608
+ var text = [konfig.command].concat(konfig.args || []).join(' ');
2609
+ Object.keys(konfig.env || {}).forEach(function (k) { text += ' · ' + k + '=' + konfig.env[k]; });
2610
+ return text;
2611
+ }
2612
+ var url = konfig.url;
2613
+ Object.keys(konfig.headers || {}).forEach(function (k) { url += ' · ' + k + ': ' + konfig.headers[k]; });
2614
+ return url;
2615
+ }
2616
+
2617
+ /** Die Homepage eines Servers, dasselbe Muster wie eine Quelle (E60): der Name bleibt ruhig, das
2618
+ Signalorange trägt nur das Pfeilsymbol. Öffnet über denselben Kanal wie eine Quelle (E60): eine
2619
+ http(s)-Adresse geht in den Browser. */
2620
+ function mcpHeimLink(url) {
2621
+ var a = document.createElement('a');
2622
+ a.className = 'qlink';
2623
+ a.href = '#';
2624
+ a.title = url;
2625
+ a.setAttribute('aria-label', url + ' im Browser öffnen');
2626
+ a.appendChild(document.createTextNode(url));
2627
+ a.appendChild(externSvg());
2628
+ a.addEventListener('click', function (ev) {
2629
+ ev.preventDefault();
2630
+ window.hmgr.quelleOeffnen(url).catch(function () {});
2631
+ });
2632
+ return a;
2633
+ }
2634
+
2635
+ /** F39: die Prüf-Funktion ist dieselbe wie beim tatsächlichen Einhängen, hier aber nur gegen die
2636
+ Prozess-Umgebung von hmgr (der Main-Prozess liefert `fehlendeVariablen` schon fertig mit der
2637
+ Übersicht, ohne den ortsbezogenen .env-Abgleich, der bleibt dem Einhängen selbst vorbehalten
2638
+ und kommt dann als `warnung` zurück). Wiederverwendet die bestehende .ruhezeile-Optik. */
2639
+ function mcpWarnZeile(fehlend) {
2640
+ var mehrzahl = fehlend.length > 1;
2641
+ var namen = fehlend.map(function (v) { return '${' + v + '}'; }).join(', ');
2642
+ var rz = e('div', 'ruhezeile');
2643
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2644
+ svg.setAttribute('viewBox', '0 0 16 16');
2645
+ svg.setAttribute('stroke-width', '1.6');
2646
+ svg.setAttribute('aria-hidden', 'true');
2647
+ svg.innerHTML = '<circle cx="8" cy="8" r="6.5"/><path d="M8 4.5v4M8 10.5v.5"/>';
2648
+ rz.appendChild(svg);
2649
+ rz.appendChild(e('p', null, namen + (mehrzahl ? ' sind' : ' ist') + ' in der Umgebung von hmgr nicht gesetzt. Ob '
2650
+ + (mehrzahl ? 'die Variablen' : 'die Variable') + ' beim Start des Harness gesetzt ' + (mehrzahl ? 'sind' : 'ist')
2651
+ + ', kann hmgr nicht wissen.'));
2652
+ return rz;
2653
+ }
2654
+
2655
+ /** Notizen sind das Langsame am Eintrag (entwurf/gui.html): zugeklappt, öffnen sich nur, wenn
2656
+ jemand sie sucht. Der Wortlaut bleibt, wie er in der mcp.yaml steht, Zeilenumbrüche
2657
+ eingeschlossen (weiss-raum: pre-line in styles.css). */
2658
+ function mcpNotizenBlock(text) {
2659
+ var d = document.createElement('details');
2660
+ d.className = 'notizen';
2661
+ var s = document.createElement('summary');
2662
+ var pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
2663
+ pfeil.setAttribute('viewBox', '0 0 16 16');
2664
+ pfeil.setAttribute('class', 'keil');
2665
+ pfeil.setAttribute('fill', 'none');
2666
+ pfeil.setAttribute('stroke', 'currentColor');
2667
+ pfeil.setAttribute('stroke-width', '1.7');
2668
+ pfeil.setAttribute('stroke-linejoin', 'round');
2669
+ pfeil.setAttribute('aria-hidden', 'true');
2670
+ pfeil.innerHTML = '<path d="M4.3 2.5 7.8 6 4.3 9.5"/>';
2671
+ s.appendChild(pfeil);
2672
+ s.appendChild(document.createTextNode('Notizen'));
2673
+ d.appendChild(s);
2674
+ d.appendChild(e('p', null, text));
2675
+ return d;
2676
+ }
2677
+
2678
+ function mcpKarte(k) {
2679
+ var eintrag = k.eintrag;
2680
+ var kEl = e('div', 'mcp');
2681
+
2682
+ var kopf = e('div', 'mcpkopf');
2683
+ kopf.appendChild(e('b', null, eintrag.name));
2684
+ if (eintrag.homepage) kopf.appendChild(mcpHeimLink(eintrag.homepage));
2685
+ var ein = e('button', 'knopf leise klein', 'Einhängen …');
2686
+ ein.type = 'button';
2687
+ ein.setAttribute('aria-label', eintrag.name + ' einhängen');
2688
+ ein.addEventListener('click', function () { oeffneMcpEinhaengen(k); });
2689
+ kopf.appendChild(ein);
2690
+ kEl.appendChild(kopf);
2691
+
2692
+ kEl.appendChild(e('p', 'was', eintrag.beschreibung));
2693
+
2694
+ var konf = e('div', 'kette mcpkonfig');
2695
+ konf.appendChild(e('span', 'glied', mcpKonfigArt(eintrag.konfig)));
2696
+ konf.appendChild(e('span', 'mono', mcpKonfigWortlaut(eintrag.konfig)));
2697
+ kEl.appendChild(konf);
2698
+
2699
+ if (k.fehlendeVariablen.length > 0) kEl.appendChild(mcpWarnZeile(k.fehlendeVariablen));
2700
+ if (eintrag.notizen) kEl.appendChild(mcpNotizenBlock(eintrag.notizen));
2701
+
2702
+ return kEl;
2703
+ }
2704
+
2705
+ /** Ohne Katalog steht hier kein leerer Kasten, sondern der Weg zur Datei: anlegen lässt sich ein
2706
+ Eintrag nur dort, hmgr schreibt sie nie (E61). */
2707
+ function mcpEinladung() {
2708
+ var kasten = e('div', 'startfeld mcpstart');
2709
+ var p1 = e('p');
2710
+ p1.appendChild(document.createTextNode('Noch kein Server im Katalog. hmgr liest ihn aus '));
2711
+ p1.appendChild(e('span', 'mono', pfadSchreibweise('~/.hmgr/mcp.yaml')));
2712
+ p1.appendChild(document.createTextNode(' und schreibt die Datei nie.'));
2713
+ kasten.appendChild(p1);
2714
+ var p2 = e('p');
2715
+ p2.appendChild(document.createTextNode('Trag einen Server dort von Hand ein, oder lass ihn von dem LLM eintragen, das ihn ohnehin gerade einrichtet. Das Format steht in '));
2716
+ p2.appendChild(e('span', 'mono', 'hmgr help mcp'));
2717
+ p2.appendChild(document.createTextNode('.'));
2718
+ kasten.appendChild(p2);
2719
+ return kasten;
2720
+ }
2721
+
2722
+ function baueMcp() {
2723
+ var w = $('mcpliste');
2724
+ w.textContent = '';
2725
+ if (MCP_KATALOG.length === 0) w.appendChild(mcpEinladung());
2726
+ else MCP_KATALOG.forEach(function (k) { w.appendChild(mcpKarte(k)); });
2727
+ $('navzahlmcp').textContent = MCP_KATALOG.length;
2728
+ }
2729
+
2730
+ /* ---------------- Einhängen (F42) ---------------- */
2731
+
2732
+ var mcpLaufend = null;
2733
+ var mcpPlaetzeAuswahl = [];
2734
+ // Sperre gegen Doppel-Anstoß (wie beim Hinzufügen einer Quelle, E54): zwischen Klick und Dialog
2735
+ // liegt ein IPC-Aufruf. Ein zweiter Klick in dieser Lücke riefe showModal() an einem schon
2736
+ // offenen Dialog auf, und das ist ein Fehler, kein zweiter Dialog.
2737
+ var mcpWahlOeffnet = false;
2738
+
2739
+ async function oeffneMcpEinhaengen(k) {
2740
+ if (mcpWahlOeffnet || $('d-mcp').open) return;
2741
+ mcpWahlOeffnet = true;
2742
+ try {
2743
+ await baueMcpWahl(k);
2744
+ } finally {
2745
+ mcpWahlOeffnet = false;
2746
+ }
2747
+ }
2748
+
2749
+ async function baueMcpWahl(k) {
2750
+ mcpLaufend = k;
2751
+ $('dm-name').textContent = k.eintrag.name;
2752
+ $('dm-text').textContent = 'Ein Ort je Einhängen. agents kennt keine MCP-Server und steht deshalb nicht zur Wahl.';
2753
+ var box = $('dm-orte');
2754
+ box.textContent = '';
2755
+ // Die Plätze kommen aus dem Kern (F42, E16): die Fähigkeit hängt am Adapter, nicht am
2756
+ // Harness-Namen, hier wird nicht selbst entschieden, wer welche Ebene kennt.
2757
+ var plaetze = await rufe(function () { return window.hmgr.mcpPlaetze(); });
2758
+ mcpPlaetzeAuswahl = plaetze;
2759
+ plaetze.forEach(function (ziel, i) {
2760
+ var schon = k.eingehaengt.some(function (z) { return zielKey(z) === zielKey(ziel); });
2761
+ var aktiv = harnessAktiv(ziel.harness);
2762
+ var label = e('label', 'wahlzeile');
2763
+ var input = document.createElement('input');
2764
+ input.type = 'radio';
2765
+ input.name = 'mcport';
2766
+ input.value = String(i);
2767
+ input.disabled = schon || !aktiv;
2768
+ label.appendChild(input);
2769
+
2770
+ var txt = e('span');
2771
+ txt.appendChild(beschriftet(e('b', null, zielTitel({ ziel: ziel })), ziel.harness));
2772
+ txt.appendChild(e('span', 'mono', mcpOrtSpur(ziel)));
2773
+
2774
+ if (schon) {
2775
+ // F46: die Orte-Liste am Katalog-Eintrag gibt es nicht mehr, der Weg zum Aushängen führt
2776
+ // über den Entfernen-Knopf im MCP-Abschnitt der Ziel-Karte.
2777
+ txt.appendChild(e('span', 'notiz', 'Hängt hier schon. Zum Aushängen den Entfernen-Knopf am MCP-Abschnitt der Ziel-Karte benutzen.'));
2778
+ } else if (!aktiv) {
2779
+ txt.appendChild(e('span', 'notiz', harnessName(ziel.harness) + ' ist inaktiv. Schalt den Harness in den Einstellungen wieder ein, um hier einzuhängen.'));
2780
+ } else if (ziel.ort.art === 'arbeitsverzeichnis') {
2781
+ // E64: die Datei bleibt beim Einhängen unangetastet, aber nicht mehr grundsätzlich — der
2782
+ // Übernehmen-Knopf an der Ziel-Karte fasst sie auf ausdrücklichen Auftrag an.
2783
+ txt.appendChild(e('span', 'notiz', 'Gilt nur für dich. Eingehängt wird neben der geteilten .mcp.json des Projekts, die bleibt dabei unverändert.'));
2784
+ } else if (ziel.harness === 'hermes' && ziel.ort.art === 'home') {
2785
+ txt.appendChild(e('span', 'notiz', 'Hermes kennt keine Projektebene für MCP-Server, hier geht nur global.'));
2786
+ }
2787
+ label.appendChild(txt);
2788
+ box.appendChild(label);
2789
+ });
2790
+ zeige($('d-mcp'));
2791
+ }
2792
+
2793
+ $('d-mcp').addEventListener('submit', function (ev) {
2794
+ if (ev.submitter && ev.submitter.value === 'los') {
2795
+ var gewaehlt = $('dm-orte').querySelector('input:checked');
2796
+ if (!gewaehlt) ev.preventDefault();
2797
+ }
2798
+ });
2799
+
2800
+ nachDialog($('d-mcp'), async function (wert) {
2801
+ var k = mcpLaufend;
2802
+ mcpLaufend = null;
2803
+ if (wert !== 'los' || !k) return;
2804
+ var gewaehlt = $('dm-orte').querySelector('input:checked');
2805
+ if (!gewaehlt) return;
2806
+ var ziel = mcpPlaetzeAuswahl[+gewaehlt.value];
2807
+ var ergebnis = await rufe(function () {
2808
+ return window.hmgr.mcpEinhaengen({ name: k.eintrag.name, harness: ziel.harness, ort: ziel.ort });
2809
+ });
2810
+ await ladeAlles();
2811
+ // Erfolg über die Meldungsleiste (F43): kein Erfolgsdialog, kein Vermerk an der Orts-Zeile. Der
2812
+ // Kern liefert den Hinweis zur Wirkung nur bei Claude Code, Hermes lädt selbst nach.
2813
+ var teile = [k.eintrag.name + ' hängt jetzt in ' + zielTitel({ ziel: ziel }) + '.'];
2814
+ if (ergebnis.hinweis) teile.push(ergebnis.hinweis);
2815
+ if (ergebnis.warnung) teile.push(ergebnis.warnung);
2816
+ melde(teile.join('\n'));
2817
+ });
2818
+
2819
+ /* ---------------- Aushängen ---------------- */
2820
+
2821
+ var mcpAusKontext = null;
2822
+ function frageMcpAus(eintrag, ziel) {
2823
+ mcpAusKontext = { eintrag: eintrag, ziel: ziel };
2824
+ $('dma-titel').textContent = eintrag.name + ' aus ' + zielTitel({ ziel: ziel }) + ' aushängen?';
2825
+ $('dma-text').textContent = 'Der Eintrag verschwindet aus ' + mcpOrtDatei(ziel)
2826
+ + '. Der Katalog-Eintrag bleibt, du kannst ihn jederzeit wieder einhängen.';
2827
+ zeige($('d-mcpaus'));
2828
+ }
2829
+ nachDialog($('d-mcpaus'), async function (wert) {
2830
+ var kontext = mcpAusKontext;
2831
+ mcpAusKontext = null;
2832
+ if (wert !== 'ja' || !kontext) return;
2833
+ var ergebnis = await rufe(function () {
2834
+ return window.hmgr.mcpAushaengen({ name: kontext.eintrag.name, harness: kontext.ziel.harness, ort: kontext.ziel.ort });
2835
+ });
2836
+ await ladeAlles();
2837
+ var teile = [kontext.eintrag.name + ' aus ' + zielTitel({ ziel: kontext.ziel }) + ' ausgehängt.'];
2838
+ if (ergebnis.hinweis) teile.push(ergebnis.hinweis);
2839
+ melde(teile.join('\n'));
2840
+ });
2841
+
2842
+ /* ---------------- Übernehmen aus der geteilten .mcp.json (E64) ---------------- */
2843
+ /* Der einzige Weg, auf dem hmgr die geteilte Datei anfasst, deshalb eine ausdrückliche Rückfrage:
2844
+ der Eintrag wandert in die private Konfiguration und verschwindet aus der Datei — für alle, die
2845
+ sie teilen. Der Name muss nicht im Katalog stehen, übernommen wird der Block aus der Datei. */
2846
+ var mcpUebernahmeKontext = null;
2847
+ function frageMcpUebernahme(name, ziel, datei) {
2848
+ mcpUebernahmeKontext = { name: name, ziel: ziel };
2849
+ $('dmu-titel').textContent = name + ' übernehmen?';
2850
+ $('dmu-text').textContent = name + ' wird in deine private Konfiguration für '
2851
+ + zielTitel({ ziel: ziel }) + ' übernommen und aus ' + (datei || '.mcp.json')
2852
+ + ' entfernt. Ist die Datei mit dem Team geteilt, verlieren die anderen den Eintrag, sobald die '
2853
+ + 'Änderung committet ist.';
2854
+ zeige($('d-mcpueb'));
2855
+ }
2856
+ nachDialog($('d-mcpueb'), async function (wert) {
2857
+ var kontext = mcpUebernahmeKontext;
2858
+ mcpUebernahmeKontext = null;
2859
+ if (wert !== 'ja' || !kontext) return;
2860
+ var ergebnis = await rufe(function () {
2861
+ return window.hmgr.mcpUebernehmen({ name: kontext.name, harness: kontext.ziel.harness, ort: kontext.ziel.ort });
2862
+ });
2863
+ await ladeAlles();
2864
+ var teile = [kontext.name + ' gehört jetzt zu deiner privaten Konfiguration für '
2865
+ + zielTitel({ ziel: kontext.ziel }) + ' und steht nicht mehr in ' + ergebnis.datei + '.'];
2866
+ if (ergebnis.hinweis) teile.push(ergebnis.hinweis);
2867
+ melde(teile.join('\n'));
2868
+ });
2869
+
2870
+ /* ---------------- MCP hinzufügen (E61-GUI-Nachtrag) ---------------- */
2871
+ /* Gegenrichtung zu oeffneMcpEinhaengen: von der Ziel-Karte aus, Mehrfachauswahl über den ganzen
2872
+ Katalog, dasselbe Dialogmuster wie „Skills hinzufügen" (E47) statt der Ortswahl von d-mcp. */
2873
+ var mcpHinzufuegenKontext = null;
2874
+ function dmhBilanzAktualisieren() {
2875
+ var n = $('dmh-liste').querySelectorAll('input:checked').length;
2876
+ $('dmh-bilanz').textContent = zahlwort(n, 'ausgewählt', 'ausgewählt');
2877
+ }
2878
+ function oeffneMcpHinzufuegen(ziel) {
2879
+ mcpHinzufuegenKontext = { ziel: ziel };
2880
+ $('dmh-ziel').textContent = zielTitel({ ziel: ziel });
2881
+ var box = $('dmh-liste');
2882
+ box.textContent = '';
2883
+ // Ohne Katalog stünde hier ein leerer Kasten mit einem Knopf, der nichts tut: hmgr legt
2884
+ // MCP-Einträge nie selbst an (E61), also nennt der Dialog denselben Weg wie die MCP-Ansicht.
2885
+ if (MCP_KATALOG.length === 0) {
2886
+ box.appendChild(e('p', 'ohne', 'Noch kein Server im Katalog. hmgr liest ihn aus '
2887
+ + pfadSchreibweise('~/.hmgr/mcp.yaml') + ' und schreibt die Datei nie.'));
2888
+ }
2889
+ MCP_KATALOG.forEach(function (k, i) {
2890
+ var schon = k.eingehaengt.some(function (z) { return zielKey(z) === zielKey(ziel); });
2891
+ var label = e('label', 'wahlzeile');
2892
+ var input = document.createElement('input');
2893
+ input.type = 'checkbox';
2894
+ input.value = String(i);
2895
+ input.disabled = schon;
2896
+ input.addEventListener('change', dmhBilanzAktualisieren);
2897
+ label.appendChild(input);
2898
+ var txt = e('span');
2899
+ txt.appendChild(e('b', null, k.eintrag.name));
2900
+ if (k.eintrag.beschreibung) txt.appendChild(e('span', 'mono', k.eintrag.beschreibung));
2901
+ if (schon) txt.appendChild(e('span', 'notiz', 'Hängt hier schon.'));
2902
+ label.appendChild(txt);
2903
+ box.appendChild(label);
2904
+ });
2905
+ dmhBilanzAktualisieren();
2906
+ zeige($('d-mcp-hinzufuegen'));
2907
+ }
2908
+
2909
+ $('d-mcp-hinzufuegen').addEventListener('submit', function (ev) {
2910
+ if (ev.submitter && ev.submitter.value === 'los') {
2911
+ var gewaehlt = Array.from($('dmh-liste').querySelectorAll('input:checked'));
2912
+ if (gewaehlt.length === 0) ev.preventDefault();
2913
+ }
2914
+ });
2915
+
2916
+ nachDialog($('d-mcp-hinzufuegen'), async function (wert) {
2917
+ if (wert !== 'los' || mcpHinzufuegenKontext === null) return;
2918
+ var ziel = mcpHinzufuegenKontext.ziel;
2919
+ var indizes = Array.from($('dmh-liste').querySelectorAll('input:checked')).map(function (i) { return +i.value; });
2920
+ var erfolge = 0;
2921
+ var hinweise = [];
2922
+ var fehlschlaege = [];
2923
+ for (var n = 0; n < indizes.length; n++) {
2924
+ var k = MCP_KATALOG[indizes[n]];
2925
+ try {
2926
+ var ergebnis = await rufe(function () {
2927
+ return window.hmgr.mcpEinhaengen({ name: k.eintrag.name, harness: ziel.harness, ort: ziel.ort });
2928
+ });
2929
+ erfolge++;
2930
+ if (ergebnis.hinweis && hinweise.indexOf(ergebnis.hinweis) < 0) hinweise.push(ergebnis.hinweis);
2931
+ // F39-Warnungen gesammelt im Erfolgs-Toast statt je Einhängen einzeln zu melden.
2932
+ if (ergebnis.warnung) hinweise.push(k.eintrag.name + ': ' + ergebnis.warnung);
2933
+ } catch (fehler) {
2934
+ // rufe() hat den Fehler zwar schon in die Meldungsleiste gesetzt, die Schluss-Meldung unten
2935
+ // überschreibt sie aber gleich wieder. Ein Teilerfolg muss beide Seiten nennen, sonst behauptet
2936
+ // der Toast einen Lauf ohne Fehler. Die übrigen Einträge laufen weiter.
2937
+ fehlschlaege.push(k.eintrag.name + ': ' + fehler.message);
2938
+ }
2939
+ }
2940
+ ZIEL_OFFEN[kartenKey(ziel)] = true;
2941
+ await ladeAlles();
2942
+ var zeilen = [erfolge > 0
2943
+ ? zahlwort(erfolge, 'MCP-Server hängt', 'MCP-Server hängen') + ' jetzt in ' + zielTitel({ ziel: ziel }) + '.'
2944
+ : 'Nichts eingehängt in ' + zielTitel({ ziel: ziel }) + '.'];
2945
+ hinweise.forEach(function (h) { zeilen.push(h); });
2946
+ if (fehlschlaege.length > 0) {
2947
+ zeilen.push(zahlwort(fehlschlaege.length, 'Eintrag ließ', 'Einträge ließen') + ' sich nicht einhängen:');
2948
+ fehlschlaege.forEach(function (f) { zeilen.push(f); });
2949
+ }
2950
+ melde(zeilen.join('\n'));
2951
+ });
2952
+
2033
2953
  /* ================= EINSTELLUNGEN ================= */
2034
2954
 
2035
2955
  /** Ziele eines Harness-Typs mit mindestens einem eigenen Link oder liegen gebliebener
@@ -2559,7 +3479,11 @@
2559
3479
 
2560
3480
  // Die Fragen kommen fertig formuliert aus dem Kern; für die Anzeige werden Home-Pfade darin
2561
3481
  // wie überall sonst auf ~ gekürzt (nur Anzeige, die Antwort transportiert keinen Text zurück).
2562
- function frageKurz(text) { return HOME ? text.split(HOME + '/').join('~/') : text; }
3482
+ /** Wie pfadKurz, aber mitten im Fließtext einer Rückfrage — und ebenso mit beiden Trennern (E68). */
3483
+ function frageKurz(text) {
3484
+ if (!HOME) return text;
3485
+ return text.split(HOME + '/').join('~/').split(HOME + '\\').join('~\\');
3486
+ }
2563
3487
  window.hmgr.onRueckfrage(function (nachricht) {
2564
3488
  if (nachricht.art === 'bestaetigen') {
2565
3489
  $('db-frage').textContent = frageKurz(nachricht.frage);
@@ -2607,5 +3531,5 @@
2607
3531
 
2608
3532
  /* ---------------- Start ---------------- */
2609
3533
 
2610
- ladeHome().then(ladeAlles).catch(function (fehler) { melde(fehler.message || String(fehler)); });
3534
+ ladeUmgebung().then(ladeAlles).catch(function (fehler) { melde(fehler.message || String(fehler)); });
2611
3535
  })();