hmgr 0.0.6 → 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.
@@ -0,0 +1,87 @@
1
+ // Liest eine Bundle-Definition aus einer Quelle (E69). hmgr schreibt solche Dateien nie.
2
+ import { parseDocument } from 'yaml';
3
+ import { BundleDateiFehler } from "../kern/modell.js";
4
+ import { quelleAusText } from "./quelle-text.js";
5
+ /** Konvention für Bundle-Definitionen in einer Quelle (E69): `bundles/<name>.yaml`. */
6
+ export const BUNDLE_VERZEICHNIS = 'bundles';
7
+ /**
8
+ * Ob ein Pfad in der Quelle eine Bundle-Definition ist: eine Datei direkt unter `bundles/`, mit
9
+ * Endung `.yaml` oder `.yml`. Nichts Verschachteltes — die Konvention nennt genau eine Ebene, und
10
+ * ein Unterordner wäre eine stille zweite Bedeutung.
11
+ */
12
+ export function istBundleDatei(pfad) {
13
+ return /^bundles\/[^/]+\.(?:yaml|yml)$/.test(pfad);
14
+ }
15
+ /** Der Dateiname ohne Verzeichnis und Endung: Ersatzname, wenn das Feld `name` fehlt. */
16
+ export function bundleDateiName(pfad) {
17
+ return pfad.replace(/^bundles\//, '').replace(/\.(?:yaml|yml)$/, '');
18
+ }
19
+ function fehlertext(fehler) {
20
+ return fehler instanceof Error ? fehler.message : String(fehler);
21
+ }
22
+ function istObjekt(wert) {
23
+ return typeof wert === 'object' && wert !== null && !Array.isArray(wert);
24
+ }
25
+ /** Erlaubte Felder auf oberster Ebene und je Mitglied. Alles andere ist ein Fehler mit Feldnamen. */
26
+ const FELDER = new Set(['name', 'description', 'skills']);
27
+ const MITGLIED_FELDER = new Set(['source', 'path']);
28
+ function mitgliedLesen(datei, roh, i) {
29
+ if (!istObjekt(roh))
30
+ throw new BundleDateiFehler(datei, `skills[${i}] muss ein Objekt mit source und path sein`);
31
+ for (const feld of Object.keys(roh))
32
+ if (!MITGLIED_FELDER.has(feld))
33
+ throw new BundleDateiFehler(datei, `skills[${i}]: unbekanntes Feld „${feld}"`);
34
+ const source = roh['source'];
35
+ if (typeof source !== 'string' || source.trim().length === 0)
36
+ throw new BundleDateiFehler(datei, `skills[${i}].source muss die Adresse einer Quelle sein (URL oder Pfad)`);
37
+ const pfad = roh['path'];
38
+ if (typeof pfad !== 'string' || pfad.trim().length === 0)
39
+ throw new BundleDateiFehler(datei, `skills[${i}].path muss der Pfad des Skills in der Quelle sein`);
40
+ // Führende und abschließende Schrägstriche fallen weg: `skills/x/` und `skills/x` meinen denselben
41
+ // Ordner, und der Katalog führt ihn ohne (E11). Ein Wurzel-Skill (leerer Pfad) ist nicht
42
+ // adressierbar — er hieße in der Definition „", das ist kein Text und fliegt oben schon auf.
43
+ return { quelle: quelleAusText(source.trim()), pfad: pfad.trim().replace(/^\/+/, '').replace(/\/+$/, '') };
44
+ }
45
+ /**
46
+ * Eine Bundle-Definition aus dem YAML-Text lesen und validieren (E69). `datei` ist der Pfad in der
47
+ * Quelle, er dient als Ersatzname (ohne Verzeichnis und Endung), wenn das Feld `name` fehlt: der
48
+ * Dateiname ist Konvention, das Feld ist die Aussage des Autors — dieselbe Rangfolge wie beim
49
+ * Skill-Namen (E32). `quelle` ist die Quelle, in der die Datei liegt.
50
+ */
51
+ export function bundleLesen(text, datei, quelle) {
52
+ let daten;
53
+ try {
54
+ const dokument = parseDocument(text, { strict: true });
55
+ // `parseDocument` sammelt Syntaxfehler, statt zu werfen: ohne diese Prüfung käme eine kaputte
56
+ // Datei als halb gelesenes Objekt durch und der Nutzer bekäme eine Meldung über ein Feld statt
57
+ // über die Datei.
58
+ const erster = dokument.errors[0];
59
+ if (erster !== undefined)
60
+ throw new BundleDateiFehler(datei, `kaputtes YAML: ${erster.message}`);
61
+ daten = dokument.toJS();
62
+ }
63
+ catch (fehler) {
64
+ if (fehler instanceof BundleDateiFehler)
65
+ throw fehler;
66
+ throw new BundleDateiFehler(datei, `kaputtes YAML: ${fehlertext(fehler)}`);
67
+ }
68
+ if (!istObjekt(daten))
69
+ throw new BundleDateiFehler(datei, 'die Datei muss ein Objekt mit name, description und skills sein');
70
+ for (const feld of Object.keys(daten))
71
+ if (!FELDER.has(feld))
72
+ throw new BundleDateiFehler(datei, `unbekanntes Feld „${feld}"`);
73
+ const rohName = daten['name'];
74
+ if (rohName !== undefined && (typeof rohName !== 'string' || rohName.trim().length === 0))
75
+ throw new BundleDateiFehler(datei, 'name muss nicht leerer Text sein');
76
+ const name = typeof rohName === 'string' ? rohName.trim() : bundleDateiName(datei);
77
+ const beschreibung = daten['description'];
78
+ if (typeof beschreibung !== 'string' || beschreibung.trim().length === 0)
79
+ throw new BundleDateiFehler(datei, 'description muss nicht leerer Text sein');
80
+ const skills = daten['skills'];
81
+ if (!Array.isArray(skills))
82
+ throw new BundleDateiFehler(datei, 'skills muss eine Liste sein');
83
+ if (skills.length === 0)
84
+ throw new BundleDateiFehler(datei, 'skills darf nicht leer sein');
85
+ const mitglieder = skills.map((roh, i) => mitgliedLesen(datei, roh, i));
86
+ return { name, beschreibung: beschreibung.trim(), mitglieder, quelle, datei };
87
+ }
@@ -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";
@@ -253,6 +254,55 @@ export class GitQuellen {
253
254
  })),
