hmgr 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +10 -0
  2. package/dist/adapter/checkout-pfad.js +10 -2
  3. package/dist/adapter/claude-code.js +251 -23
  4. package/dist/adapter/composer.js +4 -3
  5. package/dist/adapter/datei-text.js +18 -0
  6. package/dist/adapter/dateisystem.js +146 -8
  7. package/dist/adapter/git.js +129 -17
  8. package/dist/adapter/hermes.js +235 -10
  9. package/dist/adapter/marketplace-json.js +2 -1
  10. package/dist/adapter/mcp-katalog.js +124 -0
  11. package/dist/adapter/quelle-text.js +10 -1
  12. package/dist/adapter/skill-md.js +96 -19
  13. package/dist/adapter/terminal.js +1 -1
  14. package/dist/adapter/umgebung.js +43 -0
  15. package/dist/adapter/vergleich.js +20 -0
  16. package/dist/adapter/zustand-json.js +6 -1
  17. package/dist/cli/hilfe.js +227 -26
  18. package/dist/cli/main.js +362 -20
  19. package/dist/gui/beobachter.js +108 -0
  20. package/dist/gui/icons.js +114 -0
  21. package/dist/gui/login-pfad.js +120 -0
  22. package/dist/gui/main.js +823 -0
  23. package/dist/gui/mcp-ansicht.js +57 -0
  24. package/dist/gui/oeffnen.js +33 -0
  25. package/dist/gui/preload.cjs +70 -0
  26. package/dist/gui/renderer/fonts/Inter-LICENSE.txt +92 -0
  27. package/dist/gui/renderer/fonts/InterVariable.woff2 +0 -0
  28. package/dist/gui/renderer/fonts/JetBrainsMono-Medium.woff2 +0 -0
  29. package/dist/gui/renderer/fonts/JetBrainsMono-OFL.txt +93 -0
  30. package/dist/gui/renderer/fonts/JetBrainsMono-Regular.woff2 +0 -0
  31. package/dist/gui/renderer/index.html +581 -0
  32. package/dist/gui/renderer/renderer.js +3227 -0
  33. package/dist/gui/renderer/styles.css +701 -0
  34. package/dist/kern/anwendungsfaelle.js +859 -65
  35. package/dist/kern/modell.js +297 -0
  36. package/dist/kern/quelle-ort.js +14 -1
  37. package/dist/ports/harness.js +16 -0
  38. package/dist/ports/mcp-katalog.js +1 -0
  39. package/dist/ports/umgebung.js +1 -0
  40. package/dist/ports/zustand.js +1 -1
  41. package/package.json +47 -2
@@ -1,12 +1,10 @@
1
1
  // Git über die CLI, keine Bibliothek. Checkout-Schema und Worktrees nach E22, Discovery nach E11.
2
- import { execFile, spawn } from 'node:child_process';
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 { promisify } from 'node:util';
6
5
  import { marketplaceLesen } from "./marketplace-json.js";
7
- import { SKILL_DATEI, skillAusPfad } from "./skill-md.js";
6
+ import { SKILL_DATEI, skillAusPfad, skillName } from "./skill-md.js";
8
7
  import { BASIS_NAME, basisPfad, quelleZerlegen, refDekodieren, worktreePfad } from "./checkout-pfad.js";
9
- const ausfuehren = promisify(execFile);
10
8
  /** Bekannte Orte für Skills, in dieser Reihenfolge (E11). */
11
9
  export const BEKANNTE_ORTE = ['skills', '.claude/skills', '.agents/skills'];
12
10
  /** Eine lokale Arbeitskopie hat für hmgr keinen Ref, es zählt der Stand auf der Platte (B11). */
@@ -30,13 +28,17 @@ async function existiert(pfad) {
30
28
  }
31
29
  /**
32
30
  * Aus Dateipfaden die Skill-Verzeichnisse bilden: ein Verzeichnis mit SKILL.md ist ein Skill.
33
- * Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32).
31
+ * Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32). Eine
32
+ * SKILL.md direkt an der Wurzel (`teile.length === 1`) ergibt den Pfad `''`: das Repo ist selbst
33
+ * ein Skill (E11-Nachtrag 2026-09-05). `''` ist die einzige interne Schreibweise dafür, nie `.` —
34
+ * `checkoutZerlegen`, `skillAusPfad` und die Sparse-Checkout-Muster (unten) sind darauf abgestimmt.
35
+ * Wurzel-Skill und `skills/`-Unterordner schließen sich nicht aus, beide landen in der Liste.
34
36
  */
