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.
- package/README.md +10 -0
- package/dist/adapter/checkout-pfad.js +10 -2
- package/dist/adapter/claude-code.js +251 -23
- package/dist/adapter/composer.js +4 -3
- package/dist/adapter/datei-text.js +18 -0
- package/dist/adapter/dateisystem.js +146 -8
- package/dist/adapter/git.js +129 -17
- package/dist/adapter/hermes.js +235 -10
- package/dist/adapter/marketplace-json.js +2 -1
- package/dist/adapter/mcp-katalog.js +124 -0
- package/dist/adapter/quelle-text.js +10 -1
- package/dist/adapter/skill-md.js +96 -19
- package/dist/adapter/terminal.js +1 -1
- package/dist/adapter/umgebung.js +43 -0
- package/dist/adapter/vergleich.js +20 -0
- package/dist/adapter/zustand-json.js +6 -1
- package/dist/cli/hilfe.js +227 -26
- package/dist/cli/main.js +362 -20
- package/dist/gui/beobachter.js +108 -0
- package/dist/gui/icons.js +114 -0
- package/dist/gui/login-pfad.js +120 -0
- package/dist/gui/main.js +823 -0
- package/dist/gui/mcp-ansicht.js +57 -0
- package/dist/gui/oeffnen.js +33 -0
- package/dist/gui/preload.cjs +70 -0
- package/dist/gui/renderer/fonts/Inter-LICENSE.txt +92 -0
- package/dist/gui/renderer/fonts/InterVariable.woff2 +0 -0
- package/dist/gui/renderer/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/dist/gui/renderer/fonts/JetBrainsMono-OFL.txt +93 -0
- package/dist/gui/renderer/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/dist/gui/renderer/index.html +581 -0
- package/dist/gui/renderer/renderer.js +3227 -0
- package/dist/gui/renderer/styles.css +701 -0
- package/dist/kern/anwendungsfaelle.js +859 -65
- package/dist/kern/modell.js +297 -0
- package/dist/kern/quelle-ort.js +14 -1
- package/dist/ports/harness.js +16 -0
- package/dist/ports/mcp-katalog.js +1 -0
- package/dist/ports/umgebung.js +1 -0
- package/dist/ports/zustand.js +1 -1
- package/package.json +47 -2
package/dist/adapter/git.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// Git über die CLI, keine Bibliothek. Checkout-Schema und Worktrees nach E22, Discovery nach E11.
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/adapter/hermes.js
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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})`);
|