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
@@ -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
- /** URL heißt Git-Quelle mit Checkout, ein nackter Pfad heißt lokale Arbeitskopie ohne Clone (B11). */
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) };
@@ -1,9 +1,10 @@
1
- // Name eines Skills: `name` aus dem Frontmatter der SKILL.md, sonst der Verzeichnisname (E32).
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.replace(/^\uFEFF/, '').split('\n');
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)
@@ -12,38 +13,114 @@ function frontmatter(inhalt) {
12
13
  return undefined;
13
14
  }
14
15
  /**
15
- * Der Verzeichnisname ist nur Konvention, das Frontmatter ist die Aussage des Autors (E18, E32).
16
- * Ohne Frontmatter, ohne YAML-Objekt, bei kaputtem YAML oder ohne nicht leeren `name` gilt der
17
- * Verzeichnisname. Ein Name mit Pfadtrenner gilt nicht, aus ihm würde im Ziel kein Eintrag,
18
- * sondern ein Pfad.
16
+ * Das Frontmatter als Objekt, einmal geparst für alle Felder. Fehlt der Block, ist das YAML
17
+ * kaputt oder liest es sich nicht als Objekt, gibt es kein Ergebnis (E32).
19
18
  */
20
- export function skillName(verzeichnisname, skillMdInhalt) {
19
+ function frontmatterDaten(skillMdInhalt) {
21
20
  if (skillMdInhalt === undefined)
22
- return verzeichnisname;
21
+ return undefined;
23
22
  const block = frontmatter(skillMdInhalt);
24
23
  if (block === undefined)
25
- return verzeichnisname;
24
+ return undefined;
26
25
  let daten;
27
26
  try {
28
27
  daten = parse(block);
29
28
  }
30
29
  catch {
31
- return verzeichnisname;
30
+ return undefined;
32
31
  }
33
32
  if (typeof daten !== 'object' || daten === null || Array.isArray(daten))
34
- return verzeichnisname;
35
- const roh = daten['name'];
33
+ return undefined;
34
+ return daten;
35
+ }
36
+ /** Ein Textfeld, getrimmt, leer heißt „nicht vorhanden" (E39). */
37
+ function textFeld(daten, feld) {
38
+ const roh = daten?.[feld];
36
39
  if (typeof roh !== 'string')
37
- return verzeichnisname;
38
- const name = roh.trim();
39
- if (name.length === 0 || name === '.' || name === '..')
40
+ return undefined;
41
+ const wert = roh.trim();
42
+ return wert.length === 0 ? undefined : wert;
43
+ }
44
+ /** Ein boolesches Feld mit dem genannten erwarteten Wert, sonst „nicht vorhanden" (E55). */
45
+ function boolFeld(daten, feld, erwartet) {
46
+ return daten?.[feld] === erwartet ? true : undefined;
47
+ }
48
+ /**
49
+ * Ein Feld, das vorhanden und nicht leer sein muss, unabhängig von seiner Art (E55: hooks,
50
+ * allowed-tools). Leere Liste, leeres Objekt, leerer Text und `false` heißen „nicht vorhanden":
51
+ * beides sind Arten zu sagen, dass es nichts gibt, und die Marke behauptete sonst etwas
52
+ * Sicherheitsrelevantes, das nicht dasteht.
53
+ */
54
+ function vorhandenFeld(daten, feld) {
55
+ const roh = daten?.[feld];
56
+ if (roh === undefined || roh === null || roh === false)
57
+ return undefined;
58
+ if (typeof roh === 'string')
59
+ return roh.trim().length > 0 ? true : undefined;
60
+ if (Array.isArray(roh))
61
+ return roh.length > 0 ? true : undefined;
62
+ if (typeof roh === 'object')
63
+ return Object.keys(roh).length > 0 ? true : undefined;
64
+ return true;
65
+ }
66
+ /**
67
+ * Der Verzeichnisname ist nur Konvention, das Frontmatter ist die Aussage des Autors (E18, E32).
68
+ * Ohne Frontmatter, ohne YAML-Objekt, bei kaputtem YAML oder ohne nicht leeren `name` gilt der
69
+ * Verzeichnisname. Ein Name mit Pfadtrenner gilt nicht, aus ihm würde im Ziel kein Eintrag,
70
+ * sondern ein Pfad.
71
+ */
72
+ function nameAus(daten, verzeichnisname) {
73
+ const name = textFeld(daten, 'name');
74
+ if (name === undefined || name === '.' || name === '..')
40
75
  return verzeichnisname;
41
76
  if (name.includes('/') || name.includes('\\'))
42
77
  return verzeichnisname;
43
78
  return name;
44
79
  }
45
- /** Skill aus seinem Pfad in der Quelle, Name nach `skillName`. */
46
- export function skillAusPfad(pfad, skillMdInhalt) {
47
- const verzeichnisname = pfad.split('/').filter((t) => t.length > 0).at(-1) ?? pfad;
48
- return { name: skillName(verzeichnisname, skillMdInhalt), pfad };
80
+ /** Name eines Skills aus dem Frontmatter, sonst der Verzeichnisname (E32). */
81
+ export function skillName(verzeichnisname, skillMdInhalt) {
82
+ return nameAus(frontmatterDaten(skillMdInhalt), verzeichnisname);
83
+ }
84
+ /**
85
+ * Beschreibung eines Skills aus dem Feld `description` im Frontmatter (E39), analog zur
86
+ * Namensregel E32: fehlendes Frontmatter, kein YAML-Objekt, kaputtes YAML oder kein nicht leerer
87
+ * Text ⇒ keine Beschreibung, nichts bricht ab.
88
+ */
89
+ export function skillBeschreibung(skillMdInhalt) {
90
+ return textFeld(frontmatterDaten(skillMdInhalt), 'description');
91
+ }
92
+ /**
93
+ * Skill aus seinem Pfad in der Quelle: Name nach `skillName`, Beschreibung nach
94
+ * `skillBeschreibung`, Besonderheiten aus dem Frontmatter (E55), jedes Feld nur gesetzt, wenn
95
+ * etwas da ist. Das Frontmatter wird dafür einmal geparst, nicht je Feld: der Katalog einer Quelle
96
+ * geht über alle ihre Skills. `ersatzname` greift nur, wenn der Pfad kein Segment hergibt.
97
+ */
98
+ export function skillAusPfad(pfad, skillMdInhalt, ersatzname) {
99
+ const verzeichnisname = pfad.split('/').filter((t) => t.length > 0).at(-1) ?? ersatzname ?? pfad;
100
+ const daten = frontmatterDaten(skillMdInhalt);
101
+ const skill = { name: nameAus(daten, verzeichnisname), pfad };
102
+ const beschreibung = textFeld(daten, 'description');
103
+ if (beschreibung !== undefined)
104
+ skill.beschreibung = beschreibung;
105
+ // `disable-model-invocation: true`: lädt erst bei `/name`. `user-invocable: false`: kein `/name`.
106
+ if (boolFeld(daten, 'disable-model-invocation', true))
107
+ skill.nurPerBefehl = true;
108
+ if (boolFeld(daten, 'user-invocable', false))
109
+ skill.nichtPerBefehl = true;
110
+ if (vorhandenFeld(daten, 'hooks'))
111
+ skill.registriertHooks = true;
112
+ if (vorhandenFeld(daten, 'allowed-tools'))
113
+ skill.werkzeugeErlaubt = true;
114
+ const kompatibilitaet = textFeld(daten, 'compatibility');
115
+ if (kompatibilitaet !== undefined)
116
+ skill.kompatibilitaet = kompatibilitaet;
117
+ const modell = textFeld(daten, 'model');
118
+ if (modell !== undefined)
119
+ skill.modell = modell;
120
+ if (textFeld(daten, 'context') === 'fork')
121
+ skill.eigenerKontext = true;
122
+ const argumentHinweis = textFeld(daten, 'argument-hint');
123
+ if (argumentHinweis !== undefined)
124
+ skill.argumentHinweis = argumentHinweis;
125
+ return skill;
49
126
  }
@@ -14,7 +14,7 @@ export class TerminalRueckfrage {
14
14
  return false;
15
15
  const leser = createInterface({ input: process.stdin, output: process.stdout });
16
16
  try {
17
- const antwort = await leser.question(`${frage} `);
17
+ const antwort = await leser.question(`${frage} [j/N] `);
18
18
  return JA.has(antwort.trim().toLowerCase());
19
19
  }
20
20
  finally {
@@ -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,11 +12,15 @@ 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 ?? []),
18
19
  bindungen: (daten.bindungen ?? []),
20
+ // Fehlt das Feld (state.json vor E38), gilt der bisherige Ist-Zustand: nur claude-code aktiv.
21
+ aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
22
+ // Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
23
+ ignorierteEintraege: (daten.ignorierteEintraege ?? []),
19
24
  };
20
25
  }
21
26
  async schreiben(zustand) {