hmgr 0.0.7 → 0.0.14

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.
@@ -4,15 +4,22 @@ import { mkdir, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { OrtNichtUnterstuetzt } from "../kern/modell.js";
6
6
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
7
+ import { nurHome } from "./heime.js";
7
8
  const AGENTS_VERZEICHNIS = '.agents';
8
9
  const SKILLS_ORDNER = 'skills';
9
10
  const GITIGNORE_ZEILE = '.agents/';
10
11
  export class Agents {
11
12
  name = 'agents';
12
- home;
13
- /** `home` ist das HOME des Nutzers, das Verzeichnis liegt darunter als `.agents`. */
14
- constructor(home) {
15
- this.home = home;
13
+ heime;
14
+ /**
15
+ * `heime` sagt, wo das Homedir liegt (E78, F89): umstellbar, aber ohne zusätzliche Homedirs —
16
+ * die gibt es bisher nur bei claude-code. Ein blanker Pfad steht für „genau dieses Home".
17
+ */
18
+ constructor(heime) {
19
+ this.heime = typeof heime === 'string' ? nurHome(heime) : heime;
20
+ }
21
+ get home() {
22
+ return this.heime.primaer(this.name);
16
23
  }
17
24
  /**
18
25
  * Wie bei den anderen Harness-Typen zählt das angelegte Verzeichnis. Legt es niemand an, gibt es
@@ -25,8 +32,10 @@ export class Agents {
25
32
  return ['home', 'arbeitsverzeichnis'];
26
33
  }
27
34
  zielVerzeichnis(ort) {
35
+ // Über `heime.pfad` statt über `this.home`: nennt der Ort ein zusätzliches Homedir, wirft der
36
+ // Port (E78). agents hat die Fähigkeit nicht, ein solcher Ort dürfte hier gar nicht ankommen.
28
37
  if (ort.art === 'home')
29
- return path.join(this.home, AGENTS_VERZEICHNIS, SKILLS_ORDNER);
38
+ return path.join(this.heime.pfad(this.name, ort), AGENTS_VERZEICHNIS, SKILLS_ORDNER);
30
39
  if (ort.art === 'arbeitsverzeichnis')
31
40
  return path.join(ort.pfad, AGENTS_VERZEICHNIS, SKILLS_ORDNER);
32
41
  throw new OrtNichtUnterstuetzt(this.name, ort.art);
@@ -1,12 +1,24 @@
1
1
  // Claude Code: Grundverhalten plus Plugins und MCP-Server über die claude-CLI (E16, E24, E27, E61).
2
2
  import spawn from 'cross-spawn';
3
3
  import { realpathSync } from 'node:fs';
4
- import { mkdir, readFile, stat } from 'node:fs/promises';
4
+ import { readFile, stat } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
- import { McpAbweichenderEintrag, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, OrtNichtUnterstuetzt, pluginSchluessel, } from "../kern/modell.js";
6
+ import { HeimOhneCliZugriff, McpAbweichenderEintrag, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, OrtNichtUnterstuetzt, pluginSchluessel, } from "../kern/modell.js";
7
7
  import { ohneBom } from "./datei-text.js";
8
+ import { LokalerZugriff } from "./dateisystem.js";
8
9
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
10
+ import { heimVerbinden, nurHome } from "./heime.js";
9
11
  import { tiefGleich } from "./vergleich.js";
12
+ const CLAUDE_VERZEICHNIS = '.claude';
13
+ const SKILLS_ORDNER = 'skills';
14
+ /**
15
+ * Die Adresse eines zusätzlichen Homedirs an diesem Ort (E78), sonst `undefined`. Was die
16
+ * claude-CLI angeht — MCP-Server, Plugins —, gilt nur für das primäre Homedir: die CLI auf diesem
17
+ * Rechner kennt die Konfiguration einer WSL-Distro nicht.
18
+ */
19
+ function zusaetzlichesHeim(ort) {
20
+ return ort.art === 'home' ? ort.heim : undefined;
21
+ }
10
22
  const GITIGNORE_ZEILE = '.claude/';
11
23
  /**
12
24
  * Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme.
@@ -113,6 +125,9 @@ class ClaudePlugins {
113
125
  * Startverzeichnis ab, mit dem Zielverzeichnis als cwd landete sie unter `.claude/skills/`.
114
126
  */
115
127
  scope(ziel) {
128
+ const heim = zusaetzlichesHeim(ziel.ort);
129
+ if (heim !== undefined)
130
+ throw new HeimOhneCliZugriff(this.harness.name, heim, 'Plugins');
116
131
  if (ziel.ort.art === 'home')
117
132
  return { scope: 'user' };
118
133
  if (ziel.ort.art === 'arbeitsverzeichnis')
@@ -219,6 +234,7 @@ async function projektMcpJsonLesen(verzeichnis) {
219
234
  class ClaudeMcp {
220
235
  harness;
221
236
  ausfuehrer;
237
+ /** Das primäre Homedir, jedes Mal frisch: es lässt sich zur Laufzeit umstellen (E78, F89). */
222
238
  home;
223
239
  constructor(harness, ausfuehrer, home) {
224
240
  this.harness = harness;
@@ -232,6 +248,9 @@ class ClaudeMcp {
232
248
  return 'Claude Code übernimmt die Änderung mit der nächsten Sitzung.';
233
249
  }
234
250
  scope(ort) {
251
+ const heim = zusaetzlichesHeim(ort);
252
+ if (heim !== undefined)
253
+ throw new HeimOhneCliZugriff(this.harness.name, heim, 'MCP-Server');
235
254
  if (ort.art === 'home')
236
255
  return { scope: 'user' };
237
256
  if (ort.art === 'arbeitsverzeichnis')
@@ -343,6 +362,9 @@ class ClaudeMcp {
343
362
  * sich Arbeitsverzeichnisse aber so, wie der Nutzer sie genannt hat, also zählen beide.
344
363
  */
345
364
  async mcpServersAm(ort) {
365
+ const heim = zusaetzlichesHeim(ort);
366
+ if (heim !== undefined)
367
+ throw new HeimOhneCliZugriff(this.harness.name, heim, 'MCP-Server');
346
368
  const daten = await this.claudeJsonLesen();
347
369
  if (ort.art === 'home')
348
370
  return mcpServersVon(daten);
@@ -355,7 +377,7 @@ class ClaudeMcp {
355
377
  }
356
378
  async claudeJsonLesen() {
357
379
  try {
358
- const text = await readFile(path.join(this.home, '.claude.json'), 'utf8');
380
+ const text = await readFile(path.join(this.home(), '.claude.json'), 'utf8');
359
381
  return JSON.parse(ohneBom(text));
360
382
  }
361
383
  catch {
@@ -365,29 +387,42 @@ class ClaudeMcp {
365
387
  }
366
388
  export class ClaudeCode {
367
389
  name = 'claude-code';
368
- home;
390
+ heime;
369
391
  ausfuehrer;
392
+ /** Zugriff je Ort (E78): lokal wie bisher, für ein WSL-Heim die Bridge. */
393
+ zugriffFuer;
370
394
  /** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt. */
371
395
  plugins;
372
396
  /** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt (E61). */
373
397
  mcp;
374
398
  geprueft = false;
375
- constructor(home, ausfuehrer = claudeAusfuehrer) {
376
- this.home = home;
399
+ /**
400
+ * `heime` sagt, wo die Homedirs liegen (E78): das primäre und, seit F87, zusätzliche in einer
401
+ * WSL-Distro. Ein blanker Pfad steht weiterhin für „nur dieses eine Home", damit Tests und
402
+ * Aufrufer ohne Zustand unverändert bleiben.
403
+ */
404
+ constructor(heime, ausfuehrer = claudeAusfuehrer, zugriffFuer) {
405
+ this.heime = typeof heime === 'string' ? nurHome(heime) : heime;
377
406
  this.ausfuehrer = ausfuehrer;
407
+ const lokal = new LokalerZugriff();
408
+ this.zugriffFuer = zugriffFuer ?? (() => lokal);
378
409
  }
379
410
  /**
380
- * Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin- und
411
+ * Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat — oder wenn ein zusätzliches
412
+ * Homedir eingetragen ist (E78): wer eines eingetragen hat, hat Claude Code dort laufen, und ein
413
+ * Windows-Home ohne `.claude` soll die WSL-Ziele nicht mit verschwinden lassen. Die Plugin- und
381
414
  * MCP-Fähigkeit kommen dazu, sobald `claude --version` durchläuft: ohne CLI gibt es beides nicht.
382
415
  */
383
416
  async vorhanden() {
384
- const da = (await stat(path.join(this.home, '.claude')).catch(() => undefined)) !== undefined;
417
+ const primaer = this.heime.primaer(this.name);
418
+ const da = (await stat(path.join(primaer, CLAUDE_VERZEICHNIS)).catch(() => undefined)) !== undefined ||
419
+ this.heime.zusaetzliche(this.name).length > 0;
385
420
  if (!this.geprueft) {
386
421
  this.geprueft = true;
387
422
  const { code } = await this.ausfuehrer(['--version']).catch(() => ({ code: 1 }));
388
423
  if (code === 0) {
389
424
  this.plugins = new ClaudePlugins(this, this.ausfuehrer);
390
- this.mcp = new ClaudeMcp(this, this.ausfuehrer, this.home);
425
+ this.mcp = new ClaudeMcp(this, this.ausfuehrer, () => this.heime.primaer(this.name));
391
426
  }
392
427
  }
393
428
  return da;
@@ -395,15 +430,21 @@ export class ClaudeCode {
395
430
  orte() {
396
431
  return ['home', 'arbeitsverzeichnis'];
397
432
  }
433
+ /** Fähigkeit: zusätzliche Homedirs (E78, F87). Das Layout ist ein fester Ordner, kein Konfigeintrag. */
434
+ zusaetzlicheHeime() {
435
+ return true;
436
+ }
398
437
  zielVerzeichnis(ort) {
399
438
  if (ort.art === 'home')
400
- return path.join(this.home, '.claude', 'skills');
439
+ return heimVerbinden(ort, this.heime.pfad(this.name, ort), CLAUDE_VERZEICHNIS, SKILLS_ORDNER);
401
440
  if (ort.art === 'arbeitsverzeichnis')
402
- return path.join(ort.pfad, '.claude', 'skills');
441
+ return path.join(ort.pfad, CLAUDE_VERZEICHNIS, SKILLS_ORDNER);
403
442
  throw new OrtNichtUnterstuetzt(this.name, ort.art);
404
443
  }
405
444
  async vorbereiten(ort) {
406
- await mkdir(this.zielVerzeichnis(ort), { recursive: true });
445
+ // Über den Zugriff, nicht über `mkdir`: in einem WSL-Heim liegt das Verzeichnis im
446
+ // Linux-Dateisystem, wo Windows-Node nicht anlegen kann (E78).
447
+ await this.zugriffFuer(ort).verzeichnisAnlegen(this.zielVerzeichnis(ort));
407
448
  if (ort.art === 'arbeitsverzeichnis' && (await istGitRepo(ort.pfad)))
408
449
  await gitignoreEintragen(ort.pfad, GITIGNORE_ZEILE);
409
450
  }
@@ -45,15 +45,63 @@ export function linkFehlerHinweis(plattform = process.platform) {
45
45
  ? 'Aktiviere den Windows-Entwicklermodus (Einstellungen → System → Für Entwickler) oder starte hmgr mit Administratorrechten.'
46
46
  : 'Prüfe die Schreibrechte auf das Zielverzeichnis und ob das Linkziel vorhanden ist.';
47
47
  }
48
+ /**
49
+ * Zugriff auf das lokale Dateisystem (E78): der Weg, den hmgr immer schon ging, jetzt hinter dem
50
+ * Port. Linkziele brauchen keine Übersetzung, sie stehen schon in der Schreibweise des Rechners.
51
+ */
52
+ export class LokalerZugriff {
53
+ trenner = path.sep;
54
+ verbinden(verzeichnis, name) {
55
+ return path.join(verzeichnis, name);
56
+ }
57
+ async namen(verzeichnis) {
58
+ return await readdir(verzeichnis).catch(() => []);
59
+ }
60
+ async info(pfad) {
61
+ const roh = await lstat(pfad).catch(() => undefined);
62
+ if (roh === undefined)
63
+ return undefined;
64
+ if (!roh.isSymbolicLink())
65
+ return { istLink: false, istVerzeichnis: roh.isDirectory() };
66
+ return { istLink: true, istVerzeichnis: (await stat(pfad).catch(() => undefined))?.isDirectory() === true };
67
+ }
68
+ async linkZiel(pfad) {
69
+ const info = await lstat(pfad).catch(() => undefined);
70
+ if (!info || !info.isSymbolicLink())
71
+ return undefined;
72
+ // Junctions meldet Node als Symlink, ihr Ziel aber mit `\\?\`-Präfix (E66): erst kürzen, dann
73
+ // auflösen, sonst ergäbe `path.resolve` einen Pfad, der zu keinem Soll-Ziel passt.
74
+ return path.resolve(path.dirname(pfad), ohneWindowsPraefix(await readlink(pfad)));
75
+ }
76
+ async verzeichnisAnlegen(pfad) {
77
+ await mkdir(pfad, { recursive: true });
78
+ }
79
+ async linkAnlegen(pfad, hostZiel) {
80
+ await symlink(hostZiel, pfad, symlinkTyp());
81
+ }
82
+ async linkEntfernen(pfad) {
83
+ await unlink(pfad);
84
+ }
85
+ async loeschen(pfad) {
86
+ await rm(pfad, { recursive: true, force: true });
87
+ }
88
+ }
48
89
  export class DateisystemZiele {
49
90
  wurzel;
50
91
  zustand;
51
92
  /** $XDG_STATE_HOME, falls gesetzt und nicht leer, für den vorrangigen Lockfile-Ort (E50). */
52
93
  xdgStateHome;
53
- constructor(wurzel, zustand, xdgStateHome) {
94
+ /**
95
+ * Welcher Zugriff zu welchem Ziel gehört (E78): lokal wie bisher, für ein WSL-Heim die Bridge.
96
+ * Ohne Angabe ist alles lokal — so bleibt der Adapter ohne Windows-Umgebung derselbe wie vorher.
97
+ */
98
+ zugriffFuer;
99
+ lokal = new LokalerZugriff();
100
+ constructor(wurzel, zustand, xdgStateHome, zugriffFuer) {
54
101
  this.wurzel = wurzel;
55
102
  this.zustand = zustand;
56
103
  this.xdgStateHome = xdgStateHome;
104
+ this.zugriffFuer = zugriffFuer ?? (() => this.lokal);
57
105
  }
58
106
  async quellen() {
59
107
  return (await this.zustand.lesen()).quellen;
@@ -105,37 +153,22 @@ export class DateisystemZiele {
105
153
  }
106
154
  return undefined;
107
155
  }
108
- async linkZiel(verzeichnis, name) {
109
- const pfad = path.join(verzeichnis, name);
110
- const info = await lstat(pfad).catch(() => undefined);
111
- if (!info || !info.isSymbolicLink())
112
- return undefined;
113
- // Junctions meldet Node als Symlink, ihr Ziel aber mit `\\?\`-Präfix (E66): erst kürzen, dann
114
- // auflösen, sonst ergäbe `path.resolve` einen Pfad, der zu keinem Soll-Ziel passt.
115
- return path.resolve(verzeichnis, ohneWindowsPraefix(await readlink(pfad)));
116
- }
117
156
  /**
118
157
  * Ein Skill ist ein Verzeichnis. Dateien wie .DS_Store zählen nicht als fremder Eintrag,
119
- * ebenso wenig ein Symlink, der auf keine Verzeichnis zeigt.
158
+ * ebenso wenig ein Symlink, der auf kein Verzeichnis zeigt.
120
159
  */
121
- async istVerzeichnis(verzeichnis, name) {
122
- const pfad = path.join(verzeichnis, name);
123
- const info = await lstat(pfad).catch(() => undefined);
124
- if (info === undefined)
125
- return false;
126
- if (info.isDirectory())
127
- return true;
128
- if (!info.isSymbolicLink())
129
- return false;
130
- return (await stat(pfad).catch(() => undefined))?.isDirectory() === true;
160
+ istVerzeichnis(info) {
161
+ return info !== undefined && info.istVerzeichnis;
131
162
  }
132
163
  async eintraege(ziel, verzeichnis) {
133
- const namen = await readdir(verzeichnis).catch(() => []);
164
+ const zugriff = this.zugriffFuer(ziel);
165
+ const namen = await zugriff.namen(verzeichnis);
134
166
  const ergebnis = [];
135
- for (const name of namen.sort()) {
136
- if (!(await this.istVerzeichnis(verzeichnis, name)))
167
+ for (const name of [...namen].sort()) {
168
+ const pfad = zugriff.verbinden(verzeichnis, name);
169
+ if (!this.istVerzeichnis(await zugriff.info(pfad)))
137
170
  continue;
138
- const zielPfad = await this.linkZiel(verzeichnis, name);
171
+ const zielPfad = await zugriff.linkZiel(pfad);
139
172
  const gedeutet = zielPfad ? await this.deuten(zielPfad) : undefined;
140
173
  if (gedeutet?.art === 'link')
141
174
  ergebnis.push({ art: 'link', link: { ziel, name, checkout: gedeutet.checkout, skill: gedeutet.skill } });
@@ -148,20 +181,21 @@ export class DateisystemZiele {
148
181
  return ergebnis;
149
182
  }
150
183
  async linkLegen(ziel, verzeichnis, name, checkout, skill, ersetzen) {
184
+ const zugriff = this.zugriffFuer(ziel);
151
185
  // Ein leerer Name oder einer mit Pfadtrenner ergäbe keinen Eintrag im Ziel, sondern einen Pfad:
152
- // `path.join` fiele auf das Zielverzeichnis selbst zurück, und `ersetzen` löschte es (E12, E32).
153
- if (name.length === 0 || name.includes('/') || name.includes(path.sep))
186
+ // `verbinden` fiele auf das Zielverzeichnis selbst zurück, und `ersetzen` löschte es (E12, E32).
187
+ if (name.length === 0 || name.includes('/') || name.includes(zugriff.trenner))
154
188
  throw new Error(`Ungültiger Name für einen Link: „${name}"`);
155
- await mkdir(verzeichnis, { recursive: true });
156
- const pfad = path.join(verzeichnis, name);
157
- const info = await lstat(pfad).catch(() => undefined);
189
+ await zugriff.verzeichnisAnlegen(verzeichnis);
190
+ const pfad = zugriff.verbinden(verzeichnis, name);
191
+ const info = await zugriff.info(pfad);
158
192
  if (info) {
159
- const zielPfad = await this.linkZiel(verzeichnis, name);
193
+ const zielPfad = await zugriff.linkZiel(pfad);
160
194
  const eigen = zielPfad ? await this.deuten(zielPfad) : undefined;
161
195
  if (eigen)
162
- await unlink(pfad);
196
+ await zugriff.linkEntfernen(pfad);
163
197
  else if (ersetzen)
164
- await rm(pfad, { recursive: true, force: true });
198
+ await zugriff.loeschen(pfad);
165
199
  else
166
200
  throw new NameBelegt(ziel, name);
167
201
  }
@@ -171,33 +205,36 @@ export class DateisystemZiele {
171
205
  // relativer Pfad. `path.resolve` kostet nichts und macht die Zusage hier fest.
172
206
  const checkoutZiel = path.resolve(checkout.pfad, skill.pfad);
173
207
  try {
174
- await symlink(checkoutZiel, pfad, symlinkTyp());
208
+ await zugriff.linkAnlegen(pfad, checkoutZiel);
175
209
  }
176
210
  catch (ursache) {
177
211
  throw new LinkAnlegenFehlgeschlagen(pfad, checkoutZiel, ursache instanceof Error ? ursache.message : String(ursache), linkFehlerHinweis());
178
212
  }
179
213
  }
180
- async linkEntfernen(_ziel, verzeichnis, name) {
181
- const zielPfad = await this.linkZiel(verzeichnis, name);
214
+ async linkEntfernen(ziel, verzeichnis, name) {
215
+ const zugriff = this.zugriffFuer(ziel);
216
+ const pfad = zugriff.verbinden(verzeichnis, name);
217
+ const zielPfad = await zugriff.linkZiel(pfad);
182
218
  if (!zielPfad)
183
219
  return;
184
220
  if (await this.deuten(zielPfad))
185
- await unlink(path.join(verzeichnis, name));
221
+ await zugriff.linkEntfernen(pfad);
186
222
  }
187
223
  /**
188
224
  * Löscht einen fremden Eintrag von der Platte (E46): ein Symlink wird als Link entfernt, ohne
189
225
  * ihm zu folgen, die Arbeitskopie dahinter bleibt unangetastet; ein Verzeichnis wird rekursiv
190
226
  * gelöscht. Ob der Eintrag tatsächlich fremd ist, hat der Kern schon geprüft.
191
227
  */
192
- async eintragLoeschen(_ziel, verzeichnis, name) {
193
- const pfad = path.join(verzeichnis, name);
194
- const info = await lstat(pfad).catch(() => undefined);
228
+ async eintragLoeschen(ziel, verzeichnis, name) {
229
+ const zugriff = this.zugriffFuer(ziel);
230
+ const pfad = zugriff.verbinden(verzeichnis, name);
231
+ const info = await zugriff.info(pfad);
195
232
  if (info === undefined)
196
233
  return;
197
- if (info.isSymbolicLink())
198
- await unlink(pfad);
234
+ if (info.istLink)
235
+ await zugriff.linkEntfernen(pfad);
199
236
  else
200
- await rm(pfad, { recursive: true, force: true });
237
+ await zugriff.loeschen(pfad);
201
238
  }
202
239
  /**
203
240
  * Lockfile-Kandidaten in Suchreihenfolge (E50): zuerst $XDG_STATE_HOME/skills/.skill-lock.json,
@@ -235,6 +272,11 @@ export class DateisystemZiele {
235
272
  }
236
273
  }
237
274
  /**
275
+ * Lockfile und Ordner-Hash lesen immer lokal, nie über die Bridge (E78): beide gehören zur
276
+ * Übernahme-Erkennung eines agents-Ziels (E48 b), und zusätzliche Homedirs gibt es bisher nur bei
277
+ * claude-code. Käme das je zusammen, wäre der Ordner-Hash über die Bridge außerdem ein Lesen
278
+ * jeder Datei einzeln — dann lieber ein eigener Schnitt als ein stiller Sonderweg hier.
279
+ *
238
280
  * Lockfile der skills-CLI (E48 b, E50, HARNESS-FAKTEN). Fehlt die Datei am ersten Kandidaten,
239
281
  * wird der nächste versucht; ist sie da, aber kein gültiges JSON oder trägt der Eintrag nicht die
240
282
  * nötigen Felder, gibt es undefined zurück statt zu werfen: ein kaputtes Lockfile bricht die
@@ -0,0 +1,76 @@
1
+ // Homedirs eines Harness aus dem Zustand (E78). Synchron, siehe HeimePort.
2
+ import path from 'node:path';
3
+ import { HeimUnbekannt, OrtNichtUnterstuetzt, heimAdresseZerlegen } from "../kern/modell.js";
4
+ /**
5
+ * Pfade in der Welt eines Homedirs zusammensetzen (E78): POSIX in einer WSL-Distro, sonst die
6
+ * Schreibweise des Betriebssystems. Ohne das ergäbe `path.join` unter Windows `\`-Pfade für ein
7
+ * Ziel, das nur Linux zu Gesicht bekommt.
8
+ */
9
+ export function heimVerbinden(ort, ...teile) {
10
+ const adresse = ort.art === 'home' ? ort.heim : undefined;
11
+ return adresse !== undefined && heimAdresseZerlegen(adresse) !== undefined
12
+ ? path.posix.join(...teile)
13
+ : path.join(...teile);
14
+ }
15
+ /**
16
+ * Ob eine Adresse auf dieser Plattform bedienbar ist (E78, Entscheidung 7). `wsl:`-Heime gibt es
17
+ * nur unter Windows; steht eines im Zustand, weil er von einem Windows-Rechner stammt, wird sein
18
+ * Ziel anderswo nicht angezeigt und nicht bedient.
19
+ */
20
+ export function heimBedienbar(adresse, plattform) {
21
+ const zerlegt = heimAdresseZerlegen(adresse);
22
+ if (zerlegt === undefined)
23
+ return false;
24
+ return plattform === 'win32';
25
+ }
26
+ export class ZustandHeime {
27
+ zustand;
28
+ home;
29
+ plattform;
30
+ /** `home` ist das HOME des Nutzers: das primäre Homedir, solange nichts anderes eingetragen ist. */
31
+ constructor(zustand, home, plattform = process.platform) {
32
+ this.zustand = zustand;
33
+ this.home = home;
34
+ this.plattform = plattform;
35
+ }
36
+ eintrag(harness) {
37
+ return this.zustand.lesenSync().harnessHeime.find((h) => h.harness === harness);
38
+ }
39
+ primaer(harness) {
40
+ const pfad = this.eintrag(harness)?.pfad;
41
+ return pfad !== undefined && pfad.length > 0 ? pfad : this.home;
42
+ }
43
+ zusaetzliche(harness) {
44
+ return (this.eintrag(harness)?.zusaetzliche ?? [])
45
+ .filter((h) => heimBedienbar(h.adresse, this.plattform))
46
+ .map((h) => ({ adresse: h.adresse, pfad: h.pfad }));
47
+ }
48
+ alle(harness) {
49
+ return [{ pfad: this.primaer(harness) }, ...this.zusaetzliche(harness)];
50
+ }
51
+ pfad(harness, ort) {
52
+ if (ort.art !== 'home')
53
+ throw new OrtNichtUnterstuetzt(harness, ort.art);
54
+ if (ort.heim === undefined)
55
+ return this.primaer(harness);
56
+ const treffer = this.zusaetzliche(harness).find((h) => h.adresse === ort.heim);
57
+ if (treffer === undefined)
58
+ throw new HeimUnbekannt(harness, ort.heim);
59
+ return treffer.pfad;
60
+ }
61
+ }
62
+ /** Der Standardfall ohne Zustand: ein Harness hat genau ein Homedir, das Home des Nutzers. */
63
+ export function nurHome(home) {
64
+ return {
65
+ primaer: () => home,
66
+ zusaetzliche: () => [],
67
+ alle: () => [{ pfad: home }],
68
+ pfad: (harness, ort) => {
69
+ if (ort.art !== 'home')
70
+ throw new OrtNichtUnterstuetzt(harness, ort.art);
71
+ if (ort.heim !== undefined)
72
+ throw new HeimUnbekannt(harness, ort.heim);
73
+ return home;
74
+ },
75
+ };
76
+ }
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { Document, isMap, isScalar, isSeq, parseDocument } from 'yaml';
7
7
  import { McpAbweichenderEintrag, OrtNichtUnterstuetzt, SkillOrdnerUngueltig, } from "../kern/modell.js";
8
8
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
9
+ import { nurHome } from "./heime.js";
9
10
  import { tiefGleich } from "./vergleich.js";
10
11
  const HERMES_VERZEICHNIS = '.hermes';
11
12
  const PROFILE_VERZEICHNIS = 'profiles';
@@ -68,7 +69,7 @@ function mcpKonfigAlsObjekt(konfig) {
68
69
  }
69
70
  export class Hermes {
70
71
  name = 'hermes';
71
- home;
72
+ heime;
72
73
  /**
73
74
  * MCP-Server (E61): nur Home und Profil, denn Hermes kennt keine Projektebene für MCPs. Bindung
74
75
  * über Arrow-Funktionen auf `this`, damit die Fähigkeit ohne eigene Klasse auskommt.
@@ -80,9 +81,16 @@ export class Hermes {
80
81
  eingehaengte: async (ort) => this.mcpEingehaengte(ort),
81
82
  envDatei: (ort) => path.join(this.mcpHermesHome(ort), '.env'),
82
83
  };
83
- /** `home` ist das HOME des Nutzers, das Hermes-Home liegt darunter als `.hermes`. */
84
- constructor(home) {
85
- this.home = home;
84
+ /**
85
+ * `heime` sagt, wo das Homedir liegt (E78, F89): umstellbar, aber ohne zusätzliche Homedirs.
86
+ * Hermes' Zielverzeichnis steht in einer config.yaml; die über die Bridge zu lesen und zu
87
+ * schreiben wäre ein eigener Schnitt. Ein blanker Pfad steht für „genau dieses Home".
88
+ */
89
+ constructor(heime) {
90
+ this.heime = typeof heime === 'string' ? nurHome(heime) : heime;
91
+ }
92
+ get home() {
93
+ return this.heime.primaer(this.name);
86
94
  }
87
95
  /** Hermes liegt auf dem Rechner, wenn es sein Home angelegt hat. */
88
96
  async vorhanden() {
@@ -93,11 +101,12 @@ export class Hermes {
93
101
  }
94
102
  /** Home und Profil sind dasselbe Konzept: ein vollständiges Hermes-Home (E13). */
95
103
  hermesHome(ort) {
96
- const basis = path.join(this.home, HERMES_VERZEICHNIS);
104
+ // Über `heime.pfad` statt über `this.home`: nennt der Ort ein zusätzliches Homedir, wirft der
105
+ // Port (E78). Hermes hat die Fähigkeit nicht, ein solcher Ort dürfte hier gar nicht ankommen.
97
106
  if (ort.art === 'home')
98
- return basis;
107
+ return path.join(this.heime.pfad(this.name, ort), HERMES_VERZEICHNIS);
99
108
  if (ort.art === 'profil')
100
- return path.join(basis, PROFILE_VERZEICHNIS, ort.name);
109
+ return path.join(this.home, HERMES_VERZEICHNIS, PROFILE_VERZEICHNIS, ort.name);
101
110
  throw new OrtNichtUnterstuetzt(this.name, ort.art);
102
111
  }
103
112
  /** Tilde gegen HOME, relative Pfade gegen das Hermes-Home. Variablen löst hmgr nicht auf. */
@@ -0,0 +1,68 @@
1
+ // Die auf npm veröffentlichte Version der hmgr-CLI (E77). npm ist der Kanal der CLI (F83=O1), der
2
+ // Gitea-Feed (E73) gehört allein der GUI — deshalb fragt dieser Adapter die npm-Registry und nennt
3
+ // in der Meldung genau den Befehl, der über npm installiert.
4
+ //
5
+ // Jeder Fehlschlag ist still: offline, DNS weg, Registry langsam, unerwartetes JSON. Ein
6
+ // Update-Hinweis ist eine Beigabe, nie ein Grund, einen Lauf scheitern zu lassen oder den Nutzer
7
+ // mit einer Netzwerkmeldung zu behelligen. Wer mitlesen will, gibt `protokoll` mit.
8
+ import { versionIstNeuer } from "../kern/version.js";
9
+ /** Der Befehl, den die Meldung verspricht — und das einzige, was sie verspricht: hmgr installiert nie selbst. */
10
+ export const NPM_BEFEHL = 'npm install -g hmgr@latest';
11
+ /** Kurzer Timeout: die Prüfung hängt an einem Knopfdruck, sie darf ihn nicht spürbar aufhalten. */
12
+ const TIMEOUT_MS = 4000;
13
+ /**
14
+ * Die aktuelle Version eines Pakets laut npm-Registry, oder null. `/<paket>/latest` liefert das
15
+ * Manifest der Dist-Tag-Version `latest`, daraus zählt nur das Feld `version`.
16
+ */
17
+ export async function npmVersion(paket, optionen = {}) {
18
+ const protokoll = optionen.protokoll ?? (() => undefined);
19
+ const abbruch = new AbortController();
20
+ const wecker = setTimeout(() => abbruch.abort(), optionen.timeoutMs ?? TIMEOUT_MS);
21
+ try {
22
+ const antwort = await fetch(`https://registry.npmjs.org/${paket}/latest`, {
23
+ signal: abbruch.signal,
24
+ headers: { accept: 'application/json' },
25
+ });
26
+ if (!antwort.ok) {
27
+ protokoll(`[npm] ${paket}: HTTP ${antwort.status}`);
28
+ return null;
29
+ }
30
+ const daten = (await antwort.json());
31
+ if (typeof daten.version !== 'string') {
32
+ protokoll(`[npm] ${paket}: Antwort ohne Versionsfeld`);
33
+ return null;
34
+ }
35
+ return daten.version;
36
+ }
37
+ catch (fehler) {
38
+ protokoll(`[npm] ${paket}: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
39
+ return null;
40
+ }
41
+ finally {
42
+ clearTimeout(wecker);
43
+ }
44
+ }
45
+ /**
46
+ * Die eine Zeile, die GUI-Sidebar und `hmgr update` gleichlautend zeigen. `installiert === null`
47
+ * heißt: eine hmgr-CLI ist da, aber so alt (vor 0.0.7, E72), dass sie ihre Version nicht sagen
48
+ * kann — dieser Fall meldet sich auch ohne `neu` (Registry unerreichbar), denn die Warnung „veraltet"
49
+ * hängt nicht am Netz, nur der Vergleich mit npm tut das.
50
+ */
51
+ export function cliHinweisText(installiert, neu) {
52
+ if (installiert !== null && neu !== null)
53
+ return `hmgr-CLI ${installiert} — npm hat ${neu}: ${NPM_BEFEHL}`;
54
+ if (neu !== null)
55
+ return `hmgr-CLI veraltet (vor 0.0.7, kennt --version nicht) — npm hat ${neu}: ${NPM_BEFEHL}`;
56
+ return `hmgr-CLI veraltet (vor 0.0.7, kennt --version nicht): ${NPM_BEFEHL}`;
57
+ }
58
+ export async function cliUpdateHinweis(installiert, optionen = {}) {
59
+ const neu = await npmVersion('hmgr', optionen);
60
+ if (installiert === null) {
61
+ // `text` für die CLI (eine Zeile), die Einzelteile für die GUI, die den Befehl hervorhebt. Der
62
+ // Befehl kommt mit, damit er nicht ein zweites Mal im Renderer steht.
63
+ return { installiert: null, neu, befehl: NPM_BEFEHL, text: cliHinweisText(null, neu) };
64
+ }
65
+ if (neu === null || !versionIstNeuer(neu, installiert))
66
+ return null;
67
+ return { installiert, neu, befehl: NPM_BEFEHL, text: cliHinweisText(installiert, neu) };
68
+ }
@@ -2,8 +2,13 @@
2
2
  import { readFile } from 'node:fs/promises';
3
3
  export class ProzessUmgebung {
4
4
  env;
5
- constructor(env = process.env) {
5
+ plattformName;
6
+ constructor(env = process.env, plattform = process.platform) {
6
7
  this.env = env;
8
+ this.plattformName = plattform;
9
+ }
10
+ plattform() {
11
+ return this.plattformName;
7
12
  }
8
13
  variablen() {
9
14
  const ergebnis = {};
@@ -0,0 +1,39 @@
1
+ // Der Verlauf als eine JSON-Datei, Standard ~/.hmgr/verlauf.json (E80). Analog ZustandJson
2
+ // (zustand-json.ts), aber ohne Fassungs-Migration: das Format ist neu, `fassung: 1` reicht.
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { leererVerlauf } from "../ports/verlauf.js";
6
+ import { ohneBom } from "./datei-text.js";
7
+ /** Höchstzahl der Einträge in der Datei (E80): ein Changelog, keine Historie ohne Ende. */
8
+ const MAX_EINTRAEGE = 20;
9
+ /** Der gelesene Text als Verlauf. Fehlt ein Feld oder ist die Form falsch, gilt der leere Verlauf. */
10
+ function deuten(roh) {
11
+ const daten = JSON.parse(ohneBom(roh));
12
+ const gemerkt = typeof daten.gemerkt === 'object' && daten.gemerkt !== null ? daten.gemerkt : {};
13
+ const eintraege = Array.isArray(daten.eintraege) ? daten.eintraege : [];
14
+ return { gemerkt, eintraege };
15
+ }
16
+ export class VerlaufJson {
17
+ pfad;
18
+ constructor(hmgrHome) {
19
+ this.pfad = path.join(hmgrHome, 'verlauf.json');
20
+ }
21
+ async lesen() {
22
+ const roh = await readFile(this.pfad, 'utf8').catch(() => undefined);
23
+ if (roh === undefined)
24
+ return leererVerlauf();
25
+ try {
26
+ return deuten(roh);
27
+ }
28
+ catch {
29
+ // Kaputtes JSON (analog ZustandJson, deuten) darf den Lauf nicht abbrechen: leerer Verlauf.
30
+ return leererVerlauf();
31
+ }
32
+ }
33
+ async schreiben(daten) {
34
+ await mkdir(path.dirname(this.pfad), { recursive: true });
35
+ // Neueste zuerst kappen, nicht sortieren: die Reihenfolge liegt beim Aufrufer.
36
+ const gekappt = { gemerkt: daten.gemerkt, eintraege: daten.eintraege.slice(0, MAX_EINTRAEGE) };
37
+ await writeFile(this.pfad, `${JSON.stringify({ fassung: 1, ...gekappt }, null, 2)}\n`);
38
+ }
39
+ }