hmgr 0.0.5 → 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 +231 -7
- package/dist/adapter/composer.js +4 -3
- package/dist/adapter/datei-text.js +18 -0
- package/dist/adapter/dateisystem.js +42 -4
- package/dist/adapter/git.js +34 -8
- package/dist/adapter/hermes.js +73 -1
- 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 +2 -1
- package/dist/adapter/umgebung.js +43 -0
- package/dist/adapter/vergleich.js +20 -0
- package/dist/adapter/zustand-json.js +2 -1
- package/dist/cli/hilfe.js +98 -0
- package/dist/cli/main.js +97 -3
- package/dist/gui/beobachter.js +108 -0
- package/dist/gui/login-pfad.js +120 -0
- package/dist/gui/main.js +361 -19
- package/dist/gui/mcp-ansicht.js +57 -0
- package/dist/gui/oeffnen.js +1 -1
- package/dist/gui/preload.cjs +16 -1
- package/dist/gui/renderer/index.html +96 -6
- package/dist/gui/renderer/renderer.js +654 -38
- package/dist/gui/renderer/styles.css +65 -2
- package/dist/kern/anwendungsfaelle.js +175 -4
- package/dist/kern/modell.js +141 -3
- package/dist/kern/quelle-ort.js +14 -1
- package/dist/ports/harness.js +3 -0
- package/dist/ports/mcp-katalog.js +1 -0
- package/dist/ports/umgebung.js +1 -0
- package/package.json +43 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// .claude-plugin/marketplace.json lesen (E24, HARNESS-FAKTEN „Plugins und Marketplaces").
|
|
2
2
|
// Nur die beiden dokumentierten Formen, alles andere wird abgelehnt statt geraten.
|
|
3
|
+
import { ohneBom } from "./datei-text.js";
|
|
3
4
|
/** Standardordner für die Skills eines Plugins, relativ zu seiner Quelle. */
|
|
4
5
|
const SKILL_ORDNER = 'skills';
|
|
5
6
|
export class MarketplaceUnbekannteForm extends Error {
|
|
@@ -80,7 +81,7 @@ function pluginLesen(roh, dateien) {
|
|
|
80
81
|
export function marketplaceLesen(text, dateien) {
|
|
81
82
|
let daten;
|
|
82
83
|
try {
|
|
83
|
-
daten = JSON.parse(text);
|
|
84
|
+
daten = JSON.parse(ohneBom(text));
|
|
84
85
|
}
|
|
85
86
|
catch (fehler) {
|
|
86
87
|
throw new MarketplaceUnbekannteForm(`kein gültiges JSON (${fehler instanceof Error ? fehler.message : fehler})`);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Liest den persönlichen MCP-Katalog ~/.hmgr/mcp.yaml (E61). hmgr schreibt ihn nie.
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { parseDocument } from 'yaml';
|
|
4
|
+
import { McpKatalogFehler } from "../kern/modell.js";
|
|
5
|
+
const STDIO_FELDER = new Set(['command', 'args', 'env']);
|
|
6
|
+
const URL_FELDER = new Set(['url', 'headers']);
|
|
7
|
+
function fehlertext(fehler) {
|
|
8
|
+
return fehler instanceof Error ? fehler.message : String(fehler);
|
|
9
|
+
}
|
|
10
|
+
function istObjekt(wert) {
|
|
11
|
+
return typeof wert === 'object' && wert !== null && !Array.isArray(wert);
|
|
12
|
+
}
|
|
13
|
+
/** Ein optionales Textfeld: fehlt es oder ist es leer, bleibt es weg, kein Fehler. */
|
|
14
|
+
function feldText(pfad, name, o, feld) {
|
|
15
|
+
if (!(feld in o))
|
|
16
|
+
return undefined;
|
|
17
|
+
const wert = o[feld];
|
|
18
|
+
if (typeof wert !== 'string')
|
|
19
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": Feld „${feld}" muss Text sein`);
|
|
20
|
+
const getrimmt = wert.trim();
|
|
21
|
+
return getrimmt.length === 0 ? undefined : wert;
|
|
22
|
+
}
|
|
23
|
+
/** Eine Zeichenkette-zu-Zeichenkette-Map wie `env` oder `headers`, oder undefined ohne das Feld. */
|
|
24
|
+
function mapLesen(pfad, name, wert, feld) {
|
|
25
|
+
if (wert === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (!istObjekt(wert))
|
|
28
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.${feld} muss ein Objekt sein`);
|
|
29
|
+
const ergebnis = {};
|
|
30
|
+
for (const [k, v] of Object.entries(wert)) {
|
|
31
|
+
if (typeof v !== 'string')
|
|
32
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.${feld}.${k} muss Text sein`);
|
|
33
|
+
ergebnis[k] = v;
|
|
34
|
+
}
|
|
35
|
+
return ergebnis;
|
|
36
|
+
}
|
|
37
|
+
/** Eine Liste von Zeichenketten wie `args`, oder undefined ohne das Feld. */
|
|
38
|
+
function argsLesen(pfad, name, wert) {
|
|
39
|
+
if (wert === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (!Array.isArray(wert))
|
|
42
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.args muss eine Liste sein`);
|
|
43
|
+
return wert.map((eintrag, i) => {
|
|
44
|
+
if (typeof eintrag !== 'string')
|
|
45
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.args[${i}] muss Text sein`);
|
|
46
|
+
return eintrag;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function konfigLesen(pfad, name, k) {
|
|
50
|
+
const hatCommand = 'command' in k;
|
|
51
|
+
const hatUrl = 'url' in k;
|
|
52
|
+
if (hatCommand && hatUrl)
|
|
53
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig darf nicht zugleich „command" und „url" haben`);
|
|
54
|
+
if (hatCommand) {
|
|
55
|
+
for (const feld of Object.keys(k))
|
|
56
|
+
if (!STDIO_FELDER.has(feld))
|
|
57
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": unbekanntes Feld „${feld}" in konfig`);
|
|
58
|
+
const command = k['command'];
|
|
59
|
+
if (typeof command !== 'string' || command.trim().length === 0)
|
|
60
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.command muss nicht leerer Text sein`);
|
|
61
|
+
const args = argsLesen(pfad, name, k['args']);
|
|
62
|
+
const env = mapLesen(pfad, name, k['env'], 'env');
|
|
63
|
+
return { command, ...(args === undefined ? {} : { args }), ...(env === undefined ? {} : { env }) };
|
|
64
|
+
}
|
|
65
|
+
if (hatUrl) {
|
|
66
|
+
for (const feld of Object.keys(k))
|
|
67
|
+
if (!URL_FELDER.has(feld))
|
|
68
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": unbekanntes Feld „${feld}" in konfig`);
|
|
69
|
+
const url = k['url'];
|
|
70
|
+
if (typeof url !== 'string' || url.trim().length === 0)
|
|
71
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig.url muss nicht leerer Text sein`);
|
|
72
|
+
const headers = mapLesen(pfad, name, k['headers'], 'headers');
|
|
73
|
+
return { url, ...(headers === undefined ? {} : { headers }) };
|
|
74
|
+
}
|
|
75
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}": konfig braucht entweder „command" oder „url"`);
|
|
76
|
+
}
|
|
77
|
+
function eintragLesen(pfad, name, roh) {
|
|
78
|
+
if (!istObjekt(roh))
|
|
79
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}" ist kein Objekt`);
|
|
80
|
+
const beschreibung = roh['beschreibung'];
|
|
81
|
+
if (typeof beschreibung !== 'string' || beschreibung.trim().length === 0)
|
|
82
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}" braucht eine Beschreibung (Feld „beschreibung")`);
|
|
83
|
+
const homepage = feldText(pfad, name, roh, 'homepage');
|
|
84
|
+
const notizen = feldText(pfad, name, roh, 'notizen');
|
|
85
|
+
const konfigRoh = roh['konfig'];
|
|
86
|
+
if (!istObjekt(konfigRoh))
|
|
87
|
+
throw new McpKatalogFehler(pfad, `Eintrag „${name}" braucht einen Konfigblock (Feld „konfig")`);
|
|
88
|
+
const konfig = konfigLesen(pfad, name, konfigRoh);
|
|
89
|
+
return {
|
|
90
|
+
name,
|
|
91
|
+
beschreibung,
|
|
92
|
+
...(homepage === undefined ? {} : { homepage }),
|
|
93
|
+
...(notizen === undefined ? {} : { notizen }),
|
|
94
|
+
konfig,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export class DateiMcpKatalog {
|
|
98
|
+
pfad;
|
|
99
|
+
constructor(pfad) {
|
|
100
|
+
this.pfad = pfad;
|
|
101
|
+
}
|
|
102
|
+
/** Fehlende Datei: leerer Katalog, kein Fehler. Kaputtes YAML oder falsches Format: Fehler mit Pfad. */
|
|
103
|
+
async lesen() {
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
text = await readFile(this.pfad, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
let daten;
|
|
112
|
+
try {
|
|
113
|
+
daten = parseDocument(text, { strict: true }).toJS();
|
|
114
|
+
}
|
|
115
|
+
catch (fehler) {
|
|
116
|
+
throw new McpKatalogFehler(this.pfad, `kaputtes YAML: ${fehlertext(fehler)}`);
|
|
117
|
+
}
|
|
118
|
+
if (daten === null || daten === undefined)
|
|
119
|
+
return [];
|
|
120
|
+
if (!istObjekt(daten))
|
|
121
|
+
throw new McpKatalogFehler(this.pfad, 'die Datei muss ein Objekt mit einem Eintrag je MCP-Name sein');
|
|
122
|
+
return Object.entries(daten).map(([name, roh]) => eintragLesen(this.pfad, name, roh));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { istWindowsLaufwerkspfad } from "../kern/quelle-ort.js";
|
|
2
3
|
export { quelleAlsText } from "../kern/modell.js";
|
|
3
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* URL heißt Git-Quelle mit Checkout, ein nackter Pfad heißt lokale Arbeitskopie ohne Clone (B11).
|
|
6
|
+
*
|
|
7
|
+
* Ein Windows-Laufwerkspfad ist ein nackter Pfad (E65/B12): `C:\work\quelle` traf sonst die
|
|
8
|
+
* scp-Regel `host:pfad` — Host „C" — und `hmgr source add C:\work\quelle` legte die Arbeitskopie
|
|
9
|
+
* als Git-Quelle mit Remote an statt als lokale. Sichtbar wurde das am Anzeigenamen „\work\quelle".
|
|
10
|
+
*/
|
|
4
11
|
export function quelleAusText(text) {
|
|
5
12
|
const roh = text.trim();
|
|
13
|
+
if (istWindowsLaufwerkspfad(roh))
|
|
14
|
+
return { art: 'lokal', pfad: path.resolve(roh) };
|
|
6
15
|
if (roh.includes('://') || /^(?:[^@/\s]+@)?[^@/:\s]+:[^/\s]/.test(roh))
|
|
7
16
|
return { art: 'git', url: roh };
|
|
8
17
|
return { art: 'lokal', pfad: path.resolve(roh) };
|
package/dist/adapter/skill-md.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Name, Beschreibung und Besonderheiten eines Skills aus dem Frontmatter der SKILL.md (E32, E39, E55).
|
|
2
2
|
import { parse } from 'yaml';
|
|
3
|
+
import { ohneBom } from "./datei-text.js";
|
|
3
4
|
export const SKILL_DATEI = 'SKILL.md';
|
|
4
5
|
/** Der Block zwischen der ersten Zeile `---` und der nächsten Zeile `---`. Fehlt er, undefined. */
|
|
5
6
|
function frontmatter(inhalt) {
|
|
6
|
-
const zeilen = inhalt
|
|
7
|
+
const zeilen = ohneBom(inhalt).split('\n');
|
|
7
8
|
if (zeilen[0]?.trimEnd() !== '---')
|
|
8
9
|
return undefined;
|
|
9
10
|
for (let i = 1; i < zeilen.length; i += 1)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Prozess-Umgebung und .env-Dateien, für die F39-Prüfung beim MCP-Einhängen (E61).
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
export class ProzessUmgebung {
|
|
4
|
+
env;
|
|
5
|
+
constructor(env = process.env) {
|
|
6
|
+
this.env = env;
|
|
7
|
+
}
|
|
8
|
+
variablen() {
|
|
9
|
+
const ergebnis = {};
|
|
10
|
+
for (const [name, wert] of Object.entries(this.env))
|
|
11
|
+
if (wert !== undefined)
|
|
12
|
+
ergebnis[name] = wert;
|
|
13
|
+
return ergebnis;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Liest NAME=wert-Zeilen. Leere Zeilen und solche, die mit „#" beginnen, zählen nicht. Werte in
|
|
17
|
+
* einfachen oder doppelten Anführungszeichen werden davon befreit, sonst unverändert übernommen.
|
|
18
|
+
*/
|
|
19
|
+
async envDatei(pfad) {
|
|
20
|
+
let text;
|
|
21
|
+
try {
|
|
22
|
+
text = await readFile(pfad, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
const ergebnis = {};
|
|
28
|
+
for (const zeile of text.split('\n')) {
|
|
29
|
+
const getrimmt = zeile.trim();
|
|
30
|
+
if (getrimmt.length === 0 || getrimmt.startsWith('#'))
|
|
31
|
+
continue;
|
|
32
|
+
const trenner = getrimmt.indexOf('=');
|
|
33
|
+
if (trenner <= 0)
|
|
34
|
+
continue;
|
|
35
|
+
const name = getrimmt.slice(0, trenner).trim();
|
|
36
|
+
let wert = getrimmt.slice(trenner + 1).trim();
|
|
37
|
+
if (wert.length >= 2 && ((wert.startsWith('"') && wert.endsWith('"')) || (wert.startsWith("'") && wert.endsWith("'"))))
|
|
38
|
+
wert = wert.slice(1, -1);
|
|
39
|
+
ergebnis[name] = wert;
|
|
40
|
+
}
|
|
41
|
+
return ergebnis;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Gemeinsames Modul der Adapter: Vergleich zweier aus YAML oder JSON gelesener Werte.
|
|
2
|
+
/**
|
|
3
|
+
* Tiefer Vergleich, ohne Rücksicht auf die Schlüsselreihenfolge. Gebraucht, wo ein Adapter einen
|
|
4
|
+
* schon vorhandenen MCP-Eintrag gegen den Katalogblock hält (E61): gleicher Block heißt „schon
|
|
5
|
+
* eingehängt", ein abweichender wird nie stillschweigend überschrieben.
|
|
6
|
+
*/
|
|
7
|
+
export function tiefGleich(a, b) {
|
|
8
|
+
if (a === b)
|
|
9
|
+
return true;
|
|
10
|
+
if (Array.isArray(a) || Array.isArray(b))
|
|
11
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => tiefGleich(v, b[i]));
|
|
12
|
+
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
|
13
|
+
const ak = Object.keys(a).sort();
|
|
14
|
+
const bk = Object.keys(b).sort();
|
|
15
|
+
if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i]))
|
|
16
|
+
return false;
|
|
17
|
+
return ak.every((k) => tiefGleich(a[k], b[k]));
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { leererZustand } from "../ports/zustand.js";
|
|
5
|
+
import { ohneBom } from "./datei-text.js";
|
|
5
6
|
export class ZustandJson {
|
|
6
7
|
pfad;
|
|
7
8
|
constructor(pfad) {
|
|
@@ -11,7 +12,7 @@ export class ZustandJson {
|
|
|
11
12
|
const roh = await readFile(this.pfad, 'utf8').catch(() => undefined);
|
|
12
13
|
if (roh === undefined)
|
|
13
14
|
return leererZustand();
|
|
14
|
-
const daten = JSON.parse(roh);
|
|
15
|
+
const daten = JSON.parse(ohneBom(roh));
|
|
15
16
|
return {
|
|
16
17
|
quellen: (daten.quellen ?? []),
|
|
17
18
|
arbeitsverzeichnisse: (daten.arbeitsverzeichnisse ?? []),
|
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
|
+
mcp add <name> MCP-Server aus dem Katalog in ein Ziel einhängen
|
|
26
|
+
mcp remove <name> MCP-Server aus einem Ziel aushängen
|
|
27
|
+
mcp migrate <name> MCP-Server aus der geteilten .mcp.json in den privaten Scope übernehmen
|
|
28
|
+
mcp list Katalog-Einträge zeigen, mit wo sie eingehängt sind
|
|
25
29
|
sync [pfad] Ziel auf den Stand seines Zeigers bringen
|
|
26
30
|
update Alle Checkouts aktualisieren
|
|
27
31
|
status Ist-Zustand der Ziele
|
|
@@ -226,6 +230,99 @@ Häufige Fälle:
|
|
|
226
230
|
kann nur die Quelle beheben.
|
|
227
231
|
|
|
228
232
|
Siehe auch: hmgr help source, hmgr help sync`;
|
|
233
|
+
const MCP = `hmgr mcp add <name> [schalter]
|
|
234
|
+
hmgr mcp remove <name> [schalter]
|
|
235
|
+
hmgr mcp migrate <name> [schalter]
|
|
236
|
+
hmgr mcp list
|
|
237
|
+
|
|
238
|
+
Ein MCP-Server ist ein Eintrag im persönlichen Katalog ~/.hmgr/mcp.yaml, nicht in einer Quelle: kein
|
|
239
|
+
Download, keine Installation, kein Update. hmgr liest den Katalog nur, er wird von Hand oder von
|
|
240
|
+
einem LLM gepflegt, das den MCP ohnehin gerade einrichtet. mcp add hängt einen Katalog-Eintrag in
|
|
241
|
+
ein Ziel ein (Projekt oder global), mcp remove hängt ihn wieder aus, mcp list zeigt den Katalog mit
|
|
242
|
+
je Eintrag, wo er eingehängt ist, dazu Einträge, die in Harness-Konfigurationen liegen, aber nicht
|
|
243
|
+
im Katalog stehen (nur genannt, hmgr fasst sie nicht an). mcp list sieht dabei in den Homes und
|
|
244
|
+
Profilen der Harnesses nach und in den Arbeitsverzeichnissen, die hmgr kennt (hmgr dir add); ein
|
|
245
|
+
MCP in einem Verzeichnis, das hmgr nicht kennt, taucht dort nicht auf.
|
|
246
|
+
|
|
247
|
+
Format von ~/.hmgr/mcp.yaml: ein Objekt, Schlüssel ist der MCP-Name. Je Eintrag:
|
|
248
|
+
|
|
249
|
+
beschreibung Pflicht, kurzer Text
|
|
250
|
+
homepage optional, URL
|
|
251
|
+
notizen optional, mehrzeilig
|
|
252
|
+
konfig Pflicht: entweder command (Pflicht), optional args und env,
|
|
253
|
+
oder url (Pflicht), optional headers
|
|
254
|
+
|
|
255
|
+
Andere Schlüssel im konfig-Block sind ein Fehler, mit Eintrag und Feld in der Meldung. Secrets
|
|
256
|
+
stehen als \${VAR}- oder \${env:VAR}-Verweise im Block, nie im Klartext; aufgelöst werden sie vom
|
|
257
|
+
Harness gegen dessen Umgebung, hmgr prüft beim Einhängen nur, ob die Variablen gesetzt sind, und
|
|
258
|
+
warnt sonst. Fehlt die Datei, ist der Katalog leer, kein Fehler; kaputtes YAML ist ein Fehler mit
|
|
259
|
+
dem Dateipfad.
|
|
260
|
+
|
|
261
|
+
Beispiel:
|
|
262
|
+
|
|
263
|
+
teamwork:
|
|
264
|
+
beschreibung: Teamwork.com Projektmanagement
|
|
265
|
+
homepage: https://teamwork.com
|
|
266
|
+
notizen: |
|
|
267
|
+
Erfordert TEAMWORK_API_KEY, siehe Teamwork-Profil.
|
|
268
|
+
konfig:
|
|
269
|
+
command: npx
|
|
270
|
+
args: ["-y", "@teamwork/mcp-server"]
|
|
271
|
+
env:
|
|
272
|
+
TEAMWORK_API_KEY: \${TEAMWORK_API_KEY}
|
|
273
|
+
|
|
274
|
+
context7:
|
|
275
|
+
beschreibung: Context7 Doku-Suche
|
|
276
|
+
konfig:
|
|
277
|
+
url: https://mcp.context7.com/mcp
|
|
278
|
+
headers:
|
|
279
|
+
Authorization: Bearer \${env:CONTEXT7_TOKEN}
|
|
280
|
+
|
|
281
|
+
Die Fähigkeit hängt am Harness-Adapter, nicht am Namen (E16): claude-code kennt Projekt (Scope
|
|
282
|
+
local, nie die geteilte .mcp.json der Projektwurzel, das bleibt Team-Sache wie beim Zeiger, E57) und
|
|
283
|
+
global (Scope user). Hermes kennt nur global, mcp_servers in seiner config.yaml (Home oder Profil
|
|
284
|
+
mit --ort profil:<name>); ein Projekt-Ziel ist bei Hermes ein Fehler, es kennt keine Projektebene für
|
|
285
|
+
MCPs. agents kennt MCP-Server gar nicht.
|
|
286
|
+
|
|
287
|
+
mcp migrate ist der einzige Weg, auf dem hmgr die geteilte .mcp.json anfasst (E64): der Eintrag
|
|
288
|
+
wandert 1:1 aus der Datei in deinen privaten Scope am selben Ort und wird dort gelöscht. Auch ein
|
|
289
|
+
Eintrag, der nicht im Katalog steht, lässt sich so übernehmen — der Konfigblock kommt aus der Datei,
|
|
290
|
+
nicht aus dem Katalog. Ist die Datei mit dem Team geteilt, verlieren die anderen den Server, sobald
|
|
291
|
+
die Änderung committet ist. Nur claude-code hat eine solche Datei und nur an einem
|
|
292
|
+
Arbeitsverzeichnis. Gearbeitet wird in zwei Schritten, erst eintragen, dann entfernen: bricht der
|
|
293
|
+
zweite ab, steht der Eintrag doppelt statt gar nicht mehr, und die Meldung sagt, was zu tun bleibt.
|
|
294
|
+
|
|
295
|
+
Nach dem Einhängen/Aushängen nennt hmgr den Weg zur Wirkung: Claude Code übernimmt die Änderung erst
|
|
296
|
+
mit der nächsten Sitzung, Hermes lädt seine config.yaml selbst nach, dafür gibt es keinen Hinweis.
|
|
297
|
+
|
|
298
|
+
Fehlende \${VAR}-Verweise beim Einhängen sind kein Abbruch, nur eine Warnung (F39): hmgr kann nicht
|
|
299
|
+
wissen, was die Shell beim Start des Harness setzt. Geprüft wird gegen die eigene Prozess-Umgebung
|
|
300
|
+
und, bei einem Hermes-Ziel, zusätzlich gegen <hermes-home>/.env.
|
|
301
|
+
|
|
302
|
+
Schalter (add, remove und migrate):
|
|
303
|
+
--harness <name> claude-code oder hermes, Standard claude-code
|
|
304
|
+
--ort <ort> home, dir:<pfad> oder profil:<name>, Standard home
|
|
305
|
+
|
|
306
|
+
Beispiele:
|
|
307
|
+
hmgr mcp list
|
|
308
|
+
hmgr mcp add teamwork --ort dir:.
|
|
309
|
+
hmgr mcp add context7 --harness hermes --ort profil:researcher
|
|
310
|
+
hmgr mcp remove teamwork --ort dir:.
|
|
311
|
+
hmgr mcp migrate kollegen-tool --ort dir:.
|
|
312
|
+
|
|
313
|
+
Häufige Fälle:
|
|
314
|
+
„steht nicht im Katalog": hmgr mcp list zeigt die bekannten Namen, ~/.hmgr/mcp.yaml pflegt sie.
|
|
315
|
+
„kennt keine MCP-Server": nur claude-code und hermes haben diese Fähigkeit, agents nicht.
|
|
316
|
+
„kennt für MCP-Server den Ort … nicht – Hermes kennt keine Projektebene für MCPs": bei Hermes
|
|
317
|
+
--ort home oder --ort profil:<name> nutzen, kein dir:<pfad>.
|
|
318
|
+
„liegt schon ein MCP-Server …, der nicht von hmgr stammt oder vom Katalogblock abweicht": von Hand
|
|
319
|
+
angleichen oder hmgr mcp remove, dann erneut versuchen.
|
|
320
|
+
„steht in der geteilten …/.mcp.json": mcp remove fasst sie nicht an, hmgr mcp migrate <name> holt
|
|
321
|
+
den Eintrag in den privaten Scope und löscht ihn aus der Datei.
|
|
322
|
+
„gibt es keine geteilte Konfigurationsdatei": mcp migrate geht nur mit --harness claude-code und
|
|
323
|
+
--ort dir:<pfad>.
|
|
324
|
+
|
|
325
|
+
Siehe auch: hmgr help harness, hmgr help status`;
|
|
229
326
|
const SYNC = `hmgr sync [pfad]
|
|
230
327
|
|
|
231
328
|
Bringt ein Arbeitsverzeichnis auf den Stand seines Zeigers: fehlende Quellen übernehmen, fehlende
|
|
@@ -359,6 +456,7 @@ const THEMEN = new Map([
|
|
|
359
456
|
['unlink', UNLINK],
|
|
360
457
|
['adopt', ADOPT],
|
|
361
458
|
['plugin', PLUGIN],
|
|
459
|
+
['mcp', MCP],
|
|
362
460
|
['sync', SYNC],
|
|
363
461
|
['update', UPDATE],
|
|
364
462
|
['status', STATUS],
|
package/dist/cli/main.js
CHANGED
|
@@ -5,15 +5,17 @@ import { realpathSync } from 'node:fs';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
import { Anwendungsfaelle } from "../kern/anwendungsfaelle.js";
|
|
8
|
-
import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, HarnessNichtVorhanden, HarnessTypUnbekannt, KeinPassenderRef, NameBelegt, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, QuelleAufraeumenLokaleAenderungen, QuelleNameBelegt, QuelleUnbekannt, SkillMehrdeutig, SkillOrdnerBereitsVerwaltet, SkillOrdnerFaehigkeitFehlt, SkillOrdnerNichtVerwaltet, SkillOrdnerUngueltig, SkillUnbekannt, ZeigerMehrdeutig, ZeigerSchreibenGesperrt, pluginSchluessel, quelleName, quelleSchluessel, } from "../kern/modell.js";
|
|
8
|
+
import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, HarnessNichtVorhanden, HarnessTypUnbekannt, KeinPassenderRef, LinkAnlegenFehlgeschlagen, McpAbweichenderEintrag, McpEbeneNichtUnterstuetzt, McpFaehigkeitFehlt, McpKatalogFehler, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, McpUnbekannt, NameBelegt, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, QuelleAufraeumenLokaleAenderungen, QuelleNameBelegt, QuelleUnbekannt, SkillMehrdeutig, SkillOrdnerBereitsVerwaltet, SkillOrdnerFaehigkeitFehlt, SkillOrdnerNichtVerwaltet, SkillOrdnerUngueltig, SkillUnbekannt, ZeigerMehrdeutig, ZeigerSchreibenGesperrt, pluginSchluessel, quelleName, quelleSchluessel, } from "../kern/modell.js";
|
|
9
9
|
import { Agents } from "../adapter/agents.js";
|
|
10
10
|
import { ClaudeCode } from "../adapter/claude-code.js";
|
|
11
11
|
import { Hermes } from "../adapter/hermes.js";
|
|
12
12
|
import { ComposerZeiger } from "../adapter/composer.js";
|
|
13
13
|
import { DateisystemZiele } from "../adapter/dateisystem.js";
|
|
14
14
|
import { GitQuellen } from "../adapter/git.js";
|
|
15
|
+
import { DateiMcpKatalog } from "../adapter/mcp-katalog.js";
|
|
15
16
|
import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
|
|
16
17
|
import { TerminalRueckfrage } from "../adapter/terminal.js";
|
|
18
|
+
import { ProzessUmgebung } from "../adapter/umgebung.js";
|
|
17
19
|
import { ZustandJson } from "../adapter/zustand-json.js";
|
|
18
20
|
import { HILFE, hilfeText } from "./hilfe.js";
|
|
19
21
|
export function umgebungLesen(env = process.env) {
|
|
@@ -38,6 +40,8 @@ export function bauen(umgebung, optionen = {}) {
|
|
|
38
40
|
zeiger: new ComposerZeiger(),
|
|
39
41
|
zustand,
|
|
40
42
|
rueckfrage: optionen.rueckfrage ?? new TerminalRueckfrage(),
|
|
43
|
+
mcpKatalog: new DateiMcpKatalog(path.join(umgebung.hmgrHome, 'mcp.yaml')),
|
|
44
|
+
umgebung: optionen.umgebung ?? new ProzessUmgebung(),
|
|
41
45
|
};
|
|
42
46
|
return { anwendungsfaelle: new Anwendungsfaelle(ports), ports };
|
|
43
47
|
}
|
|
@@ -55,6 +59,7 @@ const SCHALTER = {
|
|
|
55
59
|
unlink: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal'] },
|
|
56
60
|
adopt: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal', 'probelauf'] },
|
|
57
61
|
plugin: { werte: ['harness', 'ort', 'composer', 'quelle'], flaggen: ['local', 'lokal'] },
|
|
62
|
+
mcp: { werte: ['harness', 'ort'], flaggen: [] },
|
|
58
63
|
sync: { werte: [], flaggen: [] },
|
|
59
64
|
update: { werte: [], flaggen: [] },
|
|
60
65
|
status: { werte: [], flaggen: [] },
|
|
@@ -82,6 +87,13 @@ const UNTERKOMMANDOS = {
|
|
|
82
87
|
remove: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'local', 'lokal'] },
|
|
83
88
|
list: { woerter: 1, schalter: [] },
|
|
84
89
|
},
|
|
90
|
+
mcp: {
|
|
91
|
+
add: { woerter: 2, schalter: ['harness', 'ort'] },
|
|
92
|
+
remove: { woerter: 2, schalter: ['harness', 'ort'] },
|
|
93
|
+
// Übernehmen (E64): dieselben Ziel-Schalter wie add und remove.
|
|
94
|
+
migrate: { woerter: 2, schalter: ['harness', 'ort'] },
|
|
95
|
+
list: { woerter: 1, schalter: [] },
|
|
96
|
+
},
|
|
85
97
|
harness: {
|
|
86
98
|
list: { woerter: 1, schalter: [] },
|
|
87
99
|
enable: { woerter: 2, schalter: [] },
|
|
@@ -207,7 +219,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
|
|
|
207
219
|
console.log(quelleAlsText(quelle) === quelleAlsText(genannt)
|
|
208
220
|
? 'Quelle ist schon bekannt, keine zweite angelegt.'
|
|
209
221
|
: `Quelle ist schon bekannt, geführt als ${quelleAlsText(quelle)}. Keine zweite angelegt.`);
|
|
210
|
-
console.log(`Quelle ${quelleAlsText(quelle)} (${quelle.art}) als „${quelleName(quelle)}", ${skills.length} Skills:`);
|
|
222
|
+
console.log(`Quelle ${quelleAlsText(quelle)} (${quelle.art}) als „${quelleName(quelle)}", ${skills.length} ${skills.length === 1 ? 'Skill' : 'Skills'}:`);
|
|
211
223
|
for (const skill of skills) {
|
|
212
224
|
const beschreibung = skill.beschreibung === undefined ? '' : ` ${kappen(skill.beschreibung)}`;
|
|
213
225
|
console.log(` ${skill.name} ${skill.pfad}${beschreibung}`);
|
|
@@ -478,6 +490,64 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
|
|
|
478
490
|
console.log(` ${zeile}`);
|
|
479
491
|
return 0;
|
|
480
492
|
}
|
|
493
|
+
if (kommando === 'mcp') {
|
|
494
|
+
const schalter = schalterLesen(argv.slice(1), 'mcp');
|
|
495
|
+
unterkommandoPruefen('mcp', schalter);
|
|
496
|
+
const { rest, werte } = schalter;
|
|
497
|
+
const unterkommando = rest[0];
|
|
498
|
+
if (unterkommando === 'list') {
|
|
499
|
+
const uebersicht = await anwendungsfaelle.mcpUebersicht();
|
|
500
|
+
if (uebersicht.katalog.length === 0)
|
|
501
|
+
console.log('Kein MCP im Katalog. hmgr help mcp zeigt das Format von ~/.hmgr/mcp.yaml.');
|
|
502
|
+
for (const { eintrag, eingehaengt, geteilteFundorte } of uebersicht.katalog) {
|
|
503
|
+
console.log(`${eintrag.name} ${eintrag.beschreibung}`);
|
|
504
|
+
if (eingehaengt.length === 0 && geteilteFundorte.length === 0)
|
|
505
|
+
console.log(' nirgends eingehängt');
|
|
506
|
+
for (const ziel of eingehaengt)
|
|
507
|
+
console.log(` ${ziel.harness} ${beschreibeOrt(ziel.ort)}`);
|
|
508
|
+
// Geteilte, nur lesbare Fundorte (E61-Nachtrag): eigens gekennzeichnet, hmgr fasst sie nicht an.
|
|
509
|
+
for (const f of geteilteFundorte)
|
|
510
|
+
console.log(` ${f.ziel.harness} ${beschreibeOrt(f.ziel.ort)} ${f.datei} · Scope ${f.scope} (geteilt, hmgr fasst sie nicht an)`);
|
|
511
|
+
}
|
|
512
|
+
if (uebersicht.unbekannt.length > 0 || uebersicht.unbekannteGeteilteFundorte.length > 0) {
|
|
513
|
+
console.log('Nicht im Katalog, aber eingehängt (hmgr fasst sie nicht an):');
|
|
514
|
+
for (const { ziel, name } of uebersicht.unbekannt)
|
|
515
|
+
console.log(` ${name} ${ziel.harness} ${beschreibeOrt(ziel.ort)}`);
|
|
516
|
+
for (const f of uebersicht.unbekannteGeteilteFundorte)
|
|
517
|
+
console.log(` ${f.name} ${f.ziel.harness} ${beschreibeOrt(f.ziel.ort)} ${f.datei} · Scope ${f.scope}`);
|
|
518
|
+
}
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
if (unterkommando !== 'add' && unterkommando !== 'remove' && unterkommando !== 'migrate')
|
|
522
|
+
throw new Error(`${unterkommando === undefined ? 'mcp braucht ein Unterkommando' : `Unbekanntes Unterkommando „${unterkommando}" für mcp`}: mcp add <name>, mcp remove <name>, mcp migrate <name> oder mcp list. hmgr help mcp zeigt Beispiele.`);
|
|
523
|
+
const name = rest[1];
|
|
524
|
+
if (name === undefined)
|
|
525
|
+
throw new Error(`mcp ${unterkommando} braucht einen MCP-Namen. hmgr mcp list zeigt die Katalog-Einträge.`);
|
|
526
|
+
const ziel = { harness: werte.get('harness') ?? 'claude-code', ort: ortLesen(werte.get('ort')) };
|
|
527
|
+
// Übernehmen (E64): der Name muss nicht im Katalog stehen, der Block kommt aus der geteilten Datei.
|
|
528
|
+
if (unterkommando === 'migrate') {
|
|
529
|
+
const ergebnis = await anwendungsfaelle.mcpUebernehmen({ name, ziel });
|
|
530
|
+
console.log(`MCP ${ergebnis.name} in den privaten Scope von ${ziel.harness} ${beschreibeOrt(ziel.ort)} übernommen `
|
|
531
|
+
+ `und aus ${ergebnis.datei} entfernt.`);
|
|
532
|
+
if (ergebnis.hinweis !== undefined)
|
|
533
|
+
console.log(` ${ergebnis.hinweis}`);
|
|
534
|
+
return 0;
|
|
535
|
+
}
|
|
536
|
+
if (unterkommando === 'add') {
|
|
537
|
+
const ergebnis = await anwendungsfaelle.mcpEinhaengen({ name, ziel });
|
|
538
|
+
console.log(`MCP ${ergebnis.eintrag.name} eingehängt in ${ziel.harness} ${beschreibeOrt(ziel.ort)}.`);
|
|
539
|
+
if (ergebnis.hinweis !== undefined)
|
|
540
|
+
console.log(` ${ergebnis.hinweis}`);
|
|
541
|
+
if (ergebnis.warnung !== undefined)
|
|
542
|
+
console.log(` ${ergebnis.warnung}`);
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
const ergebnis = await anwendungsfaelle.mcpAushaengen({ name, ziel });
|
|
546
|
+
console.log(`MCP ${ergebnis.eintrag.name} ausgehängt aus ${ziel.harness} ${beschreibeOrt(ziel.ort)}.`);
|
|
547
|
+
if (ergebnis.hinweis !== undefined)
|
|
548
|
+
console.log(` ${ergebnis.hinweis}`);
|
|
549
|
+
return 0;
|
|
550
|
+
}
|
|
481
551
|
if (kommando === 'sync') {
|
|
482
552
|
const { rest } = schalterLesen(argv.slice(1), 'sync');
|
|
483
553
|
restPruefen(rest, 1, 'sync');
|
|
@@ -691,6 +761,7 @@ function beschreibeOrt(ort) {
|
|
|
691
761
|
}
|
|
692
762
|
function istModellFehler(fehler) {
|
|
693
763
|
return (fehler instanceof NameBelegt ||
|
|
764
|
+
fehler instanceof LinkAnlegenFehlgeschlagen ||
|
|
694
765
|
fehler instanceof SkillMehrdeutig ||
|
|
695
766
|
fehler instanceof SkillUnbekannt ||
|
|
696
767
|
fehler instanceof OrtNichtUnterstuetzt ||
|
|
@@ -709,7 +780,16 @@ function istModellFehler(fehler) {
|
|
|
709
780
|
fehler instanceof SkillOrdnerFaehigkeitFehlt ||
|
|
710
781
|
fehler instanceof SkillOrdnerBereitsVerwaltet ||
|
|
711
782
|
fehler instanceof SkillOrdnerNichtVerwaltet ||
|
|
712
|
-
fehler instanceof SkillOrdnerUngueltig
|
|
783
|
+
fehler instanceof SkillOrdnerUngueltig ||
|
|
784
|
+
fehler instanceof McpUnbekannt ||
|
|
785
|
+
fehler instanceof McpFaehigkeitFehlt ||
|
|
786
|
+
fehler instanceof McpEbeneNichtUnterstuetzt ||
|
|
787
|
+
fehler instanceof McpAbweichenderEintrag ||
|
|
788
|
+
fehler instanceof McpKatalogFehler ||
|
|
789
|
+
fehler instanceof McpNurGeteilterFundort ||
|
|
790
|
+
fehler instanceof McpUebernahmeNichtMoeglich ||
|
|
791
|
+
fehler instanceof McpNichtInGeteilterDatei ||
|
|
792
|
+
fehler instanceof McpUebernahmeUnvollstaendig);
|
|
713
793
|
}
|
|
714
794
|
/**
|
|
715
795
|
* Der nächste Schritt zu einem Fehler aus dem Kern. Die Fehlertexte selbst bleiben, wie das Modell
|
|
@@ -752,6 +832,20 @@ function naechsterSchritt(fehler) {
|
|
|
752
832
|
return fehler.grund === 'ausserhalb'
|
|
753
833
|
? 'Der Pfad muss unterhalb des Hermes-Home liegen.'
|
|
754
834
|
: 'Das ist Hermes\' eigener Skill-Ordner, ein anderer Pfad muss her.';
|
|
835
|
+
if (fehler instanceof McpUnbekannt)
|
|
836
|
+
return 'hmgr mcp list zeigt die Katalog-Einträge. Gepflegt wird der Katalog in ~/.hmgr/mcp.yaml, hmgr help mcp zeigt das Format.';
|
|
837
|
+
if (fehler instanceof McpFaehigkeitFehlt)
|
|
838
|
+
return 'Nur claude-code und hermes kennen MCP-Server.';
|
|
839
|
+
if (fehler instanceof McpEbeneNichtUnterstuetzt)
|
|
840
|
+
return 'claude-code kennt --ort home und --ort dir:<pfad>, hermes --ort home und --ort profil:<name>.';
|
|
841
|
+
if (fehler instanceof McpAbweichenderEintrag)
|
|
842
|
+
return 'Erst von Hand angleichen oder mit hmgr mcp remove entfernen, dann erneut versuchen.';
|
|
843
|
+
if (fehler instanceof McpKatalogFehler)
|
|
844
|
+
return '~/.hmgr/mcp.yaml prüfen und korrigieren, hmgr help mcp zeigt das Format.';
|
|
845
|
+
if (fehler instanceof McpUebernahmeNichtMoeglich)
|
|
846
|
+
return 'Eine geteilte .mcp.json kennt nur claude-code, und nur an einem Arbeitsverzeichnis: --harness claude-code --ort dir:<pfad>.';
|
|
847
|
+
if (fehler instanceof McpNichtInGeteilterDatei)
|
|
848
|
+
return 'hmgr mcp list zeigt, welche Namen dort stehen; mcp migrate übernimmt nur Einträge aus der geteilten Datei.';
|
|
755
849
|
return undefined;
|
|
756
850
|
}
|
|
757
851
|
export async function main(argv, umgebung) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Datei-Watcher für externe Änderungen (E62): main.ts verdrahtet nur, die eigentliche Logik ist
|
|
2
|
+
// hier rein (kein Electron-Import), nach dem Muster von oeffnen.ts und icons.ts, damit sie ohne
|
|
3
|
+
// Electron testbar ist.
|
|
4
|
+
//
|
|
5
|
+
// Beobachtet wird eine feste Liste von Pfaden (hmgr-Zustand, MCP-Katalog, Harness-MCP-Konfigs,
|
|
6
|
+
// .env-Dateien, siehe main.ts). Zwei Eigenheiten des Dateisystems bestimmen den Aufbau, beide am
|
|
7
|
+
// laufenden System gemessen (macOS 25, Node 22):
|
|
8
|
+
//
|
|
9
|
+
// 1. Beobachtet wird nie die Datei selbst, sondern immer ihr Elternverzeichnis mit einem Filter auf
|
|
10
|
+
// die Dateinamen darin. `fs.watch` auf eine Datei hängt am Inode und wird nach dem ersten
|
|
11
|
+
// atomaren Ersetzen (schreiben in eine Temp-Datei, dann `rename` darüber) dauerhaft taub — kein
|
|
12
|
+
// weiteres Event, weder bei erneutem `rename` noch beim Schreiben an Ort und Stelle. Genau so
|
|
13
|
+
// schreiben viele Editoren, und so wird auch `~/.claude.json` ersetzt. Ein Watcher auf das
|
|
14
|
+
// Verzeichnis überlebt das und meldet den Dateinamen mit. Nebenbei deckt er Dateien ab, die beim
|
|
15
|
+
// Start noch gar nicht existieren: tauchen sie später auf, fängt derselbe Watcher es ab.
|
|
16
|
+
//
|
|
17
|
+
// 2. Ein Event heißt nicht, dass sich etwas geändert hat. `claude plugin list` — das hmgr bei jedem
|
|
18
|
+
// Anzeige-Aufbau je Claude-Code-Ziel ruft — ersetzt `~/.claude.json` jedes Mal per `rename`, mit
|
|
19
|
+
// unverändertem Inhalt. Ohne Gegenmaßnahme entstünde daraus eine Endlosschleife: Anzeige laden →
|
|
20
|
+
// Datei ersetzt → Watcher feuert → Anzeige laden. Darum stößt der Watcher erst an, wenn sich der
|
|
21
|
+
// Inhalt mindestens einer beobachteten Datei gegenüber dem letzten Stand wirklich unterscheidet.
|
|
22
|
+
//
|
|
23
|
+
// Fehlt das Elternverzeichnis, bleibt der Pfad unbeobachtet, bis ihn der nächste Fokus-Reload (E62)
|
|
24
|
+
// ohnehin erfasst. Mehrere Events in kurzer Folge (ein Editor schreibt beim Speichern oft mehrfach,
|
|
25
|
+
// ein `rename` meldet mehrere Events) lösen genau einen Callback aus: jedes Event verschiebt einen
|
|
26
|
+
// Timer um `entprellenMs` nach hinten, erst danach wird verglichen.
|
|
27
|
+
import { readFileSync, watch } from 'node:fs';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
const STANDARD_ENTPRELLEN_MS = 400;
|
|
31
|
+
/** Marke für eine Datei, die es (noch) nicht gibt — unterscheidbar von jedem echten Inhalt. */
|
|
32
|
+
const FEHLT = 'fehlt';
|
|
33
|
+
/** Fingerabdruck des Inhalts. Unlesbar (fehlend, keine Rechte) zählt als `FEHLT`, nicht als Änderung. */
|
|
34
|
+
function fingerabdruck(pfad) {
|
|
35
|
+
try {
|
|
36
|
+
return createHash('sha256').update(readFileSync(pfad)).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return FEHLT;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Beobachtet `pfade` auf inhaltliche Änderungen und ruft `callback` entprellt auf. Doppelte Pfade
|
|
44
|
+
* werden nur einmal beobachtet, Pfade im selben Verzeichnis teilen sich einen Watcher.
|
|
45
|
+
*/
|
|
46
|
+
export function beobachten(pfade, callback, optionen = {}) {
|
|
47
|
+
const entprellenMs = optionen.entprellenMs ?? STANDARD_ENTPRELLEN_MS;
|
|
48
|
+
const watcher = [];
|
|
49
|
+
let timer;
|
|
50
|
+
let geschlossen = false;
|
|
51
|
+
const eindeutige = [...new Set(pfade)];
|
|
52
|
+
const stand = new Map(eindeutige.map((p) => [p, fingerabdruck(p)]));
|
|
53
|
+
/** Zieht den Stand nach und sagt, ob sich dabei etwas geändert hat. */
|
|
54
|
+
function hatSichGeaendert() {
|
|
55
|
+
let geaendert = false;
|
|
56
|
+
for (const pfad of eindeutige) {
|
|
57
|
+
const neu = fingerabdruck(pfad);
|
|
58
|
+
if (stand.get(pfad) !== neu) {
|
|
59
|
+
stand.set(pfad, neu);
|
|
60
|
+
geaendert = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return geaendert;
|
|
64
|
+
}
|
|
65
|
+
function anstossen() {
|
|
66
|
+
if (geschlossen)
|
|
67
|
+
return;
|
|
68
|
+
if (timer !== undefined)
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
timer = setTimeout(() => {
|
|
71
|
+
timer = undefined;
|
|
72
|
+
if (geschlossen)
|
|
73
|
+
return;
|
|
74
|
+
if (hatSichGeaendert())
|
|
75
|
+
callback();
|
|
76
|
+
}, entprellenMs);
|
|
77
|
+
}
|
|
78
|
+
// Je Verzeichnis ein Watcher, der die dort gesuchten Dateinamen kennt.
|
|
79
|
+
const jeVerzeichnis = new Map();
|
|
80
|
+
for (const pfad of eindeutige) {
|
|
81
|
+
const verzeichnis = path.dirname(pfad);
|
|
82
|
+
const namen = jeVerzeichnis.get(verzeichnis) ?? new Set();
|
|
83
|
+
namen.add(path.basename(pfad));
|
|
84
|
+
jeVerzeichnis.set(verzeichnis, namen);
|
|
85
|
+
}
|
|
86
|
+
for (const [verzeichnis, namen] of jeVerzeichnis) {
|
|
87
|
+
try {
|
|
88
|
+
watcher.push(watch(verzeichnis, (_ereignis, geaenderterName) => {
|
|
89
|
+
// Ohne Namen (kommt vor, je nach Plattform) wird konservativ angestoßen; der
|
|
90
|
+
// Inhaltsvergleich entscheidet ohnehin.
|
|
91
|
+
if (geaenderterName === null || namen.has(path.basename(geaenderterName)))
|
|
92
|
+
anstossen();
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Das Verzeichnis fehlt: diese Pfade bleiben unbeobachtet (siehe Kommentar oben).
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
close() {
|
|
101
|
+
geschlossen = true;
|
|
102
|
+
if (timer !== undefined)
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
for (const w of watcher)
|
|
105
|
+
w.close();
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|