35
37
  export function skillPfadeAusDateien(dateien) {
36
38
  const verzeichnisse = new Set();
37
39
  for (const datei of dateien) {
38
40
  const teile = datei.split('/');
39
- if (teile.at(-1) !== SKILL_DATEI || teile.length < 2)
41
+ if (teile.at(-1) !== SKILL_DATEI)
40
42
  continue;
41
43
  verzeichnisse.add(teile.slice(0, -1).join('/'));
42
44
  }
@@ -96,6 +98,14 @@ async function inhalteAufPlatte(wurzel, pfade) {
96
98
  }
97
99
  return ergebnis;
98
100
  }
101
+ /**
102
+ * Ersatzname für einen Skill, dessen Pfad kein Segment hergibt (Wurzel-Skill, E11-Nachtrag): der
103
+ * Repo-Name der Quelle. Ohne ihn hieße der Skill „" und ein Link auf ihn zeigte nicht auf einen
104
+ * Eintrag im Ziel, sondern auf das Zielverzeichnis (E32).
105
+ */
106
+ function ersatznameDerQuelle(quelle) {
107
+ return quelle.art === 'lokal' ? path.basename(quelle.pfad) : quelleZerlegen(quelle).repo;
108
+ }
99
109
  async function dateienAufPlatte(wurzel, unter = '') {
100
110
  const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
101
111
  const dateien = [];
@@ -115,14 +125,37 @@ export class GitQuellen {
115
125
  constructor(wurzel) {
116
126
  this.wurzel = wurzel;
117
127
  }
128
+ /**
129
+ * stdin ist zu und git darf nicht nach Zugangsdaten fragen, sonst hängt ein wartender Aufruf
130
+ * den ganzen Ablauf (dasselbe Muster wie beim claude-Ausführer). Der Timeout ist großzügiger
131
+ * als dort, ein erster Clone über ein langsames Netz braucht Zeit.
132
+ */
118
133
  async git(args, cwd) {
119
- const { stdout } = await ausfuehren('git', args, { cwd, maxBuffer: 32 * 1024 * 1024 });
120
- return stdout;
134
+ return await new Promise((erfuellen, ablehnen) => {
135
+ const kind = spawn('git', args, {
136
+ cwd,
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ timeout: 300_000,
139
+ killSignal: 'SIGKILL',
140
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
141
+ });
142
+ const stuecke = [];
143
+ const fehler = [];
144
+ kind.stdout.on('data', (teil) => stuecke.push(teil));
145
+ kind.stderr.on('data', (teil) => fehler.push(teil));
146
+ kind.on('error', ablehnen);
147
+ kind.on('close', (code) => {
148
+ if (code === 0)
149
+ erfuellen(Buffer.concat(stuecke).toString('utf8'));
150
+ else
151
+ ablehnen(new Error(`git ${args.join(' ')}: ${Buffer.concat(fehler).toString('utf8').trim()}`));
152
+ });
153
+ });
121
154
  }
122
155
  /** Wie `git`, aber mit Text auf stdin und Bytes als Ergebnis: `execFile` kennt keine Eingabe. */
123
156
  async gitMitEingabe(args, cwd, eingabe) {
124
157
  return await new Promise((erfuellen, ablehnen) => {
125
- const kind = spawn('git', args, { cwd });
158
+ const kind = spawn('git', args, { cwd, timeout: 300_000, killSignal: 'SIGKILL' });
126
159
  const stuecke = [];
127
160
  const fehler = [];
128
161
  kind.stdout.on('data', (teil) => stuecke.push(teil));
@@ -159,24 +192,32 @@ export class GitQuellen {
159
192
  async skillMdInhalte(basis, ref, pfade) {
160
193
  if (pfade.length === 0)
161
194
  return new Map();
162
- const eingabe = `${pfade.map((p) => `${ref}:${p}/${SKILL_DATEI}`).join('\n')}\n`;
195
+ // Ein Wurzel-Skill hat den Pfad `''`: `${ref}:${p}/${SKILL_DATEI}` ergäbe `ref:/SKILL.md` mit
196
+ // führendem Schrägstrich, den git als eigenen (ungültigen) Pfad läse statt als Wurzel (E39).
197
+ const eingabe = `${pfade.map((p) => `${ref}:${p.length === 0 ? SKILL_DATEI : `${p}/${SKILL_DATEI}`}`).join('\n')}\n`;
163
198
  const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
164
199
  if (ausgabe === undefined)
165
200
  return new Map();
166
201
  return batchInhalte(ausgabe, pfade);
167
202
  }
168
- /** Skills der Quelle, Namen aus dem Frontmatter der SKILL.md (E32). */
203
+ /**
204
+ * Skills der Quelle, Namen aus dem Frontmatter der SKILL.md (E32). Ein Wurzel-Skill (Pfad `''`,
205
+ * E11-Nachtrag) hat keinen Verzeichnisnamen, aus dem `skillAusPfad` einen Namen ableiten könnte:
206
+ * der Repo-Name ist der Ersatzname, nie der leere String, sonst zeigte ein Link ohne Frontmatter
207
+ * `name` auf einen Eintrag mit leerem Namen im Ziel (E32).
208
+ */
169
209
  async skills(quelle, ref) {
210
+ const ersatzname = ersatznameDerQuelle(quelle);
170
211
  if (quelle.art === 'lokal') {
171
212
  const pfade = skillPfadeAusDateien(await dateienAufPlatte(quelle.pfad));
172
213
  const inhalte = await inhalteAufPlatte(quelle.pfad, pfade);
173
- return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
214
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
174
215
  }
175
216
  const basis = await this.basis(quelle);
176
217
  const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
177
218
  const pfade = skillPfadeAusDateien(ausgabe.split('\n').filter((z) => z.length > 0));
178
219
  const inhalte = await this.skillMdInhalte(basis, ref, pfade);
179
- return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
220
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
180
221
  }
181
222
  /**
182
223
  * Marketplace einer Quelle (E24). Der Inhalt kommt aus dem Git-Baum, nicht aus einem Checkout:
@@ -206,7 +247,9 @@ export class GitQuellen {
206
247
  plugins: gelesen.plugins.map((p) => ({
207
248
  name: p.name,
208
249
  dependencies: p.dependencies,
209
- skills: p.skills.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad))),
250
+ // Auch hier kann ein Pfad leer sein (`"source": "."` mit `"skills": ["."]`): derselbe
251
+ // Ersatzname wie im Katalog, damit nie ein Skill ohne Namen entsteht (E32).
252
+ skills: p.skills.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatznameDerQuelle(quelle))),
210
253
  })),
211
254
  };
212
255
  }
@@ -265,8 +308,14 @@ export class GitQuellen {
265
308
  for (const eintrag of vorhanden)
266
309
  if (!WURZEL_MUSTER.includes(eintrag))
267
310
  muster.add(eintrag);
311
+ // Ein Wurzel-Skill (E48 a, leerer Pfad) ist der ganze Baum: `/**` nimmt ihn nach `!/*/` wieder
312
+ // rekursiv auf, ein Muster `//` wäre kein gültiges Sparse-Checkout-Muster. Der Link, den
313
+ // `linkLegen` darauf setzt, zeigt dann auf die Worktree-Wurzel selbst (E11-Nachtrag 2026-09-05):
314
+ // die ganze Arbeitskopie samt `.git` wird sichtbar, nicht nur die SKILL.md und ihre
315
+ // Unterordner. Ein Harness liest ohnehin nur SKILL.md und referenzierte Unterordner, das `.git`
316
+ // stört nicht — wohl aber sähe ein Nutzer, der den Link im Finder öffnet, den vollen Worktree.
268
317
  for (const skill of skills)
269
- muster.add(`/${skill.pfad}/`);
318
+ muster.add(skill.pfad === '' ? '/**' : `/${skill.pfad}/`);
270
319
  await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
271
320
  if (neu)
272
321
  await this.git(['checkout', '--detach', ref], pfad);
@@ -300,6 +349,12 @@ export class GitQuellen {
300
349
  }
301
350
  return ergebnis;
302
351
  }
352
+ /**
353
+ * Scheitert der Abruf der Quelle, etwa ohne Netz (E51), ergibt es `fehlgeschlagen` mit
354
+ * Fehlertext statt eines geworfenen Fehlers: `aktualisieren` (Kern) ruft das je Checkout auf,
355
+ * ein einzelner Fehlschlag darf die übrigen Checkouts nicht abbrechen. Gefangen wird genau der
356
+ * Abruf und nichts sonst, damit kein Programmierfehler als Fehlschlag der Quelle erscheint.
357
+ */
303
358
  async aktualisieren(checkout) {
304
359
  if (checkout.quelle.art === 'lokal')
305
360
  return { ergebnis: 'unveraendert' };
@@ -307,7 +362,12 @@ export class GitQuellen {
307
362
  return { ergebnis: 'uebersprungen', grund: 'lokale-aenderung' }; // E15
308
363
  const basis = await this.basis(checkout.quelle);
309
364
  const von = (await this.git(['rev-parse', 'HEAD'], checkout.pfad)).trim();
310
- await this.git(['fetch', '--prune', '--tags', 'origin'], basis);
365
+ try {
366
+ await this.git(['fetch', '--prune', '--tags', 'origin'], basis);
367
+ }
368
+ catch (fehler) {
369
+ return { ergebnis: 'fehlgeschlagen', fehler: fehler instanceof Error ? fehler.message : String(fehler) };
370
+ }
311
371
  const nach = (await this.git(['rev-parse', checkout.ref], basis)).trim();
312
372
  if (von === nach)
313
373
  return { ergebnis: 'unveraendert' };
@@ -357,10 +417,62 @@ export class GitQuellen {
357
417
  relativerPfad: path.relative(wurzel, echt).split(path.sep).join('/'),
358
418
  };
359
419
  }
420
+ /**
421
+ * `--no-optional-locks`, weil die Prüfung auch fremde Arbeitskopien trifft (E48 a): ohne den
422
+ * Schalter schreibt `status` deren Index-Cache neu, und die Erhebung wäre nicht mehr rein lesend.
423
+ */
360
424
  async lokaleAenderungen(checkout) {
361
425
  if (checkout.quelle.art === 'lokal')
362
426
  return false;
363
- const ausgabe = await this.git(['status', '--porcelain'], checkout.pfad);
427
+ const ausgabe = await this.git(['--no-optional-locks', 'status', '--porcelain'], checkout.pfad);
364
428
  return ausgabe.trim().length > 0;
365
429
  }
430
+ /**
431
+ * Enthaltensein ohne Netzzugriff (E48): `merge-base --is-ancestor` gegen den lokalen Stand des
432
+ * Tracking-Refs, kein `fetch`. Schlägt der Aufruf fehl, etwa ohne Tracking-Ref, gilt „nicht
433
+ * enthalten" als sicherer Standard.
434
+ */
435
+ async imRemoteEnthalten(pfad, ref) {
436
+ try {
437
+ await this.git(['merge-base', '--is-ancestor', 'HEAD', `refs/remotes/origin/${ref}`], pfad);
438
+ return true;
439
+ }
440
+ catch {
441
+ return false;
442
+ }
443
+ }
444
+ async wurzelSkill(pfad) {
445
+ const inhalt = await readFile(path.join(pfad, SKILL_DATEI), 'utf8').catch(() => undefined);
446
+ if (inhalt === undefined)
447
+ return null;
448
+ // Der Skill-Pfad ist leer, der Verzeichnisname ist deshalb der Ordner der Arbeitskopie selbst
449
+ // (E32). Aus dem leeren Pfad allein käme ein leerer Name, und ein leerer Name zeigte im Ziel
450
+ // auf das Zielverzeichnis statt auf einen Eintrag darin.
451
+ const { beschreibung } = skillAusPfad('', inhalt);
452
+ const name = skillName(path.basename(pfad), inhalt);
453
+ return beschreibung === undefined ? { name, pfad: '' } : { name, pfad: '', beschreibung };
454
+ }
455
+ /**
456
+ * Gitignorierte Dateien einer Arbeitskopie, für die Vorschau vor dem Ersetzen (E50). Wie bei
457
+ * `lokaleAenderungen`: `--no-optional-locks`, damit das Lesen auch einer fremden Arbeitskopie
458
+ * nichts auf der Platte verändert. Kein Git-Repo oder ein Git-Fehler ergibt eine leere Liste.
459
+ */
460
+ async gitignorierteDateien(pfad) {
461
+ try {
462
+ // Nur die Arbeitskopie selbst zählt. Liegt der Ordner bloß in einem umgebenden Repo, meldete
463
+ // `status` dessen ignorierte Dateien, die mit dem Ordner nichts zu tun haben, und die
464
+ // Vorschau behauptete einen Verlust, den das Ersetzen gar nicht anrichtet.
465
+ const wurzel = await this.git(['--no-optional-locks', 'rev-parse', '--show-toplevel'], pfad);
466
+ if ((await realpath(wurzel.trim())) !== (await realpath(pfad)))
467
+ return [];
468
+ const ausgabe = await this.git(['--no-optional-locks', 'status', '--porcelain', '--ignored'], pfad);
469
+ return ausgabe
470
+ .split('\n')
471
+ .filter((zeile) => zeile.startsWith('!! '))
472
+ .map((zeile) => zeile.slice(3));
473
+ }
474
+ catch {
475
+ return [];
476
+ }
477
+ }
366
478
  }
@@ -1,11 +1,12 @@
1
1
  // Hermes Agent: Grundverhalten plus Profile entdecken und Verzeichnis in der config.yaml registrieren.
2
2
  // Skills landen nie in <hermes-home>/skills, sondern in einem Ordner aus skills.external_dirs (E13).
3
3
  import { readFileSync, realpathSync } from 'node:fs';
4
- import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
4
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
- import { Document, isSeq, parseDocument } from 'yaml';
7
- import { OrtNichtUnterstuetzt } from "../kern/modell.js";
6
+ import { Document, isMap, isScalar, isSeq, parseDocument } from 'yaml';
7
+ import { McpAbweichenderEintrag, OrtNichtUnterstuetzt, SkillOrdnerUngueltig, } from "../kern/modell.js";
8
8
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
9
+ import { tiefGleich } from "./vergleich.js";
9
10
  const HERMES_VERZEICHNIS = '.hermes';
10
11
  const PROFILE_VERZEICHNIS = 'profiles';
11
12
  const CONFIG = 'config.yaml';
@@ -46,9 +47,39 @@ function trustedProjectDirs(daten) {
46
47
  return [];
47
48
  return dirs.filter((d) => typeof d === 'string');
48
49
  }
50
+ /** Liest mcp_servers aus einer geparsten config.yaml (E61). Fehlt der Abschnitt, ist er leer. */
51
+ function mcpServers(daten) {
52
+ if (typeof daten !== 'object' || daten === null)
53
+ return {};
54
+ const mcp = daten['mcp_servers'];
55
+ if (typeof mcp !== 'object' || mcp === null || Array.isArray(mcp))
56
+ return {};
57
+ return mcp;
58
+ }
59
+ /** Der neutrale Konfigblock als reines Objekt, für die config.yaml und für den Vergleich (E61). */
60
+ function mcpKonfigAlsObjekt(konfig) {
61
+ if ('command' in konfig)
62
+ return {
63
+ command: konfig.command,
64
+ ...(konfig.args === undefined ? {} : { args: konfig.args }),
65
+ ...(konfig.env === undefined ? {} : { env: konfig.env }),
66
+ };
67
+ return { url: konfig.url, ...(konfig.headers === undefined ? {} : { headers: konfig.headers }) };
68
+ }
49
69
  export class Hermes {
50
70
  name = 'hermes';
51
71
  home;
72
+ /**
73
+ * MCP-Server (E61): nur Home und Profil, denn Hermes kennt keine Projektebene für MCPs. Bindung
74
+ * über Arrow-Funktionen auf `this`, damit die Fähigkeit ohne eigene Klasse auskommt.
75
+ */
76
+ mcp = {
77
+ ebenen: () => ['home', 'profil'],
78
+ einhaengen: async (mcpName, konfig, ort) => this.mcpEinhaengen(ort, mcpName, konfig),
79
+ aushaengen: async (mcpName, ort) => this.mcpAushaengen(ort, mcpName),
80
+ eingehaengte: async (ort) => this.mcpEingehaengte(ort),
81
+ envDatei: (ort) => path.join(this.mcpHermesHome(ort), '.env'),
82
+ };
52
83
  /** `home` ist das HOME des Nutzers, das Hermes-Home liegt darunter als `.hermes`. */
53
84
  constructor(home) {
54
85
  this.home = home;
@@ -96,10 +127,12 @@ export class Hermes {
96
127
  }
97
128
  return parseDocument(text).toJS();
98
129
  }
99
- zielVerzeichnis(ort) {
100
- if (ort.art === 'arbeitsverzeichnis')
101
- return path.join(ort.pfad, HERMES_VERZEICHNIS, EIGENER_ORDNER);
102
- const hermesHome = this.hermesHome(ort);
130
+ /**
131
+ * Der eine von hmgr verwaltete `external_dirs`-Eintrag (E13, E56): der erste unterhalb des
132
+ * Hermes-Home, ohne Hermes' eigenen Ordner `skills`. Alle weiteren Einträge der config.yaml
133
+ * ignoriert hmgr vollständig. `undefined` heißt: kein solcher Eintrag steht dort.
134
+ */
135
+ ersterEintrag(hermesHome) {
103
136
  const eigenerOrdner = path.join(hermesHome, EIGENER_ORDNER);
104
137
  for (const eintrag of externalDirs(this.gelesen(hermesHome))) {
105
138
  const pfad = this.aufloesen(eintrag, hermesHome);
@@ -109,7 +142,98 @@ export class Hermes {
109
142
  continue;
110
143
  return pfad;
111
144
  }
112
- return path.join(hermesHome, STANDARD_ORDNER);
145
+ return undefined;
146
+ }
147
+ zielVerzeichnis(ort) {
148
+ if (ort.art === 'arbeitsverzeichnis')
149
+ return path.join(ort.pfad, HERMES_VERZEICHNIS, EIGENER_ORDNER);
150
+ const hermesHome = this.hermesHome(ort);
151
+ return this.ersterEintrag(hermesHome) ?? path.join(hermesHome, STANDARD_ORDNER);
152
+ }
153
+ /**
154
+ * Orte mit einem verwalteten Skill-Ordner (E56): Home und Profil, beides ein vollständiges
155
+ * Hermes-Home mit config.yaml. Ein Arbeitsverzeichnis hat keine, dort steht `.hermes/skills`
156
+ * fest (E13).
157
+ */
158
+ skillOrdnerOrte() {
159
+ return ['home', 'profil'];
160
+ }
161
+ /**
162
+ * Fähigkeit: verwalteter Skill-Ordner (E56). Anders als `zielVerzeichnis` ohne Ausweichen auf den
163
+ * Standard-Ordner: `undefined` heißt wirklich „kein Eintrag verwaltet". Arbeitsverzeichnis kennt
164
+ * die Fähigkeit nicht, dort steht `.hermes/skills` fest (E13).
165
+ */
166
+ async verwalteterSkillOrdner(ort) {
167
+ if (ort.art === 'arbeitsverzeichnis')
168
+ return undefined;
169
+ return this.ersterEintrag(this.hermesHome(ort));
170
+ }
171
+ /**
172
+ * Prüft einen genannten Pfad als verwalteten Skill-Ordner (E56) und gibt ihn aufgelöst zurück:
173
+ * `~` und relative Angaben deutet er wie Hermes selbst (relativ zum Hermes-Home). Er muss
174
+ * unterhalb des Hermes-Home liegen und darf nicht Hermes' eigener Ordner `skills` sein, sonst
175
+ * `SkillOrdnerUngueltig`. Beides wird zusätzlich am echten Pfad gemessen, damit ein Symlink im Weg
176
+ * nicht aus dem Hermes-Home herausführt; geliefert wird trotzdem der ungefolgte Pfad, denn so
177
+ * steht er auch in der config.yaml.
178
+ */
179
+ gepruefterOrdner(hermesHome, pfad) {
180
+ const ordner = this.aufloesen(pfad, hermesHome);
181
+ // Variablen löst hmgr nicht auf (siehe `aufloesen`): ein solcher Pfad ist kein Ort auf der Platte.
182
+ if (ordner === undefined)
183
+ throw new SkillOrdnerUngueltig(pfad, 'ausserhalb');
184
+ // Nur die Elternkette wird aufgelöst: ist der Ordner selbst ein Symlink, gilt er als das, was er
185
+ // ist, und ein Löschen träfe später den Symlink, nicht sein Ziel.
186
+ const echt = path.join(this.echterPfad(path.dirname(ordner)), path.basename(ordner));
187
+ const basis = this.echterPfad(hermesHome);
188
+ if (!unterhalb(ordner, hermesHome) || !unterhalb(echt, basis))
189
+ throw new SkillOrdnerUngueltig(ordner, 'ausserhalb');
190
+ for (const [kandidat, wurzel] of [
191
+ [ordner, path.join(hermesHome, EIGENER_ORDNER)],
192
+ [echt, path.join(basis, EIGENER_ORDNER)],
193
+ ])
194
+ if (kandidat === wurzel || unterhalb(kandidat, wurzel))
195
+ throw new SkillOrdnerUngueltig(ordner, 'eigener-ordner');
196
+ return ordner;
197
+ }
198
+ /**
199
+ * Fähigkeit: Skill-Ordner festlegen (E56). Nur unterhalb des Hermes-Home und außerhalb des
200
+ * eigenen Ordners `skills`, sonst `SkillOrdnerUngueltig`. Legt den Ordner an und trägt ihn ein,
201
+ * wie `vorbereiten`/`registrieren` es für den Standard-Ordner schon tun. Gibt den aufgelösten
202
+ * Pfad zurück, wie er in der config.yaml landet.
203
+ */
204
+ async skillOrdnerFestlegen(ort, pfad) {
205
+ if (ort.art === 'arbeitsverzeichnis')
206
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
207
+ const hermesHome = this.hermesHome(ort);
208
+ const ordner = this.gepruefterOrdner(hermesHome, pfad);
209
+ await mkdir(ordner, { recursive: true });
210
+ await this.registrieren(hermesHome, ordner);
211
+ return ordner;
212
+ }
213
+ /**
214
+ * Fähigkeit: Skill-Ordner entfernen (E56), Gegenstück zu `skillOrdnerFestlegen`: nur der
215
+ * `external_dirs`-Eintrag verschwindet aus der config.yaml, der Ordner selbst bleibt unangetastet
216
+ * (dafür `skillOrdnerLoeschen`).
217
+ */
218
+ async skillOrdnerEntfernen(ort) {
219
+ if (ort.art === 'arbeitsverzeichnis')
220
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
221
+ const hermesHome = this.hermesHome(ort);
222
+ const pfad = this.ersterEintrag(hermesHome);
223
+ if (pfad === undefined)
224
+ return;
225
+ await this.deregistrieren(hermesHome, pfad);
226
+ }
227
+ /**
228
+ * Fähigkeit: den (zuvor verwalteten) Ordner mit Inhalt löschen (E56). Sicherheitsnetz: dieselbe
229
+ * Prüfung wie beim Festlegen, egal was der Aufrufer meint — nur unterhalb des Hermes-Home, nie
230
+ * Hermes' eigener Ordner `skills`, kein Ausbruch über einen Symlink im Weg.
231
+ */
232
+ async skillOrdnerLoeschen(ort, pfad) {
233
+ if (ort.art === 'arbeitsverzeichnis')
234
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
235
+ const ordner = this.gepruefterOrdner(this.hermesHome(ort), pfad);
236
+ await rm(ordner, { recursive: true, force: true });
113
237
  }
114
238
  async vorbereiten(ort) {
115
239
  const verzeichnis = this.zielVerzeichnis(ort);
@@ -132,16 +256,76 @@ export class Hermes {
132
256
  return;
133
257
  const eintrag = this.alsEintrag(verzeichnis);
134
258
  const liste = doc.getIn(['skills', 'external_dirs']);
259
+ const skills = doc.get('skills');
135
260
  if (isSeq(liste))
136
261
  liste.add(eintrag);
137
- else
262
+ // `skills:` ohne Inhalt ist ein Skalar (null), kein Abschnitt: `setIn` bräche daran ab
263
+ // („Expected YAML collection at skills"). Ein Abschnitt mit Inhalt bekommt nur den neuen
264
+ // Schlüssel, alles daneben bleibt stehen.
265
+ else if (skills === null || skills === undefined)
266
+ doc.set('skills', { external_dirs: [eintrag] });
267
+ else if (isMap(skills))
138
268
  doc.setIn(['skills', 'external_dirs'], [eintrag]);
269
+ // Steht unter `skills` etwas anderes als ein Abschnitt, hat es jemand von Hand so gemeint:
270
+ // überschreiben hieße Daten wegwerfen, also rührt hmgr die Datei nicht an.
271
+ else
272
+ throw new Error(`In ${pfad} ist „skills" kein Abschnitt, hmgr trägt dort nichts ein.`);
139
273
  await mkdir(hermesHome, { recursive: true });
140
274
  await writeFile(pfad, doc.toString());
141
275
  }
142
276
  folgtLinks() {
143
277
  return true;
144
278
  }
279
+ /**
280
+ * Fähigkeit Zurückbauen (E38), Gegenstück zu `registrieren`: entfernt den `external_dirs`-Eintrag,
281
+ * den hmgr für diesen Ort eingetragen hat, falls einer da ist. Arbeitsverzeichnisse kennen keinen
282
+ * solchen Eintrag, dort steht `.hermes/skills` fest (E13).
283
+ */
284
+ async zurueckbauen(ort) {
285
+ if (ort.art === 'arbeitsverzeichnis')
286
+ return false;
287
+ const hermesHome = this.hermesHome(ort);
288
+ return await this.deregistrieren(hermesHome, this.zielVerzeichnis(ort));
289
+ }
290
+ /**
291
+ * Fähigkeit „ist registriert" (E38 R7), Gegenstück zu `vorbereiten`/`zurueckbauen`, ohne etwas zu
292
+ * ändern: ob der `external_dirs`-Eintrag für diesen Ort noch in der config.yaml steht, unabhängig
293
+ * davon, ob dort noch Links liegen. Arbeitsverzeichnisse kennen keinen solchen Eintrag (E13),
294
+ * dort steht `.hermes/skills` fest, wie bei `zurueckbauen`.
295
+ */
296
+ async istRegistriert(ort) {
297
+ if (ort.art === 'arbeitsverzeichnis')
298
+ return false;
299
+ const hermesHome = this.hermesHome(ort);
300
+ const verzeichnis = this.zielVerzeichnis(ort);
301
+ return externalDirs(this.gelesen(hermesHome)).some((eintrag) => this.aufloesen(eintrag, hermesHome) === verzeichnis);
302
+ }
303
+ /** Entfernt jeden Eintrag aus skills.external_dirs, der auf `verzeichnis` zeigt. Kommentare bleiben. */
304
+ async deregistrieren(hermesHome, verzeichnis) {
305
+ const pfad = path.join(hermesHome, CONFIG);
306
+ const text = await readFile(pfad, 'utf8').catch(() => undefined);
307
+ if (text === undefined)
308
+ return false;
309
+ const doc = parseDocument(text);
310
+ const liste = doc.getIn(['skills', 'external_dirs']);
311
+ if (!isSeq(liste))
312
+ return false;
313
+ let geaendert = false;
314
+ for (let i = liste.items.length - 1; i >= 0; i--) {
315
+ const item = liste.items[i];
316
+ const wert = typeof item === 'string' ? item : isScalar(item) ? item.value : undefined;
317
+ if (typeof wert !== 'string')
318
+ continue;
319
+ if (this.aufloesen(wert, hermesHome) !== verzeichnis)
320
+ continue;
321
+ liste.delete(i);
322
+ geaendert = true;
323
+ }
324
+ if (!geaendert)
325
+ return false;
326
+ await writeFile(pfad, doc.toString());
327
+ return true;
328
+ }
145
329
  /** Vergleich wie bei Hermes über den aufgelösten Pfad, Symlinks eingeschlossen. */
146
330
  echterPfad(pfad) {
147
331
  try {
@@ -178,7 +362,7 @@ export class Hermes {
178
362
  return {
179
363
  hinweis: `Hermes lädt Projekt-Skills in ${wurzel} erst nach \`hermes skills trust ${wurzel}\`.`,
180
364
  abhilfe: {
181
- frage: `Hermes lädt Projekt-Skills in ${wurzel} erst nach Vertrauen. Jetzt als vertrauenswürdig eintragen? [j/N]`,
365
+ frage: `Hermes lädt Projekt-Skills in ${wurzel} erst nach Vertrauen. Jetzt als vertrauenswürdig eintragen?`,
182
366
  erledigt: `${wurzel} als vertrauenswürdig in Hermes eingetragen.`,
183
367
  ausfuehren: async () => await this.vertrauen(wurzel),
184
368
  },
@@ -205,6 +389,47 @@ export class Hermes {
205
389
  await mkdir(hermesHome, { recursive: true });
206
390
  await writeFile(pfad, doc.toString());
207
391
  }
392
+ /** Home oder Profil als Hermes-Home (E61): mcp kennt nur diese beiden Orte. */
393
+ mcpHermesHome(ort) {
394
+ if (ort.art !== 'home' && ort.art !== 'profil')
395
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
396
+ return this.hermesHome(ort);
397
+ }
398
+ async mcpEingehaengte(ort) {
399
+ return Object.keys(mcpServers(this.gelesen(this.mcpHermesHome(ort))));
400
+ }
401
+ /**
402
+ * Trägt `mcp_servers.<name>` ein (E61). Steht dort schon ein Eintrag, der vom Katalogblock
403
+ * abweicht (egal, wer ihn gelegt hat), bricht es ab, ohne stillschweigend zu überschreiben; ein
404
+ * gleicher Eintrag ist idempotent. Kommentare bleiben erhalten.
405
+ */
406
+ async mcpEinhaengen(ort, mcpName, konfig) {
407
+ const hermesHome = this.mcpHermesHome(ort);
408
+ const pfad = path.join(hermesHome, CONFIG);
409
+ const text = await readFile(pfad, 'utf8').catch(() => undefined);
410
+ const doc = text === undefined ? new Document({}) : parseDocument(text);
411
+ const bestehend = mcpServers(doc.toJS())[mcpName];
412
+ const wert = mcpKonfigAlsObjekt(konfig);
413
+ if (bestehend !== undefined && !tiefGleich(bestehend, wert))
414
+ throw new McpAbweichenderEintrag({ harness: this.name, ort }, mcpName);
415
+ doc.setIn(['mcp_servers', mcpName], wert);
416
+ await mkdir(hermesHome, { recursive: true });
417
+ await writeFile(pfad, doc.toString());
418
+ }
419
+ /** Entfernt genau diesen Eintrag aus mcp_servers (E61). Kommentare bleiben erhalten. */
420
+ async mcpAushaengen(ort, mcpName) {
421
+ const hermesHome = this.mcpHermesHome(ort);
422
+ const pfad = path.join(hermesHome, CONFIG);
423
+ const text = await readFile(pfad, 'utf8').catch(() => undefined);
424
+ if (text === undefined)
425
+ return;
426
+ const doc = parseDocument(text);
427
+ const mcp = doc.get('mcp_servers');
428
+ if (!isMap(mcp) || !mcp.has(mcpName))
429
+ return;
430
+ mcp.delete(mcpName);
431
+ await writeFile(pfad, doc.toString());
432
+ }
208
433
  /** Fähigkeit Profile entdecken: Verzeichnisse mit config.yaml, bei jedem Aufruf frisch gelesen (E19). */
209
434
  async profile() {
210
435
  const basis = path.join(this.home, HERMES_VERZEICHNIS, PROFILE_VERZEICHNIS);
@@ -1,5 +1,6 @@
1
1
  // .claude-plugin/marketplace.json lesen (E24, HARNESS-FAKTEN „Plugins und Marketplaces").
2
2
  // Nur die beiden dokumentierten Formen, alles andere wird abgelehnt statt geraten.
3
+ import { ohneBom } from "./datei-text.js";
3
4
  /** Standardordner für die Skills eines Plugins, relativ zu seiner Quelle. */
4
5
  const SKILL_ORDNER = 'skills';
5
6
  export class MarketplaceUnbekannteForm extends Error {
@@ -80,7 +81,7 @@ function pluginLesen(roh, dateien) {
80
81
  export function marketplaceLesen(text, dateien) {
81
82
  let daten;
82
83
  try {
83
- daten = JSON.parse(text);
84
+ daten = JSON.parse(ohneBom(text));
84
85
  }
85
86
  catch (fehler) {
86
87
  throw new MarketplaceUnbekannteForm(`kein gültiges JSON (${fehler instanceof Error ? fehler.message : fehler})`);