254
255
  };
255
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
+ }
256
306
  async refs(quelle) {
257
307
  if (quelle.art === 'lokal')
258
308
  return [LOKAL_REF];
@@ -374,6 +424,104 @@ export class GitQuellen {
374
424
  await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
375
425
  return { ergebnis: 'aktualisiert', von, nach };
376
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
+ }
377
525
  /**
378
526
  * Checkout von der Platte nehmen. Ein Worktree steht im Basis-Clone eingetragen, ein blankes
379
527
  * Löschen des Verzeichnisses ließe den Eintrag stehen und ein späterer `worktree add` auf
@@ -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,7 +1,7 @@
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
5
  import { ohneBom } from "./datei-text.js";
6
6
  export class ZustandJson {
7
7
  pfad;
@@ -14,6 +14,9 @@ export class ZustandJson {
14
14
  return leererZustand();
15
15
  const daten = JSON.parse(ohneBom(roh));
16
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,
17
20
  quellen: (daten.quellen ?? []),
18
21
  arbeitsverzeichnisse: (daten.arbeitsverzeichnisse ?? []),
19
22
  bindungen: (daten.bindungen ?? []),
@@ -21,10 +24,14 @@ export class ZustandJson {
21
24
  aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
22
25
  // Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
23
26
  ignorierteEintraege: (daten.ignorierteEintraege ?? []),
27
+ // Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert.
28
+ bundleInstallationen: (daten.bundleInstallationen ?? []),
24
29
  };
25
30
  }
26
31
  async schreiben(zustand) {
27
32
  await mkdir(path.dirname(this.pfad), { recursive: true });
28
- 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`);
29
36
  }
30
37
  }
package/dist/cli/hilfe.js CHANGED
@@ -22,6 +22,10 @@ hmgr <kommando> [schalter]
22
22
  plugin add <name> Plugin in ein Ziel legen (installieren oder Skills verlinken)
23
23
  plugin remove <name> Plugin aus einem Ziel entfernen
24
24
  plugin list Plugins der Marketplaces aller Quellen
25
+ bundle add <name> Bundle als Ganzes in ein Ziel installieren
26
+ bundle remove <name> Bundle als Ganzes aus einem Ziel entfernen
27
+ bundle sync [name] Installierte Bundles gegen ihre Definition ziehen
28
+ bundle list Bundles der bekannten Quellen
25
29
  mcp add <name> MCP-Server aus dem Katalog in ein Ziel einhängen
26
30
  mcp remove <name> MCP-Server aus einem Ziel aushängen
27
31
  mcp migrate <name> MCP-Server aus der geteilten .mcp.json in den privaten Scope übernehmen
@@ -33,6 +37,7 @@ hmgr <kommando> [schalter]
33
37
  harness enable <name> Harness-Typ aktivieren, hmgr richtet ihn künftig ein
34
38
  harness disable <name> Harness-Typ deaktivieren, mit Angebot zum Säubern
35
39
  harness skills-dir <name> Verwalteten Skill-Ordner zeigen, festlegen oder entfernen
40
+ --version Versionsnummer von hmgr ausgeben
36
41
 
37
42
  hmgr help <kommando> zeigt Schalter, Beispiele und Zusammenhänge.`;
38
43
  const SOURCE = `hmgr source add <url|pfad> [--name <name>]
@@ -230,6 +235,87 @@ Häufige Fälle:
230
235
  kann nur die Quelle beheben.
231
236
 
232
237
  Siehe auch: hmgr help source, hmgr help sync`;
238
+ const BUNDLE = `hmgr bundle add <name> [schalter]
239
+ hmgr bundle remove <name> [schalter]
240
+ hmgr bundle sync [name] [schalter]
241
+ hmgr bundle list
242
+
243
+ Ein Bundle ist eine benannte Zusammenstellung von Skills über Quellen hinweg: was für ein Vorhaben
244
+ zusammengehört, liegt selten in einem Repo. Anders als ein Plugin (das lebt in einem Marketplace und
245
+ löst Abhängigkeiten nur dort auf) darf ein Bundle Skills aus beliebig vielen Quellen nennen.
246
+
247
+ Definiert wird es von Hand, als YAML-Datei in einer Quelle, Konvention bundles/<name>.yaml. hmgr
248
+ liest und validiert sie, schreibt sie nie — über die Quelle kommt sie versioniert auf alle
249
+ Maschinen. Es gibt keinen eigenen Katalog und keinen Pflegebefehl: bundle list zeigt, was beim
250
+ Quellen-Scan gefunden wurde.
251
+
252
+ Format der Datei:
253
+
254
+ name optional, sonst gilt der Dateiname ohne Endung
255
+ description Pflicht, kurzer Text
256
+ skills Pflicht, nicht leere Liste; je Eintrag source (URL oder Pfad der Quelle)
257
+ und path (Pfad des Skill-Ordners in dieser Quelle)
258
+
259
+ Beispiel bundles/wordpress.yaml:
260
+
261
+ name: wordpress
262
+ description: Alles für die WordPress-Arbeit
263
+ skills:
264
+ - source: git@gitea.example.com:tim/skills.git
265
+ path: skills/wp-core
266
+ - source: https://github.com/taquiri/skills
267
+ path: skills/tq-mailform
268
+
269
+ Ein Ref je Mitglied kennt die Definition nicht: der Link folgt dem Ref, mit dem die Quelle
270
+ eingerichtet ist. Angesprochen wird der Skill über seinen Pfad, nicht über seinen Namen — der Name
271
+ steht im Frontmatter und wird erst beim Lesen der Quelle bekannt.
272
+
273
+ Je Ziel ist das Bundle eine Einheit: add legt alle seine Links, remove nimmt alle wieder weg, sync
274
+ zieht Änderungen an der Definition nach. Nennt die Definition eine Quelle, die hmgr noch nicht
275
+ kennt, legt add sie an und sagt, welche neu sind. Lässt sich auch nur ein Mitglied nicht auflösen
276
+ oder liegt am Ziel ein Namenskonflikt, bricht add ab, bevor irgendetwas verlinkt oder eine Quelle
277
+ angelegt ist — ein halbes Bundle wäre stilles Fehlen von Skills. Wer nur einen Teil braucht,
278
+ verlinkt die erreichbaren Skills einzeln mit hmgr link.
279
+
280
+ Liegt am Ziel schon derselbe Skill aus derselben Quelle als hmgr-Link, wird er als Mitglied
281
+ übernommen: aus Bestand wird ein Bundle, ohne erst Links von Hand zu entfernen. Alles andere unter
282
+ demselben Namen ist ein Konflikt und wird genannt.
283
+
284
+ hmgr unlink <skill> auf ein einzelnes Mitglied zerbricht das Bundle in diesem Ziel: die übrigen
285
+ Links bleiben als einzelne Links liegen, der Bundle-Verweis fällt weg. Das ist kein Fehler, hmgr
286
+ sagt es nur.
287
+
288
+ Den Abgleich macht schon hmgr update für alle installierten Bundles mit, es ist derselbe Vorgang
289
+ und dieselbe Ausgabe (E69): eine geänderte Definition kommt also mit dem gewöhnlichen
290
+ Aktualisieren an, ohne dass man von der Änderung wissen müsste. bundle sync ist der gezielte Weg
291
+ daneben, wenn nur ein Bundle oder nur ein Ziel gemeint ist; ohne Namen und ohne Ziel-Schalter geht
292
+ es denselben Bestand durch wie update. Was sich nicht vollständig nachziehen lässt, bleibt in
293
+ seinem Ziel auf dem alten Stand und wird als fehlgeschlagen gemeldet.
294
+
295
+ Der Zeiger bleibt außen vor: die Zugehörigkeit der Links führt hmgr in seinem Zustand, nicht in der
296
+ composer.json. --local gibt es deshalb nicht, und die Sperre aus E57 trifft bundle nie.
297
+
298
+ Schalter (add, remove und sync):
299
+ --harness <name> claude-code, hermes oder agents, Standard claude-code
300
+ --ort <ort> home, dir:<pfad> oder profil:<name>, Standard home. profil kennt nur hermes
301
+ --composer <pfad> composer.json des Arbeitsverzeichnisses, wenn mehrere in Frage kommen
302
+ --quelle <teil> nur bei add: Quelle eingrenzen, wenn den Namen mehrere Quellen führen
303
+
304
+ Beispiele:
305
+ hmgr bundle list
306
+ hmgr bundle add wordpress --ort dir:.
307
+ hmgr bundle sync
308
+ hmgr bundle remove wordpress --ort dir:.
309
+
310
+ Häufige Fälle:
311
+ „steht in keiner Quelle": hmgr bundle list zeigt die bekannten Namen, sonst erst hmgr source add.
312
+ „liegt in mehreren Quellen": mit --quelle <teilstring> entscheiden.
313
+ „ist auf dieser Maschine nicht vollständig herstellbar": die Meldung nennt je Mitglied den Grund,
314
+ meist eine Quelle ohne Zugriff oder ein Pfad, an dem kein Skill (mehr) liegt.
315
+ „lässt sich in … nicht anlegen": die Meldung listet alle Namen, die am Ziel schon belegt sind.
316
+ „kein Bundle … installiert": hmgr status zeigt je Ziel seine Bundles.
317
+
318
+ Siehe auch: hmgr help link, hmgr help plugin, hmgr help status`;
233
319
  const MCP = `hmgr mcp add <name> [schalter]
234
320
  hmgr mcp remove <name> [schalter]
235
321
  hmgr mcp migrate <name> [schalter]
@@ -353,6 +439,18 @@ Plugin-Fähigkeit deren Plugins nach (E14). Die Links zeigen weiter auf dieselbe
353
439
  nicht erneuert werden. Ein Checkout mit lokaler Änderung wird übersprungen und gemeldet (E15),
354
440
  damit nichts verloren geht. Lokale Quellen haben keinen Checkout und bleiben unberührt.
355
441
 
442
+ Unter jeder „aktualisiert"-Zeile steht, welche Skills der neue Stand geändert hat (E70): die Namen
443
+ kommen aus dem Vergleich von altem und neuem Commit, gezählt werden nur Skills, die in diesem
444
+ Checkout auch ausgecheckt sind. Berührt ein Commit nur Dateien daneben (README, CI), bleibt die
445
+ Zeile weg.
446
+
447
+ Im selben Lauf gleicht update die installierten Bundles gegen ihre Definitionen ab (E69): eine
448
+ Definition, die inzwischen ein Mitglied mehr oder weniger nennt, wird in jedem Ziel nachgezogen, in
449
+ dem das Bundle hängt. Das ist der Weg, auf dem eine Bundle-Änderung aus einer Quelle ankommt — ohne
450
+ ihn müsste man von seinen eigenen Änderungen wissen, um sie zu holen. Was sich nicht vollständig
451
+ nachziehen lässt, bleibt in seinem Ziel auf dem alten Stand und wird als fehlgeschlagen gemeldet,
452
+ ohne den übrigen Lauf aufzuhalten. hmgr bundle sync macht dasselbe für ein einzelnes Bundle.
453
+
356
454
  Danach räumt update verwaiste Ref-Verzeichnisse auf: Checkouts, in die kein Link mehr zeigt, weil
357
455
  ein Link inzwischen auf einen anderen Ref umgelegt wurde. Vor dem Löschen fragt hmgr einmal für
358
456
  alle Kandidaten. Ohne Bestätigung, also auch ohne Terminal, bleibt alles liegen und wird nur
@@ -456,6 +554,7 @@ const THEMEN = new Map([
456
554
  ['unlink', UNLINK],
457
555
  ['adopt', ADOPT],
458
556
  ['plugin', PLUGIN],
557
+ ['bundle', BUNDLE],
459
558
  ['mcp', MCP],
460
559
  ['sync', SYNC],
461
560
  ['update', UPDATE],