hmgr 0.0.4 → 0.0.5

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/dist/cli/main.js CHANGED
@@ -5,7 +5,7 @@ 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, KeinPassenderRef, NameBelegt, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, SkillMehrdeutig, SkillUnbekannt, ZeigerMehrdeutig, pluginSchluessel, } from "../kern/modell.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";
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";
@@ -18,7 +18,12 @@ import { ZustandJson } from "../adapter/zustand-json.js";
18
18
  import { HILFE, hilfeText } from "./hilfe.js";
19
19
  export function umgebungLesen(env = process.env) {
20
20
  const home = env.HOME ?? homedir();
21
- return { home, hmgrHome: env.HMGR_HOME ?? path.join(home, '.hmgr') };
21
+ const xdgStateHome = env.XDG_STATE_HOME;
22
+ return {
23
+ home,
24
+ hmgrHome: env.HMGR_HOME ?? path.join(home, '.hmgr'),
25
+ ...(xdgStateHome !== undefined && xdgStateHome.length > 0 ? { xdgStateHome } : {}),
26
+ };
22
27
  }
23
28
  export function bauen(umgebung, optionen = {}) {
24
29
  const zustand = new ZustandJson(path.join(umgebung.hmgrHome, 'state.json'));
@@ -29,7 +34,7 @@ export function bauen(umgebung, optionen = {}) {
29
34
  const ports = {
30
35
  harnesses: [claude, new Hermes(umgebung.home), new Agents(umgebung.home)],
31
36
  quellen: new GitQuellen(wurzel),
32
- ziele: new DateisystemZiele(wurzel, zustand),
37
+ ziele: new DateisystemZiele(wurzel, zustand, umgebung.xdgStateHome),
33
38
  zeiger: new ComposerZeiger(),
34
39
  zustand,
35
40
  rueckfrage: optionen.rueckfrage ?? new TerminalRueckfrage(),
@@ -41,7 +46,7 @@ export function bauen(umgebung, optionen = {}) {
41
46
  * steht nirgends, weil der Hilfe-Dispatch vor jedem schalterLesen läuft.
42
47
  */
43
48
  const SCHALTER = {
44
- source: { werte: [], flaggen: [] },
49
+ source: { werte: ['name'], flaggen: [] },
45
50
  dir: { werte: ['composer'], flaggen: [] },
46
51
  link: {
47
52
  werte: ['harness', 'ort', 'composer', 'quelle', 'version'],
@@ -53,6 +58,7 @@ const SCHALTER = {
53
58
  sync: { werte: [], flaggen: [] },
54
59
  update: { werte: [], flaggen: [] },
55
60
  status: { werte: [], flaggen: [] },
61
+ harness: { werte: ['profil'], flaggen: [] },
56
62
  };
57
63
  /**
58
64
  * Was ein einzelnes Unterkommando von seiner Familie liest, gleichlautend mit ihrer Hilfe. Ohne
@@ -60,7 +66,12 @@ const SCHALTER = {
60
66
  * Schalter lesen.
61
67
  */
62
68
  const UNTERKOMMANDOS = {
63
- source: { add: { woerter: 2 }, list: { woerter: 1 } },
69
+ source: {
70
+ add: { woerter: 2, schalter: ['name'] },
71
+ remove: { woerter: 2, schalter: [] },
72
+ rename: { woerter: 3, schalter: [] },
73
+ list: { woerter: 1, schalter: [] },
74
+ },
64
75
  dir: {
65
76
  add: { woerter: 2, schalter: ['composer'] },
66
77
  forget: { woerter: 2, schalter: [] },
@@ -71,6 +82,11 @@ const UNTERKOMMANDOS = {
71
82
  remove: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'local', 'lokal'] },
72
83
  list: { woerter: 1, schalter: [] },
73
84
  },
85
+ harness: {
86
+ list: { woerter: 1, schalter: [] },
87
+ enable: { woerter: 2, schalter: [] },
88
+ disable: { woerter: 2, schalter: [] },
89
+ },
74
90
  };
75
91
  function schalterLesen(args, kommando) {
76
92
  const erlaubt = SCHALTER[kommando];
@@ -145,6 +161,8 @@ const UEBERNAHME_GRUND = {
145
161
  'keine-url': 'Arbeitskopie ohne remote.origin.url',
146
162
  'kein-branch': 'Arbeitskopie ohne Branch (detached HEAD)',
147
163
  'skill-unbekannt': 'Pfad ist in der Quelle kein Skill',
164
+ 'lokale-aenderungen': 'Ordner weicht von der Quelle ab',
165
+ 'nicht-pruefbar': 'hmgr kann nicht prüfen, ob der Ordner der Quelle entspricht',
148
166
  };
149
167
  export async function ausfuehren(argv, umgebung, optionen = {}) {
150
168
  const { anwendungsfaelle, ports } = bauen(umgebung, optionen);
@@ -176,22 +194,24 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
176
194
  if (kommando === 'source') {
177
195
  const schalter = schalterLesen(argv.slice(1), 'source');
178
196
  unterkommandoPruefen('source', schalter);
179
- const rest = schalter.rest;
197
+ const { rest, werte } = schalter;
180
198
  const unterkommando = rest[0];
181
199
  if (unterkommando === 'add') {
182
200
  const text = rest[1];
183
201
  if (text === undefined)
184
202
  throw new Error('source add braucht eine URL oder einen Pfad, etwa hmgr source add https://example.com/skills.git');
185
203
  const genannt = quelleAusText(text);
186
- const { quelle, skills, marketplace, bekannt } = await anwendungsfaelle.quelleHinzufuegen(genannt);
204
+ const { quelle, skills, marketplace, bekannt } = await anwendungsfaelle.quelleHinzufuegen(genannt, werte.get('name'));
187
205
  // Dieselbe Quelle in anderer Schreibweise legt keine zweite an (E36).
188
206
  if (bekannt)
189
207
  console.log(quelleAlsText(quelle) === quelleAlsText(genannt)
190
208
  ? 'Quelle ist schon bekannt, keine zweite angelegt.'
191
209
  : `Quelle ist schon bekannt, geführt als ${quelleAlsText(quelle)}. Keine zweite angelegt.`);
192
- console.log(`Quelle ${quelleAlsText(quelle)} (${quelle.art}), ${skills.length} Skills:`);
193
- for (const skill of skills)
194
- console.log(` ${skill.name} ${skill.pfad}`);
210
+ console.log(`Quelle ${quelleAlsText(quelle)} (${quelle.art}) als „${quelleName(quelle)}", ${skills.length} Skills:`);
211
+ for (const skill of skills) {
212
+ const beschreibung = skill.beschreibung === undefined ? '' : ` ${kappen(skill.beschreibung)}`;
213
+ console.log(` ${skill.name} ${skill.pfad}${beschreibung}`);
214
+ }
195
215
  if (marketplace !== null) {
196
216
  console.log(`Marketplace ${marketplace.name}, ${marketplace.plugins.length} Plugins:`);
197
217
  for (const plugin of marketplace.plugins) {
@@ -201,15 +221,89 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
201
221
  }
202
222
  return 0;
203
223
  }
224
+ if (unterkommando === 'remove') {
225
+ const text = rest[1];
226
+ if (text === undefined)
227
+ throw new Error('source remove braucht eine URL oder einen Pfad. hmgr source list zeigt die bekannten Quellen.');
228
+ const genannt = quelleAusText(text);
229
+ // Inventar (E58) vor dem Vergessen erheben (F14): danach tragen die Links ihre Quelle nicht
230
+ // mehr. Bei unbekannter Quelle entfällt der Ziel-Scan, den Fehler wirft quelleEntfernen.
231
+ const key = quelleSchluessel(genannt);
232
+ // Die Rückfrage kommt jetzt VOR dem Vergessen (F31a), nennen muss sie die Quelle trotzdem
233
+ // unter ihrer geführten Schreibweise (E36), nicht unter der getippten.
234
+ const gefuehrtVorher = (await anwendungsfaelle.quellen()).find((q) => quelleSchluessel(q) === key);
235
+ const inventar = gefuehrtVorher === undefined ? undefined : await anwendungsfaelle.quelleAufraeumInventar(genannt);
236
+ const meldeVergessen = (gefuehrt, zahl) => {
237
+ console.log(`Quelle ${quelleAlsText(gefuehrt)} vergessen, Checkouts und Links bleiben liegen.`);
238
+ if (zahl === 0)
239
+ console.log(' Kein Link betroffen.');
240
+ else {
241
+ console.log(` ${zahl === 1 ? '1 Link gilt' : `${zahl} Links gelten`} jetzt als „Link ohne bekannte Quelle", das Aktualisieren prüft ${zahl === 1 ? 'ihn' : 'sie'} nicht mehr.`);
242
+ console.log(` source add derselben Quelle macht ${zahl === 1 ? 'ihn wieder zu einem normalen Link' : 'sie wieder zu normalen Links'}.`);
243
+ }
244
+ };
245
+ // Leeres Inventar (F31a): nichts zum Aufräumen, keine Rückfrage, sofort vergessen wie bisher.
246
+ if (gefuehrtVorher === undefined ||
247
+ inventar === undefined ||
248
+ (inventar.links.length === 0 && inventar.checkouts.length === 0)) {
249
+ const gefuehrt = await anwendungsfaelle.quelleEntfernen(genannt);
250
+ meldeVergessen(gefuehrt, inventar?.links.length ?? 0);
251
+ return 0;
252
+ }
253
+ // Aufräum-Rückfrage VOR dem Vergessen (F31a): ein Ja mit lokalen Änderungen darf weder löschen
254
+ // noch die Quelle vergessen, das entscheidet erst quelleAufraeumen unten.
255
+ const teile = [
256
+ zahlwort(inventar.links.length, 'Link', 'Links'),
257
+ zahlwort(inventar.checkouts.length, 'Checkout', 'Checkouts'),
258
+ ];
259
+ const bleibt = (n) => (n === 1 ? ', bleibt liegen' : ', bleiben liegen');
260
+ if (inventar.plugins.length > 0)
261
+ teile.push(zahlwort(inventar.plugins.length, 'installiertes Plugin', 'installierte Plugins') + bleibt(inventar.plugins.length));
262
+ if (inventar.zeigerPfade.length > 0)
263
+ teile.push(zahlwort(inventar.zeigerPfade.length, 'Zeiger-Eintrag', 'Zeiger-Einträge') + bleibt(inventar.zeigerPfade.length));
264
+ let frage = `Skills von ${quelleAlsText(gefuehrtVorher)} komplett vom Rechner löschen (${teile.join(', ')})?`;
265
+ if (inventar.checkoutsMitLokalenAenderungen.length > 0) {
266
+ const anzahl = inventar.checkoutsMitLokalenAenderungen.length;
267
+ frage +=
268
+ `\n${anzahl === 1 ? 'Achtung: 1 Checkout hat' : `Achtung: ${anzahl} Checkouts haben`} lokale Änderungen. ` +
269
+ 'Ein Ja bricht dann ab, ohne zu löschen, die Quelle bleibt bekannt. Erst committen/pushen oder verwerfen, dann erneut versuchen.';
270
+ }
271
+ if (!(await ports.rueckfrage.bestaetigen(frage))) {
272
+ const gefuehrt = await anwendungsfaelle.quelleEntfernen(genannt);
273
+ meldeVergessen(gefuehrt, inventar.links.length);
274
+ return 0;
275
+ }
276
+ // Ja: erst aufräumen (wirft bei lokalen Änderungen, VOR jeder Löschung, F31a), erst danach die
277
+ // Quelle vergessen, damit ein Abbruch sie unberührt lässt.
278
+ const ergebnis = await anwendungsfaelle.quelleAufraeumen(inventar);
279
+ const gefuehrt = await anwendungsfaelle.quelleEntfernen(genannt);
280
+ console.log(`Quelle ${quelleAlsText(gefuehrt)} entfernt und aufgeräumt.`);
281
+ console.log(` entfernt ${zahlwort(ergebnis.entfernteLinks, 'Link', 'Links')}, ${zahlwort(ergebnis.entfernteCheckouts, 'Checkout', 'Checkouts')}.`);
282
+ if (inventar.plugins.length > 0)
283
+ console.log(` installierte Plugins bleiben liegen: ${inventar.plugins.join(', ')}`);
284
+ if (inventar.zeigerPfade.length > 0)
285
+ console.log(` Zeiger-Einträge bleiben liegen: ${inventar.zeigerPfade.join(', ')}`);
286
+ return 0;
287
+ }
288
+ if (unterkommando === 'rename') {
289
+ const text = rest[1];
290
+ const name = rest[2];
291
+ if (text === undefined || name === undefined || name.trim().length === 0)
292
+ throw new Error('source rename braucht eine URL oder einen Pfad und einen neuen Namen, etwa hmgr source rename https://example.com/skills.git skills');
293
+ const genannt = quelleAusText(text);
294
+ const umbenannt = await anwendungsfaelle.quelleUmbenennen(genannt, name);
295
+ console.log(`Quelle ${quelleAlsText(umbenannt)} heißt jetzt „${quelleName(umbenannt)}".`);
296
+ return 0;
297
+ }
204
298
  if (unterkommando === 'list') {
205
299
  const zustand = await ports.zustand.lesen();
206
300
  if (zustand.quellen.length === 0)
207
301
  console.log('Keine Quellen.');
208
302
  for (const quelle of zustand.quellen)
209
- console.log(`${quelle.art} ${quelleAlsText(quelle)}`);
303
+ console.log(`${quelle.art} ${quelleName(quelle)} ${quelleAlsText(quelle)}`);
210
304
  return 0;
211
305
  }
212
- throw new Error(`${unterkommando === undefined ? 'source braucht ein Unterkommando' : `Unbekanntes Unterkommando „${unterkommando}" für source`}: source add <url|pfad> oder source list. hmgr help source zeigt Beispiele.`);
306
+ throw new Error(`${unterkommando === undefined ? 'source braucht ein Unterkommando' : `Unbekanntes Unterkommando „${unterkommando}" für source`}: source add <url|pfad>, source rename <url|pfad> <name>, source remove <url|pfad> oder source list. hmgr help source zeigt Beispiele.`);
213
307
  }
214
308
  if (kommando === 'dir') {
215
309
  const schalter = schalterLesen(argv.slice(1), 'dir');
@@ -269,8 +363,10 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
269
363
  await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
270
364
  }
271
365
  if (kommando === 'unlink') {
272
- await anwendungsfaelle.linkEntfernen({ skill, ziel, lokal });
366
+ const ergebnis = await anwendungsfaelle.linkEntfernen({ skill, ziel, lokal });
273
367
  console.log(`Link ${skill} entfernt.`);
368
+ if (ergebnis.hinweis !== undefined)
369
+ console.log(ergebnis.hinweis);
274
370
  return 0;
275
371
  }
276
372
  const quelle = werte.get('quelle');
@@ -327,6 +423,8 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
327
423
  }
328
424
  const quellenText = neueQuellen === 1 ? '1 neue Quelle' : `${neueQuellen} neue Quellen`;
329
425
  console.log(`${uebernommen} übernommen, ${uebersprungen} übersprungen, ${quellenText}.`);
426
+ if (ergebnis.hinweis !== undefined)
427
+ console.log(ergebnis.hinweis);
330
428
  return 0;
331
429
  }
332
430
  if (kommando === 'plugin') {
@@ -409,8 +507,10 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
409
507
  console.log(` aktualisiert ${kopf} ${aktualisierung.von.slice(0, 8)} -> ${aktualisierung.nach.slice(0, 8)}`);
410
508
  else if (aktualisierung.ergebnis === 'unveraendert')
411
509
  console.log(` unverändert ${kopf}`);
412
- else
510
+ else if (aktualisierung.ergebnis === 'uebersprungen')
413
511
  console.log(` übersprungen ${kopf} lokale Änderung (E15)`);
512
+ else
513
+ console.log(` fehlgeschlagen ${kopf} ${aktualisierung.fehler}`);
414
514
  }
415
515
  for (const name of pluginHarnesses)
416
516
  console.log(` Plugins ${name} aktualisiert (E14)`);
@@ -440,12 +540,12 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
440
540
  if (eintrag.art === 'fremd')
441
541
  console.log(` ${eintrag.name} fremd`);
442
542
  else if (eintrag.art === 'link-ohne-quelle')
443
- console.log(` ${eintrag.name} Link ohne bekannte Quelle (${eintrag.checkoutPfad})`);
543
+ console.log(` ${eintrag.name} Link ohne bekannte Quelle (${eintrag.checkoutPfad})${beschreibeGrund(zielZustand.gruende.get(eintrag.name))}`);
444
544
  else {
445
545
  // Steht im Zeiger eine Versionseinschränkung, kommt sie vor den aufgelösten Ref (E33).
446
546
  const einschraenkung = zielZustand.einschraenkungen.get(eintrag.link.name);
447
547
  const spanne = einschraenkung === undefined ? '' : `${einschraenkung} → `;
448
- console.log(` ${eintrag.link.name} ${spanne}${quelleAlsText(eintrag.link.checkout.quelle)} @ ${eintrag.link.checkout.ref} (${eintrag.link.skill.pfad})`);
548
+ console.log(` ${eintrag.link.name} ${spanne}${quelleAlsText(eintrag.link.checkout.quelle)} @ ${eintrag.link.checkout.ref} (${eintrag.link.skill.pfad})${beschreibeGrund(zielZustand.gruende.get(eintrag.link.name))}`);
449
549
  }
450
550
  }
451
551
  if (zielZustand.plugins !== undefined)
@@ -456,8 +556,132 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
456
556
  console.log(`Nicht vorhanden: ${fehlend.join(', ')}`);
457
557
  return 0;
458
558
  }
559
+ if (kommando === 'harness') {
560
+ const schalter = schalterLesen(argv.slice(1), 'harness');
561
+ unterkommandoPruefen('harness', schalter);
562
+ const { rest, werte } = schalter;
563
+ const unterkommando = rest[0];
564
+ if (unterkommando === 'skills-dir') {
565
+ const harnessName = rest[1];
566
+ if (harnessName === undefined)
567
+ throw new Error('harness skills-dir braucht einen Harness-Namen, etwa hmgr harness skills-dir hermes. ' +
568
+ 'hmgr harness list zeigt die bekannten Namen.');
569
+ const unter = rest[2];
570
+ const profilName = werte.get('profil');
571
+ const ort = profilName === undefined ? { art: 'home' } : { art: 'profil', name: profilName };
572
+ const ziel = { harness: harnessName, ort };
573
+ const ortText = beschreibeOrt(ort);
574
+ if (unter === undefined) {
575
+ restPruefen(rest, 2, 'harness', 'skills-dir');
576
+ const pfad = await anwendungsfaelle.verwalteterSkillOrdner(ziel);
577
+ if (pfad === undefined) {
578
+ console.log(`${harnessName} ${ortText}: kein Skill-Ordner verwaltet.`);
579
+ console.log(` Festlegen mit hmgr harness skills-dir ${harnessName} add <pfad>.`);
580
+ }
581
+ else {
582
+ console.log(`${harnessName} ${ortText} ${pfad}`);
583
+ }
584
+ return 0;
585
+ }
586
+ if (unter === 'add') {
587
+ restPruefen(rest, 4, 'harness', 'skills-dir');
588
+ const pfad = rest[3];
589
+ if (pfad === undefined)
590
+ throw new Error('harness skills-dir <name> add braucht einen Pfad, etwa ' +
591
+ 'hmgr harness skills-dir hermes add ~/.hermes/external-skills.');
592
+ const gesetzt = await anwendungsfaelle.skillOrdnerFestlegen(ziel, pfad);
593
+ console.log(`${harnessName} ${ortText} liest Skills jetzt aus ${gesetzt}.`);
594
+ return 0;
595
+ }
596
+ if (unter === 'remove') {
597
+ restPruefen(rest, 3, 'harness', 'skills-dir');
598
+ const ergebnis = await anwendungsfaelle.skillOrdnerEntfernen(ziel);
599
+ console.log(`${harnessName} ${ortText}: Eintrag für ${ergebnis.pfad} entfernt.`);
600
+ const inhalt = `${zahlwort(ergebnis.hmgrLinks, 'hmgr-Link', 'hmgr-Links')}, ${zahlwort(ergebnis.fremdeEintraege, 'fremder Eintrag', 'fremde Einträge')}`;
601
+ const frage = `Ordner ${ergebnis.pfad} enthält noch ${inhalt}. Löschen?`;
602
+ if (await ports.rueckfrage.bestaetigen(frage)) {
603
+ await anwendungsfaelle.skillOrdnerLoeschen(ziel, ergebnis.pfad);
604
+ console.log(` Ordner ${ergebnis.pfad} gelöscht.`);
605
+ }
606
+ return 0;
607
+ }
608
+ throw new Error(`Unbekanntes Unterkommando „${unter}" für harness skills-dir: harness skills-dir <name>, ` +
609
+ 'harness skills-dir <name> add <pfad> oder harness skills-dir <name> remove. ' +
610
+ 'hmgr help harness zeigt Beispiele.');
611
+ }
612
+ if (unterkommando === 'list') {
613
+ const aktive = new Set(await anwendungsfaelle.aktiveHarnesses());
614
+ const fehlend = new Set(await anwendungsfaelle.nichtVorhandeneHarnesses());
615
+ for (const name of anwendungsfaelle.harnessTypen()) {
616
+ const aktivText = aktive.has(name) ? 'aktiv' : 'inaktiv';
617
+ const vorhandenText = fehlend.has(name) ? 'nicht vorhanden' : 'vorhanden';
618
+ console.log(`${name} ${aktivText}, ${vorhandenText}`);
619
+ }
620
+ return 0;
621
+ }
622
+ if (unterkommando === 'enable' || unterkommando === 'disable') {
623
+ const name = rest[1];
624
+ if (name === undefined)
625
+ throw new Error(`harness ${unterkommando} braucht einen Harness-Namen. hmgr harness list zeigt die bekannten Harness-Typen.`);
626
+ const vorherAktiv = new Set(await anwendungsfaelle.aktiveHarnesses());
627
+ if (unterkommando === 'enable') {
628
+ await anwendungsfaelle.harnessAktivieren(name);
629
+ console.log(vorherAktiv.has(name) ? `${name} ist schon aktiv.` : `${name} aktiviert.`);
630
+ return 0;
631
+ }
632
+ await anwendungsfaelle.harnessDeaktivieren(name);
633
+ console.log(vorherAktiv.has(name) ? `${name} deaktiviert.` : `${name} ist schon inaktiv.`);
634
+ // Säubern-Angebot nur, wenn eigene Konfiguration liegt, mit derselben Bedingung wie die GUI
635
+ // (E38, GRILL.md Entwurfskritik 4): eigene Links oder eine liegen gebliebene Registrierung
636
+ // ohne Link (E38 R7), sonst machten CLI und GUI verschiedene Angebote.
637
+ const ziele = (await anwendungsfaelle.istZustand()).filter((z) => z.ziel.harness === name &&
638
+ (z.eintraege.some((eintrag) => eintrag.art !== 'fremd') || z.istRegistriert));
639
+ if (ziele.length === 0)
640
+ return 0;
641
+ const links = ziele.reduce((n, z) => n + z.eintraege.filter((eintrag) => eintrag.art !== 'fremd').length, 0);
642
+ const eintraege = ziele.filter((z) => z.istRegistriert).length;
643
+ // Wer den harness-eigenen Eintrag angelegt hat, weiß niemand (E38 R7), also wird er benannt,
644
+ // nicht hmgr zugeschrieben. Gleicher Text wie bilanzText in der GUI.
645
+ const teile = [
646
+ links === 0
647
+ ? zahlwort(ziele.length, 'leeres Ziel', 'leere Ziele')
648
+ : `${zahlwort(links, 'Link', 'Links')} in ${zahlwort(ziele.length, 'Ziel', 'Zielen')}`,
649
+ ];
650
+ if (eintraege > 0)
651
+ teile.push(zahlwort(eintraege, 'harness-eigener Eintrag', 'harness-eigene Einträge'));
652
+ const liste = ziele.map((z) => ` ${z.verzeichnis}`).join('\n');
653
+ const frage = `Es liegt noch Konfiguration (${teile.join(', ')}):\n${liste}\nEntfernen?`;
654
+ if (!(await ports.rueckfrage.bestaetigen(frage))) {
655
+ console.log(' Konfiguration bleibt liegen, hmgr pflegt sie nicht mehr.');
656
+ return 0;
657
+ }
658
+ const ergebnis = await anwendungsfaelle.saeubern(name);
659
+ for (const zielErgebnis of ergebnis.ziele) {
660
+ if (zielErgebnis.entfernteLinks.length > 0)
661
+ console.log(` entfernt ${beschreibeOrt(zielErgebnis.ziel.ort)} ${zielErgebnis.entfernteLinks.join(', ')}`);
662
+ if (zielErgebnis.konfigurationZurueckgebaut)
663
+ console.log(` zurückgebaut ${beschreibeOrt(zielErgebnis.ziel.ort)} harness-eigene Konfiguration`);
664
+ }
665
+ return 0;
666
+ }
667
+ throw new Error(`${unterkommando === undefined ? 'harness braucht ein Unterkommando' : `Unbekanntes Unterkommando „${unterkommando}" für harness`}: harness list, harness enable <name> oder harness disable <name>. hmgr help harness zeigt Beispiele.`);
668
+ }
459
669
  throw new Error(`Unbekanntes Kommando „${kommando}". hmgr --help zeigt alle Kommandos.`);
460
670
  }
671
+ /** Grund eines Links als Suffix (E37). „manuell" bleibt stumm, damit die Ausgabe ruhig bleibt. */
672
+ function beschreibeGrund(grund) {
673
+ if (grund === undefined || grund.art === 'manuell')
674
+ return '';
675
+ return grund.art === 'plugin' ? ` Plugin „${grund.plugin}"` : ' über Zeiger';
676
+ }
677
+ /** Zahl mit passender Endung, Singular oder Plural, für die Säubern-Frage bei harness disable (E38). */
678
+ function zahlwort(n, ein, viele) {
679
+ return `${n} ${n === 1 ? ein : viele}`;
680
+ }
681
+ /** Text auf `laenge` Zeichen kappen, mit „…" markiert, damit eine Skill-Liste lesbar bleibt (E39). */
682
+ function kappen(text, laenge = 80) {
683
+ return text.length <= laenge ? text : `${text.slice(0, laenge - 1)}…`;
684
+ }
461
685
  function beschreibeOrt(ort) {
462
686
  if (ort.art === 'home')
463
687
  return 'home';
@@ -471,12 +695,21 @@ function istModellFehler(fehler) {
471
695
  fehler instanceof SkillUnbekannt ||
472
696
  fehler instanceof OrtNichtUnterstuetzt ||
473
697
  fehler instanceof ZeigerMehrdeutig ||
698
+ fehler instanceof ZeigerSchreibenGesperrt ||
474
699
  fehler instanceof HarnessNichtVorhanden ||
700
+ fehler instanceof HarnessTypUnbekannt ||
475
701
  fehler instanceof KeinPassenderRef ||
476
702
  fehler instanceof EinschraenkungOhneZeiger ||
477
703
  fehler instanceof PluginMehrdeutig ||
478
704
  fehler instanceof PluginUnbekannt ||
479
- fehler instanceof AbhaengigkeitFehlt);
705
+ fehler instanceof AbhaengigkeitFehlt ||
706
+ fehler instanceof QuelleUnbekannt ||
707
+ fehler instanceof QuelleNameBelegt ||
708
+ fehler instanceof QuelleAufraeumenLokaleAenderungen ||
709
+ fehler instanceof SkillOrdnerFaehigkeitFehlt ||
710
+ fehler instanceof SkillOrdnerBereitsVerwaltet ||
711
+ fehler instanceof SkillOrdnerNichtVerwaltet ||
712
+ fehler instanceof SkillOrdnerUngueltig);
480
713
  }
481
714
  /**
482
715
  * Der nächste Schritt zu einem Fehler aus dem Kern. Die Fehlertexte selbst bleiben, wie das Modell
@@ -501,9 +734,24 @@ function naechsterSchritt(fehler) {
501
734
  return 'Der Harness legt sein Verzeichnis beim ersten Start an. hmgr status zeigt, welche Harnesses hier liegen.';
502
735
  if (fehler instanceof OrtNichtUnterstuetzt)
503
736
  return 'profil:<name> kennt nur hermes, claude-code und agents kennen home und dir:<pfad>.';
504
- const text = fehler instanceof Error ? fehler.message : '';
505
- if (text.startsWith('Unbekannter Harness'))
506
- return 'Bekannt sind claude-code, hermes und agents.';
737
+ if (fehler instanceof HarnessTypUnbekannt)
738
+ return 'Bekannt sind claude-code, hermes und agents. hmgr harness list zeigt sie mit ihrem Stand.';
739
+ if (fehler instanceof QuelleUnbekannt)
740
+ return 'hmgr source list zeigt die bekannten Quellen.';
741
+ if (fehler instanceof QuelleNameBelegt)
742
+ return 'hmgr source list zeigt die schon vergebenen Namen.';
743
+ if (fehler instanceof ZeigerSchreibenGesperrt)
744
+ return 'Nutze --local, das schreibt nichts in den Zeiger.';
745
+ if (fehler instanceof SkillOrdnerFaehigkeitFehlt)
746
+ return 'Nur hermes kennt einen verwalteten Skill-Ordner.';
747
+ if (fehler instanceof SkillOrdnerBereitsVerwaltet)
748
+ return `Erst entfernen: hmgr harness skills-dir ${fehler.ziel.harness} remove${fehler.ziel.ort.art === 'profil' ? ` --profil ${fehler.ziel.ort.name}` : ''}.`;
749
+ if (fehler instanceof SkillOrdnerNichtVerwaltet)
750
+ return `Festlegen mit hmgr harness skills-dir ${fehler.ziel.harness} add <pfad>${fehler.ziel.ort.art === 'profil' ? ` --profil ${fehler.ziel.ort.name}` : ''}.`;
751
+ if (fehler instanceof SkillOrdnerUngueltig)
752
+ return fehler.grund === 'ausserhalb'
753
+ ? 'Der Pfad muss unterhalb des Hermes-Home liegen.'
754
+ : 'Das ist Hermes\' eigener Skill-Ordner, ein anderer Pfad muss her.';
507
755
  return undefined;
508
756
  }
509
757
  export async function main(argv, umgebung) {
@@ -0,0 +1,114 @@
1
+ // Quellen-Icon im Main-Prozess (E60): Besitzer-Avatar bei GitHub/Gitea, sonst Favicon, sonst
2
+ // generisch. Lädt nur hier (nie im Renderer, contextIsolation lässt dort ohnehin kein fetch mit
3
+ // Netzzugriff zu, das hier ist reine Main-Prozess-Politik), cached als data-URL auf Platte
4
+ // unterhalb von HMGR_HOME (Aufrufer reicht das Verzeichnis rein), damit Tests und der Smoke-Test
5
+ // nie ein echtes Verzeichnis anfassen. Ein Fehlschlag wird mitgecacht, aber nur für einen Tag: ein
6
+ // Treffer gilt für immer, ein Fehlschlag verfällt, sonst bliebe es bei generischen Symbolen für
7
+ // jeden, der beim ersten Start ohne Netz war. „lokal" und jede unbekannte Adresse ergeben sofort null.
8
+ import { createHash } from 'node:crypto';
9
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { urlOrt } from "../kern/quelle-ort.js";
12
+ const STANDARD_TIMEOUT_MS = 5000;
13
+ /**
14
+ * Ein Fehlschlag hält einen Tag, ein Treffer hält für immer. Ohne Verfall bekäme jeder, der beim
15
+ * ersten Start offline war, dauerhaft nur generische Symbole — der Cache soll das Netz schonen,
16
+ * nicht ein einmaliges „gerade kein Netz" festschreiben.
17
+ */
18
+ const FEHLSCHLAG_HALTBARKEIT_MS = 24 * 60 * 60 * 1000;
19
+ /**
20
+ * Obergrenze für ein Icon. Ein Avatar oder Favicon ist ein paar Kilobyte groß; alles darüber ist
21
+ * kein Symbol mehr und hätte als data-URL nichts im Cache und in der Ansicht verloren.
22
+ */
23
+ const MAX_ICON_BYTES = 512 * 1024;
24
+ /**
25
+ * Kandidaten-URLs für den Besitzer-Avatar, in Versuchsreihenfolge (F35): GitHub liefert den Avatar
26
+ * über einen eigenen Pfad, jeder andere http(s)-Host bekommt zuerst den mutmaßlichen Avatar-Pfad
27
+ * versucht (das bedient auch Gitea), dann sein Favicon.
28
+ */
29
+ export function iconKandidaten(ort) {
30
+ if (ort.host === 'github.com')
31
+ return [`https://github.com/${ort.besitzer}.png?size=40`];
32
+ return [`https://${ort.host}/${ort.besitzer}.png`, `https://${ort.host}/favicon.ico`];
33
+ }
34
+ function cacheDateiname(schluessel) {
35
+ return `${createHash('sha256').update(schluessel).digest('hex')}.txt`;
36
+ }
37
+ /**
38
+ * Die Zeitüberschreitung umfasst auch das Lesen des Rumpfs, nicht nur den Kopf: ein Server, der die
39
+ * Kopfzeilen sofort schickt und dann tröpfelt, würde den Aufruf sonst unbegrenzt hängen lassen.
40
+ * Deshalb läuft die ganze Arbeit unter demselben Signal, der Wecker fällt erst danach.
41
+ */
42
+ async function mitTimeout(arbeit, timeoutMs) {
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
45
+ try {
46
+ return await arbeit(controller.signal);
47
+ }
48
+ finally {
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+ async function alsDataUrl(antwort) {
53
+ if (!antwort.ok)
54
+ return undefined;
55
+ const typ = antwort.headers.get('content-type') ?? '';
56
+ if (!typ.startsWith('image/'))
57
+ return undefined;
58
+ const angekuendigt = Number(antwort.headers.get('content-length'));
59
+ if (Number.isFinite(angekuendigt) && angekuendigt > MAX_ICON_BYTES)
60
+ return undefined;
61
+ const puffer = Buffer.from(await antwort.arrayBuffer());
62
+ if (puffer.length === 0 || puffer.length > MAX_ICON_BYTES)
63
+ return undefined;
64
+ return `data:${typ.split(';')[0]};base64,${puffer.toString('base64')}`;
65
+ }
66
+ /**
67
+ * Was im Cache steht: eine data-URL (Treffer, gilt immer), oder ein Fehlschlag (leere Datei), der
68
+ * nur bis zu seiner Haltbarkeit gilt. Fehlt beides, muss abgefragt werden.
69
+ */
70
+ async function ausCache(cachePfad) {
71
+ const inhalt = await readFile(cachePfad, 'utf8').catch(() => undefined);
72
+ if (inhalt === undefined)
73
+ return undefined;
74
+ if (inhalt.length > 0)
75
+ return { wert: inhalt };
76
+ const info = await stat(cachePfad).catch(() => undefined);
77
+ if (info && Date.now() - info.mtimeMs < FEHLSCHLAG_HALTBARKEIT_MS)
78
+ return { wert: null };
79
+ return undefined;
80
+ }
81
+ /**
82
+ * Icon einer Quelle als data-URL, oder null (lokale Quelle, unlesbare Adresse oder jeder
83
+ * Fehlschlag: Zeitüberschreitung, Netzfehler, kein Bild). Das Ergebnis – auch ein Fehlschlag –
84
+ * landet auf Platte, ein zweiter Aufruf für denselben Host/Besitzer fragt nicht erneut ab; ein
85
+ * gecachter Fehlschlag verfällt nach einem Tag und wird dann erneut versucht.
86
+ */
87
+ export async function iconFuerQuelle(quelle, optionen) {
88
+ if (quelle.art === 'lokal')
89
+ return null;
90
+ const ort = urlOrt(quelle.url);
91
+ if (!ort || ort.host === 'local')
92
+ return null;
93
+ const holen = optionen.fetch ?? ((url, init) => fetch(url, init));
94
+ const timeoutMs = optionen.timeoutMs ?? STANDARD_TIMEOUT_MS;
95
+ const schluessel = `${ort.host}/${ort.besitzer}`;
96
+ const cachePfad = path.join(optionen.cacheVerzeichnis, cacheDateiname(schluessel));
97
+ const cached = await ausCache(cachePfad);
98
+ if (cached !== undefined)
99
+ return cached.wert;
100
+ let ergebnis;
101
+ for (const url of iconKandidaten(ort)) {
102
+ try {
103
+ ergebnis = await mitTimeout(async (signal) => alsDataUrl(await holen(url, { signal })), timeoutMs);
104
+ }
105
+ catch {
106
+ ergebnis = undefined;
107
+ }
108
+ if (ergebnis)
109
+ break;
110
+ }
111
+ await mkdir(optionen.cacheVerzeichnis, { recursive: true }).catch(() => undefined);
112
+ await writeFile(cachePfad, ergebnis ?? '', 'utf8').catch(() => undefined);
113
+ return ergebnis ?? null;
114
+ }