hmgr 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/adapter/bundle-yaml.js +87 -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 +182 -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/paket-version.js +25 -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 +11 -3
- package/dist/cli/hilfe.js +197 -0
- package/dist/cli/main.js +255 -7
- package/dist/gui/beobachter.js +108 -0
- package/dist/gui/login-pfad.js +120 -0
- package/dist/gui/main.js +462 -19
- package/dist/gui/mcp-ansicht.js +57 -0
- package/dist/gui/oeffnen.js +1 -1
- package/dist/gui/preload.cjs +20 -1
- package/dist/gui/renderer/index.html +108 -7
- package/dist/gui/renderer/renderer.js +1066 -142
- package/dist/gui/renderer/styles.css +85 -2
- package/dist/kern/anwendungsfaelle.js +624 -12
- package/dist/kern/modell.js +221 -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/dist/ports/zustand.js +15 -1
- package/package.json +59 -2
package/dist/adapter/git.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { readFile, readdir, mkdir, realpath, rm, stat } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { bundleLesen, istBundleDatei } from "./bundle-yaml.js";
|
|
5
6
|
import { marketplaceLesen } from "./marketplace-json.js";
|
|
6
7
|
import { SKILL_DATEI, skillAusPfad, skillName } from "./skill-md.js";
|
|
7
8
|
import { BASIS_NAME, basisPfad, quelleZerlegen, refDekodieren, worktreePfad } from "./checkout-pfad.js";
|
|
@@ -28,13 +29,17 @@ async function existiert(pfad) {
|
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
31
|
* Aus Dateipfaden die Skill-Verzeichnisse bilden: ein Verzeichnis mit SKILL.md ist ein Skill.
|
|
31
|
-
* Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32).
|
|
32
|
+
* Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32). Eine
|
|
33
|
+
* SKILL.md direkt an der Wurzel (`teile.length === 1`) ergibt den Pfad `''`: das Repo ist selbst
|
|
34
|
+
* ein Skill (E11-Nachtrag 2026-09-05). `''` ist die einzige interne Schreibweise dafür, nie `.` —
|
|
35
|
+
* `checkoutZerlegen`, `skillAusPfad` und die Sparse-Checkout-Muster (unten) sind darauf abgestimmt.
|
|
36
|
+
* Wurzel-Skill und `skills/`-Unterordner schließen sich nicht aus, beide landen in der Liste.
|
|
32
37
|
*/
|
|
33
38
|
export function skillPfadeAusDateien(dateien) {
|
|
34
39
|
const verzeichnisse = new Set();
|
|
35
40
|
for (const datei of dateien) {
|
|
36
41
|
const teile = datei.split('/');
|
|
37
|
-
if (teile.at(-1) !== SKILL_DATEI
|
|
42
|
+
if (teile.at(-1) !== SKILL_DATEI)
|
|
38
43
|
continue;
|
|
39
44
|
verzeichnisse.add(teile.slice(0, -1).join('/'));
|
|
40
45
|
}
|
|
@@ -94,6 +99,14 @@ async function inhalteAufPlatte(wurzel, pfade) {
|
|
|
94
99
|
}
|
|
95
100
|
return ergebnis;
|
|
96
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Ersatzname für einen Skill, dessen Pfad kein Segment hergibt (Wurzel-Skill, E11-Nachtrag): der
|
|
104
|
+
* Repo-Name der Quelle. Ohne ihn hieße der Skill „" und ein Link auf ihn zeigte nicht auf einen
|
|
105
|
+
* Eintrag im Ziel, sondern auf das Zielverzeichnis (E32).
|
|
106
|
+
*/
|
|
107
|
+
function ersatznameDerQuelle(quelle) {
|
|
108
|
+
return quelle.art === 'lokal' ? path.basename(quelle.pfad) : quelleZerlegen(quelle).repo;
|
|
109
|
+
}
|
|
97
110
|
async function dateienAufPlatte(wurzel, unter = '') {
|
|
98
111
|
const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
|
|
99
112
|
const dateien = [];
|
|
@@ -180,24 +193,32 @@ export class GitQuellen {
|
|
|
180
193
|
async skillMdInhalte(basis, ref, pfade) {
|
|
181
194
|
if (pfade.length === 0)
|
|
182
195
|
return new Map();
|
|
183
|
-
|
|
196
|
+
// Ein Wurzel-Skill hat den Pfad `''`: `${ref}:${p}/${SKILL_DATEI}` ergäbe `ref:/SKILL.md` mit
|
|
197
|
+
// führendem Schrägstrich, den git als eigenen (ungültigen) Pfad läse statt als Wurzel (E39).
|
|
198
|
+
const eingabe = `${pfade.map((p) => `${ref}:${p.length === 0 ? SKILL_DATEI : `${p}/${SKILL_DATEI}`}`).join('\n')}\n`;
|
|
184
199
|
const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
|
|
185
200
|
if (ausgabe === undefined)
|
|
186
201
|
return new Map();
|
|
187
202
|
return batchInhalte(ausgabe, pfade);
|
|
188
203
|
}
|
|
189
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* Skills der Quelle, Namen aus dem Frontmatter der SKILL.md (E32). Ein Wurzel-Skill (Pfad `''`,
|
|
206
|
+
* E11-Nachtrag) hat keinen Verzeichnisnamen, aus dem `skillAusPfad` einen Namen ableiten könnte:
|
|
207
|
+
* der Repo-Name ist der Ersatzname, nie der leere String, sonst zeigte ein Link ohne Frontmatter
|
|
208
|
+
* `name` auf einen Eintrag mit leerem Namen im Ziel (E32).
|
|
209
|
+
*/
|
|
190
210
|
async skills(quelle, ref) {
|
|
211
|
+
const ersatzname = ersatznameDerQuelle(quelle);
|
|
191
212
|
if (quelle.art === 'lokal') {
|
|
192
213
|
const pfade = skillPfadeAusDateien(await dateienAufPlatte(quelle.pfad));
|
|
193
214
|
const inhalte = await inhalteAufPlatte(quelle.pfad, pfade);
|
|
194
|
-
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
|
|
215
|
+
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
|
|
195
216
|
}
|
|
196
217
|
const basis = await this.basis(quelle);
|
|
197
218
|
const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
|
|
198
219
|
const pfade = skillPfadeAusDateien(ausgabe.split('\n').filter((z) => z.length > 0));
|
|
199
220
|
const inhalte = await this.skillMdInhalte(basis, ref, pfade);
|
|
200
|
-
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
|
|
221
|
+
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
|
|
201
222
|
}
|
|
202
223
|
/**
|
|
203
224
|
* Marketplace einer Quelle (E24). Der Inhalt kommt aus dem Git-Baum, nicht aus einem Checkout:
|
|
@@ -227,10 +248,61 @@ export class GitQuellen {
|
|
|
227
248
|
plugins: gelesen.plugins.map((p) => ({
|
|
228
249
|
name: p.name,
|
|
229
250
|
dependencies: p.dependencies,
|
|
230
|
-
|
|
251
|
+
// Auch hier kann ein Pfad leer sein (`"source": "."` mit `"skills": ["."]`): derselbe
|
|
252
|
+
// Ersatzname wie im Katalog, damit nie ein Skill ohne Namen entsteht (E32).
|
|
253
|
+
skills: p.skills.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatznameDerQuelle(quelle))),
|
|
231
254
|
})),
|
|
232
255
|
};
|
|
233
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Inhalte beliebiger Dateien eines Refs in einem Aufruf, dasselbe Bündel-Muster wie
|
|
259
|
+
* `skillMdInhalte`: `git cat-file --batch` holt beim Partial Clone die fehlenden Blobs gebündelt.
|
|
260
|
+
* Was nicht lesbar ist, fehlt in der Karte.
|
|
261
|
+
*/
|
|
262
|
+
async dateiInhalte(basis, ref, pfade) {
|
|
263
|
+
if (pfade.length === 0)
|
|
264
|
+
return new Map();
|
|
265
|
+
const eingabe = `${pfade.map((p) => `${ref}:${p}`).join('\n')}\n`;
|
|
266
|
+
const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
|
|
267
|
+
if (ausgabe === undefined)
|
|
268
|
+
return new Map();
|
|
269
|
+
return batchInhalte(ausgabe, pfade);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Bundle-Definitionen der Quelle (E69): jede Datei unter `bundles/`, gelesen wie der Marketplace
|
|
273
|
+
* über den Git-Baum statt über einen Checkout. Eine kaputte Datei ergibt einen Fehler-Eintrag,
|
|
274
|
+
* keinen Abbruch: die übrigen Bundles derselben Quelle bleiben nutzbar.
|
|
275
|
+
*/
|
|
276
|
+
async bundles(quelle, ref) {
|
|
277
|
+
let dateien;
|
|
278
|
+
let inhalte;
|
|
279
|
+
if (quelle.art === 'lokal') {
|
|
280
|
+
dateien = (await dateienAufPlatte(quelle.pfad)).filter(istBundleDatei).sort();
|
|
281
|
+
inhalte = new Map();
|
|
282
|
+
for (const datei of dateien) {
|
|
283
|
+
const roh = await readFile(path.join(quelle.pfad, datei), 'utf8').catch(() => undefined);
|
|
284
|
+
if (roh !== undefined)
|
|
285
|
+
inhalte.set(datei, roh);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const basis = await this.basis(quelle);
|
|
290
|
+
const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
|
|
291
|
+
dateien = ausgabe.split('\n').filter(istBundleDatei).sort();
|
|
292
|
+
inhalte = await this.dateiInhalte(basis, ref, dateien);
|
|
293
|
+
}
|
|
294
|
+
return dateien.map((datei) => {
|
|
295
|
+
const inhalt = inhalte.get(datei);
|
|
296
|
+
if (inhalt === undefined)
|
|
297
|
+
return { art: 'fehler', datei, fehler: 'Datei nicht lesbar' };
|
|
298
|
+
try {
|
|
299
|
+
return { art: 'bundle', datei, bundle: bundleLesen(inhalt, datei, quelle) };
|
|
300
|
+
}
|
|
301
|
+
catch (fehler) {
|
|
302
|
+
return { art: 'fehler', datei, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
234
306
|
async refs(quelle) {
|
|
235
307
|
if (quelle.art === 'lokal')
|
|
236
308
|
return [LOKAL_REF];
|
|
@@ -287,7 +359,11 @@ export class GitQuellen {
|
|
|
287
359
|
if (!WURZEL_MUSTER.includes(eintrag))
|
|
288
360
|
muster.add(eintrag);
|
|
289
361
|
// Ein Wurzel-Skill (E48 a, leerer Pfad) ist der ganze Baum: `/**` nimmt ihn nach `!/*/` wieder
|
|
290
|
-
// rekursiv auf, ein Muster `//` wäre kein gültiges Sparse-Checkout-Muster.
|
|
362
|
+
// rekursiv auf, ein Muster `//` wäre kein gültiges Sparse-Checkout-Muster. Der Link, den
|
|
363
|
+
// `linkLegen` darauf setzt, zeigt dann auf die Worktree-Wurzel selbst (E11-Nachtrag 2026-09-05):
|
|
364
|
+
// die ganze Arbeitskopie samt `.git` wird sichtbar, nicht nur die SKILL.md und ihre
|
|
365
|
+
// Unterordner. Ein Harness liest ohnehin nur SKILL.md und referenzierte Unterordner, das `.git`
|
|
366
|
+
// stört nicht — wohl aber sähe ein Nutzer, der den Link im Finder öffnet, den vollen Worktree.
|
|
291
367
|
for (const skill of skills)
|
|
292
368
|
muster.add(skill.pfad === '' ? '/**' : `/${skill.pfad}/`);
|
|
293
369
|
await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
|
|
@@ -348,6 +424,104 @@ export class GitQuellen {
|
|
|
348
424
|
await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
|
|
349
425
|
return { ergebnis: 'aktualisiert', von, nach };
|
|
350
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Geänderte Pfade zwischen zwei Commits (E70). `--no-renames` aus zwei Gründen: eine Umbenennung
|
|
429
|
+
* soll hier als Änderung an beiden Pfaden zählen, und die Ähnlichkeitsprüfung bräuchte die Blobs,
|
|
430
|
+
* die ein Partial Clone (`--filter=blob:none`) erst aus dem Netz holen müsste. Eine lokale Quelle
|
|
431
|
+
* hat keinen Checkout und damit keine zwei Commits (B11), sie kommt hier nie an.
|
|
432
|
+
*/
|
|
433
|
+
async geaenderteDateien(quelle, von, nach) {
|
|
434
|
+
if (quelle.art === 'lokal')
|
|
435
|
+
return [];
|
|
436
|
+
try {
|
|
437
|
+
const basis = await this.basis(quelle);
|
|
438
|
+
const ausgabe = await this.git(['diff', '--name-only', '--no-renames', von, nach], basis);
|
|
439
|
+
return ausgabe.split('\n').filter((z) => z.length > 0);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Skills, die in diesem Checkout auf der Platte liegen (E70): dieselbe Regel wie bei einer
|
|
447
|
+
* lokalen Quelle, nur auf das Worktree-Verzeichnis angewandt. Der Sparse-Checkout hat dort nur
|
|
448
|
+
* die gewählten Skills materialisiert (E22), also nennt der Update-Bericht auch nur diese.
|
|
449
|
+
*/
|
|
450
|
+
async ausgecheckteSkills(checkout) {
|
|
451
|
+
const pfade = skillPfadeAusDateien(await dateienAufPlatte(checkout.pfad));
|
|
452
|
+
const inhalte = await inhalteAufPlatte(checkout.pfad, pfade);
|
|
453
|
+
const ersatzname = ersatznameDerQuelle(checkout.quelle);
|
|
454
|
+
return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Ein `git log --name-only` je Quelle statt eines Aufrufs je Skill (E70). Die Ausgabe wird
|
|
458
|
+
* gelesen, während sie entsteht, und der Lauf abgebrochen, sobald jeder gesuchte Pfad sein Datum
|
|
459
|
+
* hat: der jüngste Commit steht vorn, alles Ältere ist unnötig. Gemessen an einem Repo mit 20
|
|
460
|
+
* Skills und 401 Commits: 6 ms gegen 110 ms für 20 einzelne `git log -1`.
|
|
461
|
+
*
|
|
462
|
+
* Format: `%x00%cI` stellt jeder Datumszeile ein NUL voran, das in keinem Dateipfad vorkommt —
|
|
463
|
+
* daran trennen sich Kopf und Dateiliste. `--no-renames` hält die Ähnlichkeitsprüfung heraus, die
|
|
464
|
+
* beim Partial Clone Blobs nachladen würde. Scheitert der Aufruf (kein Repo, unbekannter Ref),
|
|
465
|
+
* bleibt die Karte leer, das ist kein Fehler.
|
|
466
|
+
*/
|
|
467
|
+
async logDaten(cwd, ref, pfade) {
|
|
468
|
+
const daten = new Map();
|
|
469
|
+
const offen = new Set(pfade);
|
|
470
|
+
if (offen.size === 0)
|
|
471
|
+
return daten;
|
|
472
|
+
return await new Promise((erfuellen) => {
|
|
473
|
+
const kind = spawn('git', ['log', '--format=%x00%cI', '--name-only', '--no-renames', ref], {
|
|
474
|
+
cwd,
|
|
475
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
476
|
+
timeout: 300_000,
|
|
477
|
+
killSignal: 'SIGKILL',
|
|
478
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
479
|
+
});
|
|
480
|
+
let rest = '';
|
|
481
|
+
let datum;
|
|
482
|
+
const fertig = () => erfuellen(daten);
|
|
483
|
+
kind.on('error', fertig);
|
|
484
|
+
kind.on('close', fertig);
|
|
485
|
+
// Nach dem Abbruch schreibt git in eine Pipe, die niemand mehr liest: der Fehler darf den
|
|
486
|
+
// Prozess nicht mitnehmen, das Ergebnis steht da schon fest.
|
|
487
|
+
kind.stdout.on('error', () => { });
|
|
488
|
+
kind.stdout.on('data', (teil) => {
|
|
489
|
+
rest += teil.toString('utf8');
|
|
490
|
+
const zeilen = rest.split('\n');
|
|
491
|
+
rest = zeilen.pop() ?? '';
|
|
492
|
+
for (const zeile of zeilen) {
|
|
493
|
+
if (zeile.startsWith('\0')) {
|
|
494
|
+
datum = zeile.slice(1);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (zeile.length === 0 || datum === undefined)
|
|
498
|
+
continue;
|
|
499
|
+
for (const pfad of [...offen])
|
|
500
|
+
// Ein Wurzel-Skill (Pfad `''`, E11-Nachtrag) ist das ganze Repo: jede Datei zählt.
|
|
501
|
+
if (pfad === '' || zeile === pfad || zeile.startsWith(`${pfad}/`)) {
|
|
502
|
+
daten.set(pfad, datum);
|
|
503
|
+
offen.delete(pfad);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (offen.size === 0)
|
|
507
|
+
kind.kill('SIGTERM');
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Änderungsdatum je Skill (E70, F70=O1). Eine lokale Quelle hat keinen Basis-Clone, wohl aber
|
|
513
|
+
* meist eine Arbeitskopie (B11): dort zählt der HEAD-Stand auf der Platte, wie überall sonst bei
|
|
514
|
+
* lokalen Quellen. Ist der Pfad kein Git-Repo, bleibt die Karte leer und der Katalog nennt für
|
|
515
|
+
* diese Quelle kein Datum.
|
|
516
|
+
*/
|
|
517
|
+
async letzteAenderungen(quelle, ref, pfade) {
|
|
518
|
+
if (quelle.art === 'lokal')
|
|
519
|
+
return await this.logDaten(quelle.pfad, 'HEAD', pfade);
|
|
520
|
+
const basis = await this.basis(quelle).catch(() => undefined);
|
|
521
|
+
if (basis === undefined)
|
|
522
|
+
return new Map();
|
|
523
|
+
return await this.logDaten(basis, ref, pfade);
|
|
524
|
+
}
|
|
351
525
|
/**
|
|
352
526
|
* Checkout von der Platte nehmen. Ein Worktree steht im Basis-Clone eingetragen, ein blankes
|
|
353
527
|
* Löschen des Verzeichnisses ließe den Eintrag stehen und ein späterer `worktree add` auf
|
package/dist/adapter/hermes.js
CHANGED
|
@@ -4,8 +4,9 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
4
4
|
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { Document, isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
7
|
-
import { OrtNichtUnterstuetzt, SkillOrdnerUngueltig } from "../kern/modell.js";
|
|
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;
|
|
@@ -358,6 +389,47 @@ export class Hermes {
|
|
|
358
389
|
await mkdir(hermesHome, { recursive: true });
|
|
359
390
|
await writeFile(pfad, doc.toString());
|
|
360
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
|
+
}
|
|
361
433
|
/** Fähigkeit Profile entdecken: Verzeichnisse mit config.yaml, bei jedem Aufruf frisch gelesen (E19). */
|
|
362
434
|
async profile() {
|
|
363
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})`);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Die Version des Pakets, gelesen aus seiner package.json (E72). Keine Kopie im Code: eine
|
|
2
|
+
// zweite Stelle mit derselben Zahl geht früher oder später auseinander.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
/**
|
|
5
|
+
* Die package.json liegt in jeder Lage zwei Ebenen über diesem Modul: in der Quelle
|
|
6
|
+
* `src/adapter/` → Wurzel, im Build `dist/adapter/` → Wurzel, in der npm-Installation
|
|
7
|
+
* `node_modules/hmgr/dist/adapter/` → `node_modules/hmgr/`, im gepackten App-Bundle
|
|
8
|
+
* `app.asar/dist/adapter/` → `app.asar/`. Aufgelöst über `import.meta.url`, damit weder das
|
|
9
|
+
* Arbeitsverzeichnis noch der Aufrufweg hineinspielt.
|
|
10
|
+
*
|
|
11
|
+
* Bewusst kein Import mit `with { type: 'json' }`: der zöge die package.json beim Bauen nach
|
|
12
|
+
* `dist` und legte damit eine zweite Wahrheit an.
|
|
13
|
+
*/
|
|
14
|
+
const PAKET_JSON = new URL('../../package.json', import.meta.url);
|
|
15
|
+
let gemerkt;
|
|
16
|
+
/** Die Versionsnummer aus der package.json des Pakets, etwa „0.0.6". */
|
|
17
|
+
export function paketVersion() {
|
|
18
|
+
if (gemerkt === undefined) {
|
|
19
|
+
const inhalt = JSON.parse(readFileSync(PAKET_JSON, 'utf8'));
|
|
20
|
+
if (typeof inhalt.version !== 'string')
|
|
21
|
+
throw new Error(`Die package.json unter ${PAKET_JSON.pathname} nennt keine Version.`);
|
|
22
|
+
gemerkt = inhalt.version;
|
|
23
|
+
}
|
|
24
|
+
return gemerkt;
|
|
25
|
+
}
|
|
@@ -1,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
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Lokaler Zustand als eine JSON-Datei, Standard ~/.hmgr/state.json (E29).
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { leererZustand } from "../ports/zustand.js";
|
|
4
|
+
import { ZUSTAND_FASSUNG, leererZustand, } from "../ports/zustand.js";
|
|
5
|
+
import { ohneBom } from "./datei-text.js";
|
|
5
6
|
export class ZustandJson {
|
|
6
7
|
pfad;
|
|
7
8
|
constructor(pfad) {
|
|
@@ -11,8 +12,11 @@ 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 {
|
|
17
|
+
// Fehlt das Feld (state.json vor E69), ist es die erste Fassung: alles ohne Bundles.
|
|
18
|
+
// Geschrieben wird danach in der aktuellen Fassung, ein Rückweg ist nicht vorgesehen.
|
|
19
|
+
fassung: typeof daten.fassung === 'number' ? daten.fassung : 1,
|
|
16
20
|
quellen: (daten.quellen ?? []),
|
|
17
21
|
arbeitsverzeichnisse: (daten.arbeitsverzeichnisse ?? []),
|
|
18
22
|
bindungen: (daten.bindungen ?? []),
|
|
@@ -20,10 +24,14 @@ export class ZustandJson {
|
|
|
20
24
|
aktiveHarnesses: (daten.aktiveHarnesses ?? ['claude-code']),
|
|
21
25
|
// Fehlt das Feld (state.json vor E43), gilt: nichts ignoriert.
|
|
22
26
|
ignorierteEintraege: (daten.ignorierteEintraege ?? []),
|
|
27
|
+
// Fehlt das Feld (state.json vor E69), gilt: kein Bundle installiert.
|
|
28
|
+
bundleInstallationen: (daten.bundleInstallationen ?? []),
|
|
23
29
|
};
|
|
24
30
|
}
|
|
25
31
|
async schreiben(zustand) {
|
|
26
32
|
await mkdir(path.dirname(this.pfad), { recursive: true });
|
|
27
|
-
|
|
33
|
+
// Immer in der aktuellen Fassung, egal welche gelesen wurde: das Lesen hat den Bestand schon
|
|
34
|
+
// auf die aktuelle Form gebracht, ein alter Vermerk in der Datei wäre danach eine Lüge.
|
|
35
|
+
await writeFile(this.pfad, `${JSON.stringify({ ...zustand, fassung: ZUSTAND_FASSUNG }, null, 2)}\n`);
|
|
28
36
|
}
|
|
29
37
|
}
|