hmgr 0.0.2 → 0.0.4

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 CHANGED
@@ -26,34 +26,5 @@ npm run build
26
26
 
27
27
  ## Kommandos
28
28
 
29
- ```
30
- source add <url|pfad> Quelle hinzufügen (URL wird geklont, Pfad bleibt Arbeitskopie)
31
- source list Quellen zeigen
32
- dir add <pfad> Arbeitsverzeichnis merken und an seinen Zeiger binden
33
- dir forget <pfad> Arbeitsverzeichnis und Bindung vergessen, Links bleiben
34
- dir list Arbeitsverzeichnisse mit ihrer Bindung zeigen
35
- link <skill> Skill in ein Ziel verlinken
36
- unlink <skill> Link entfernen
37
- adopt [name] Fremde Links im Ziel durch eigene hmgr-Links ersetzen
38
- plugin add <name> Plugin in ein Ziel legen (installieren oder Skills verlinken)
39
- plugin remove <name> Plugin aus einem Ziel entfernen
40
- plugin list Plugins der Marketplaces aller Quellen
41
- sync [pfad] Ziel auf den Stand seines Zeigers bringen
42
- update Alle Checkouts aktualisieren
43
- status Ist-Zustand der Ziele
44
- ```
45
-
46
- Optionen:
47
-
48
- ```
49
- --harness <name> claude-code, hermes oder agents, Standard claude-code
50
- --ort home|dir:<pfad>|profil:<name> Standard home
51
- --quelle <teilstring> Quelle bei mehrdeutigem Skill-Namen
52
- --version <spanne> Versionseinschränkung nach Composer-Regeln, nur mit Zeiger
53
- --composer <pfad> composer.json wählen, wenn mehrere in Frage kommen
54
- --local Link nur ins Ziel legen, nicht in den Zeiger
55
- --ersetzen fremden Eintrag am Ziel ersetzen
56
- --probelauf bei adopt nur melden, nichts schreiben
57
- ```
58
-
59
- `hmgr help` zeigt dieselbe Übersicht.
29
+ `hmgr --help` zeigt die Kommandos, `hmgr help <kommando>` Schalter, Beispiele
30
+ und Zusammenhänge.
@@ -1,6 +1,7 @@
1
1
  // Pfadschema der Checkouts (E22): <wurzel>/<host>/<besitzer>/<repo>/<ref>/,
2
2
  // je Repo ein Basis-Clone unter <wurzel>/<host>/<besitzer>/<repo>/.repo.
3
3
  import path from 'node:path';
4
+ import { ortAusSegmenten, urlOrt } from "../kern/quelle-ort.js";
4
5
  export const BASIS_NAME = '.repo';
5
6
  /** Refs mit „/" im Namen werden im Pfad kodiert, damit ein Ref ein Verzeichnis bleibt. */
6
7
  export function refKodieren(ref) {
@@ -9,29 +10,13 @@ export function refKodieren(ref) {
9
10
  export function refDekodieren(text) {
10
11
  return text.replaceAll('%2F', '/').replaceAll('%25', '%');
11
12
  }
12
- function ohneGitSuffix(name) {
13
- return name.endsWith('.git') ? name.slice(0, -4) : name;
14
- }
15
- function ausSegmenten(host, segmente) {
16
- const gefiltert = segmente.filter((s) => s.length > 0);
17
- const repo = ohneGitSuffix(gefiltert[gefiltert.length - 1] ?? 'repo');
18
- const besitzer = gefiltert[gefiltert.length - 2] ?? '_';
19
- return { host, besitzer, repo };
20
- }
21
13
  /** Zerlegt „git@host:besitzer/repo.git", „https://host/besitzer/repo", „file:///pfad" und nackte Pfade. */
22
14
  export function urlZerlegen(url) {
23
- const text = url.trim().replace(/\/+$/, '');
24
- if (text.includes('://')) {
25
- const u = new URL(text);
26
- if (u.protocol === 'file:')
27
- return ausSegmenten('local', decodeURIComponent(u.pathname).split('/'));
28
- return ausSegmenten(u.hostname, decodeURIComponent(u.pathname).split('/'));
29
- }
30
- const scp = /^(?:[^@/]+@)?([^@/:]+):(.+)$/.exec(text);
31
- if (scp)
32
- return ausSegmenten(scp[1], scp[2].split('/'));
15
+ const ort = urlOrt(url);
16
+ if (ort !== undefined)
17
+ return ort;
33
18
  // Nackter lokaler Pfad: Besitzer aus dem Elternverzeichnis.
34
- return ausSegmenten('local', path.resolve(text).split(path.sep));
19
+ return ortAusSegmenten('local', path.resolve(url.trim()).split(path.sep));
35
20
  }
36
21
  export function quelleZerlegen(quelle) {
37
22
  return urlZerlegen(quelle.art === 'git' ? quelle.url : quelle.pfad);
@@ -1,6 +1,6 @@
1
1
  // Git über die CLI, keine Bibliothek. Checkout-Schema und Worktrees nach E22, Discovery nach E11.
2
2
  import { execFile, spawn } from 'node:child_process';
3
- import { readFile, readdir, mkdir, realpath, stat } from 'node:fs/promises';
3
+ import { readFile, readdir, mkdir, realpath, rm, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { marketplaceLesen } from "./marketplace-json.js";
@@ -225,6 +225,15 @@ export class GitQuellen {
225
225
  const ausgabe = await this.git(['for-each-ref', '--format=%(refname:short)', 'refs/tags'], basis);
226
226
  return ausgabe.split('\n').filter((z) => z.length > 0);
227
227
  }
228
+ /**
229
+ * Refs vom Remote nachziehen, damit eine Versionseinschränkung auch Tags sieht, die nach dem
230
+ * Clone entstanden sind (E33). Lokale Quellen haben keinen Clone und keinen Remote (B11).
231
+ */
232
+ async refsNachziehen(quelle) {
233
+ if (quelle.art === 'lokal')
234
+ return;
235
+ await this.git(['fetch', '--prune', '--tags', 'origin'], await this.basis(quelle));
236
+ }
228
237
  async standardRef(quelle) {
229
238
  if (quelle.art === 'lokal')
230
239
  return LOKAL_REF;
@@ -305,6 +314,24 @@ export class GitQuellen {
305
314
  await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
306
315
  return { ergebnis: 'aktualisiert', von, nach };
307
316
  }
317
+ /**
318
+ * Checkout von der Platte nehmen. Ein Worktree steht im Basis-Clone eingetragen, ein blankes
319
+ * Löschen des Verzeichnisses ließe den Eintrag stehen und ein späterer `worktree add` auf
320
+ * denselben Pfad scheiterte daran. Bleibt der Eintrag defekt, wird das Verzeichnis entfernt und
321
+ * der Eintrag danach mit `prune` aufgeräumt. Der Basis-Clone bleibt, er trägt alle Refs.
322
+ */
323
+ async entfernen(checkout) {
324
+ if (checkout.quelle.art === 'lokal')
325
+ return;
326
+ const basis = await this.basis(checkout.quelle);
327
+ try {
328
+ await this.git(['worktree', 'remove', '--force', checkout.pfad], basis);
329
+ }
330
+ catch {
331
+ await rm(checkout.pfad, { recursive: true, force: true });
332
+ await this.git(['worktree', 'prune'], basis);
333
+ }
334
+ }
308
335
  /**
309
336
  * Arbeitskopie um einen Pfad herum (B12). Die Wurzel kommt aus `rev-parse --show-toplevel`, vom
310
337
  * Verzeichnis des Pfads aus gefragt. Beide Pfade werden über realpath aufgelöst, sonst passen
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ export { quelleAlsText } from "../kern/modell.js";
2
3
  /** URL heißt Git-Quelle mit Checkout, ein nackter Pfad heißt lokale Arbeitskopie ohne Clone (B11). */
3
4
  export function quelleAusText(text) {
4
5
  const roh = text.trim();
@@ -6,6 +7,3 @@ export function quelleAusText(text) {
6
7
  return { art: 'git', url: roh };
7
8
  return { art: 'lokal', pfad: path.resolve(roh) };
8
9
  }
9
- export function quelleAlsText(quelle) {
10
- return quelle.art === 'git' ? quelle.url : quelle.pfad;
11
- }
@@ -21,4 +21,27 @@ export class TerminalRueckfrage {
21
21
  leser.close();
22
22
  }
23
23
  }
24
+ /**
25
+ * Nummerierte Liste, eine gelesene Zahl. Dieselbe Weiche wie bei `bestaetigen`: ohne Terminal auf
26
+ * beiden Seiten fragt hmgr nicht und gibt nichts aus. Alles, was keine der Nummern ist, auch die
27
+ * leere Eingabe, gilt als keine Antwort. Der Adapter rät nicht, er reicht das Nichts weiter.
28
+ */
29
+ async auswaehlen(frage, optionen) {
30
+ if (optionen.length === 0)
31
+ return undefined;
32
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true)
33
+ return undefined;
34
+ const leser = createInterface({ input: process.stdin, output: process.stdout });
35
+ try {
36
+ const liste = optionen.map((option, i) => ` ${i + 1}) ${option}`).join('\n');
37
+ const antwort = await leser.question(`${frage}\n${liste}\nNummer [1-${optionen.length}]: `);
38
+ const nummer = Number(antwort.trim());
39
+ if (!Number.isInteger(nummer) || nummer < 1 || nummer > optionen.length)
40
+ return undefined;
41
+ return nummer - 1;
42
+ }
43
+ finally {
44
+ leser.close();
45
+ }
46
+ }
24
47
  }
@@ -0,0 +1,268 @@
1
+ // Hilfetexte der CLI. Nur Zeichenketten, keine Ausgabe: die macht main.ts (Terminal-I/O).
2
+ /** Ebene 1: Orientierung, je Kommando ein Einzeiler, Verweis auf die Kommando-Hilfe. */
3
+ export const HILFE = `hmgr verlinkt Skills aus Git-Quellen in die Verzeichnisse, aus denen ein Harness sie lädt:
4
+ claude-code, hermes oder agents. Welche Skills und Plugins zu einem Arbeitsverzeichnis gehören,
5
+ steht in seinem Zeiger, dem extra-Abschnitt einer composer.json.
6
+
7
+ Arbeitszyklus: Skill in seiner Quelle ändern, committen und pushen, dann hmgr update. Ein frisch
8
+ geklontes Vorhaben holt seinen Stand mit hmgr sync.
9
+
10
+ hmgr <kommando> [schalter]
11
+
12
+ source add <url|pfad> Quelle hinzufügen (URL wird geklont, Pfad bleibt Arbeitskopie)
13
+ source list Quellen zeigen
14
+ dir add <pfad> Arbeitsverzeichnis merken und an seinen Zeiger binden
15
+ dir forget <pfad> Arbeitsverzeichnis und Bindung vergessen, Links bleiben
16
+ dir list Arbeitsverzeichnisse mit ihrer Bindung zeigen
17
+ link <skill> Skill in ein Ziel verlinken
18
+ unlink <skill> Link entfernen
19
+ adopt [name] Fremde Links im Ziel durch eigene hmgr-Links ersetzen
20
+ plugin add <name> Plugin in ein Ziel legen (installieren oder Skills verlinken)
21
+ plugin remove <name> Plugin aus einem Ziel entfernen
22
+ plugin list Plugins der Marketplaces aller Quellen
23
+ sync [pfad] Ziel auf den Stand seines Zeigers bringen
24
+ update Alle Checkouts aktualisieren
25
+ status Ist-Zustand der Ziele
26
+
27
+ hmgr help <kommando> zeigt Schalter, Beispiele und Zusammenhänge.`;
28
+ const SOURCE = `hmgr source add <url|pfad>
29
+ hmgr source list
30
+
31
+ Eine Quelle ist ein Git-Repo mit Skills. source add prüft, ob sie erreichbar ist, liest ihre Skills
32
+ und ihren Marketplace und merkt sie unter ~/.hmgr. Erst danach kennt hmgr die Skill-Namen, mit
33
+ denen link und plugin add arbeiten. Eine URL wird geklont, ein Pfad bleibt die Arbeitskopie, die
34
+ dort schon liegt, und wird von hmgr nicht verändert.
35
+
36
+ Welche Quelle gemeint ist, hängt an Host, Besitzer und Repo, nicht an der Schreibweise der URL. Ein
37
+ Repo in SSH- und in HTTPS-Form ist dieselbe Quelle, mit und ohne .git-Suffix ebenso. Gefetcht wird
38
+ unter der Schreibweise, die beim ersten source add stand.
39
+
40
+ Schalter: keine.
41
+
42
+ Beispiele:
43
+ hmgr source add https://github.com/tim/skills.git
44
+ hmgr source add ~/.skills/tim-skills
45
+ hmgr source list
46
+
47
+ Häufige Fälle:
48
+ „Skill … liegt in keiner Quelle": die Quelle fehlt, erst source add, dann link.
49
+ Neue Commits einer bekannten Quelle holt hmgr update, nicht source add.
50
+ „Quelle ist schon bekannt": dasselbe Repo steht schon im Zustand, hier in anderer Schreibweise.
51
+
52
+ Siehe auch: hmgr help link, hmgr help plugin, hmgr help update`;
53
+ const DIR = `hmgr dir add <pfad> [--composer <pfad>]
54
+ hmgr dir forget <pfad>
55
+ hmgr dir list
56
+
57
+ Ein Arbeitsverzeichnis ist ein Verzeichnis, in dem ein Harness gestartet wird. dir add merkt es und
58
+ bindet es an seinen Zeiger, also an die composer.json, in deren extra-Abschnitt Quellen, Skills und
59
+ Plugins des Vorhabens stehen. Ohne Zeiger bleibt das Verzeichnis gemerkt und jeder Link dort lokal
60
+ (E26). dir forget löst Bindung und Eintrag, die gelegten Links bleiben liegen.
61
+
62
+ Schalter:
63
+ --composer <pfad> nur bei add: composer.json wählen, wenn unter dem Verzeichnis mehrere in
64
+ Frage kommen
65
+
66
+ Beispiele:
67
+ hmgr dir add ~/Code/site
68
+ hmgr dir add ~/Code/site --composer ~/Code/site/app/composer.json
69
+ hmgr dir list
70
+
71
+ Häufige Fälle:
72
+ „Mehrere composer.json unter …": hmgr fragt am Terminal, welche es ist. Ohne Antwort, etwa in
73
+ einem Script, bleibt der Fehler und --composer nennt sie, hmgr rät nicht.
74
+ link --ort dir:<pfad> merkt das Verzeichnis von selbst. dir add braucht es vorher nur, wenn die
75
+ composer.json mehrdeutig ist.
76
+
77
+ Siehe auch: hmgr help link, hmgr help sync`;
78
+ const LINK = `hmgr link <skill> [schalter]
79
+
80
+ Legt im Ziel einen Symlink auf den Skill an, der aus einer bekannten Quelle stammt, und trägt ihn
81
+ in den Zeiger des Ziels ein, damit hmgr sync ihn anderswo reproduzieren kann. Einen Zeiger gibt es
82
+ nur für ein Arbeitsverzeichnis mit composer.json, an Home und Profil bleibt jeder Link lokal.
83
+
84
+ Schalter:
85
+ --harness <name> claude-code, hermes oder agents, Standard claude-code
86
+ --ort <ort> home, dir:<pfad> oder profil:<name>, Standard home. profil kennt nur hermes
87
+ --quelle <teil> Teilstring der Quelle, wenn der Skill-Name in mehreren Quellen vorkommt
88
+ --version <spanne> Versionseinschränkung nach Composer-Regeln (etwa ^1.2) oder ein Tag- oder
89
+ Branchname. Nur mit Zeiger, also dir:<pfad> ohne --local (E33)
90
+ --composer <pfad> composer.json des Arbeitsverzeichnisses, wenn mehrere in Frage kommen
91
+ --local Link nur ins Ziel legen, nicht in den Zeiger
92
+ --ersetzen einen fremden Eintrag gleichen Namens am Ziel ersetzen
93
+
94
+ Beispiele:
95
+ hmgr link netzwerk
96
+ hmgr link netzwerk --harness hermes --ort profil:researcher
97
+ hmgr link tq-schema --ort dir:. --version ^2.0
98
+
99
+ Häufige Fälle:
100
+ „liegt in mehreren Quellen": mit --quelle <teilstring der URL oder des Pfads> entscheiden.
101
+ „liegt bereits ein fremder Eintrag": --ersetzen setzen oder vorher hmgr adopt <name>.
102
+ „gibt es keinen Zeiger": --version braucht ein Arbeitsverzeichnis mit composer.json und kein
103
+ --local. Ohne Zeiger den Link ohne --version legen.
104
+
105
+ Siehe auch: hmgr help unlink, hmgr help sync, hmgr help adopt`;
106
+ const UNLINK = `hmgr unlink <skill> [schalter]
107
+
108
+ Entfernt den Link aus dem Ziel und, wenn das Ziel an einen Zeiger gebunden ist, den Skill aus dem
109
+ Zeiger. Der Checkout unter ~/.hmgr bleibt liegen, Links anderer Ziele bleiben unberührt.
110
+
111
+ Schalter:
112
+ --harness <name> claude-code, hermes oder agents, Standard claude-code
113
+ --ort <ort> home, dir:<pfad> oder profil:<name>, Standard home. profil kennt nur hermes
114
+ --composer <pfad> composer.json des Arbeitsverzeichnisses, wenn mehrere in Frage kommen
115
+ --local nur den Link entfernen, den Eintrag im Zeiger stehen lassen
116
+
117
+ Beispiele:
118
+ hmgr unlink netzwerk
119
+ hmgr unlink netzwerk --ort dir:. --local
120
+
121
+ Häufige Fälle:
122
+ Bleibt der Skill im Zeiger stehen, legt hmgr sync ihn wieder an. Ohne --local räumt unlink
123
+ beides ab.
124
+
125
+ Siehe auch: hmgr help link, hmgr help sync`;
126
+ const ADOPT = `hmgr adopt [name] [schalter]
127
+
128
+ Ersetzt fremde Symlinks im Ziel durch eigene hmgr-Links: hmgr liest, aus welcher Git-Arbeitskopie
129
+ ein Link stammt, nimmt deren remote.origin.url als Quelle, legt einen Checkout am selben Branch an
130
+ und verlinkt den Skill daraus. Ohne Namen laufen alle fremden Einträge des Ziels durch, mit Namen
131
+ nur dieser eine. Was sich nicht auflösen lässt, bleibt liegen und wird mit Grund gemeldet.
132
+
133
+ Schalter:
134
+ --harness <name> claude-code, hermes oder agents, Standard claude-code
135
+ --ort <ort> home, dir:<pfad> oder profil:<name>, Standard home. profil kennt nur hermes
136
+ --composer <pfad> composer.json des Arbeitsverzeichnisses, wenn mehrere in Frage kommen
137
+ --local Übernahme nicht in den Zeiger schreiben
138
+ --probelauf nur melden, nichts schreiben
139
+
140
+ Beispiele:
141
+ hmgr adopt --probelauf
142
+ hmgr adopt netzwerk --ort dir:.
143
+
144
+ Häufige Fälle:
145
+ „kein Symlink": der Eintrag ist ein echtes Verzeichnis, hmgr fasst ihn nicht an.
146
+ „liegt in keiner Git-Arbeitskopie" oder „ohne remote.origin.url": die Quelle ist nicht
147
+ bestimmbar. Mit hmgr source add <url> eine Quelle nennen und mit hmgr link --ersetzen verlinken.
148
+ „ohne Branch (detached HEAD)": in der Arbeitskopie einen Branch auschecken, dann erneut adopt.
149
+ „Pfad ist in der Quelle kein Skill": im Repo liegt an der Stelle keine SKILL.md.
150
+
151
+ Siehe auch: hmgr help link, hmgr help status`;
152
+ const PLUGIN = `hmgr plugin add <name> [schalter]
153
+ hmgr plugin remove <name> [schalter]
154
+ hmgr plugin list
155
+
156
+ Ein Plugin ist ein Bündel aus einem Marketplace, also aus einer Quelle mit
157
+ .claude-plugin/marketplace.json. Hat der Harness die Plugin-Fähigkeit (claude-code), lässt hmgr das
158
+ Plugin von dessen CLI installieren, sonst verlinkt es die flache Skill-Liste des Plugins samt
159
+ seinen Abhängigkeiten. In den Zeiger kommt das Plugin, nicht seine Einzelskills. plugin list zeigt
160
+ die Marketplaces aller bekannten Quellen mit ihren Plugins.
161
+
162
+ Schalter (add und remove):
163
+ --harness <name> claude-code, hermes oder agents, Standard claude-code
164
+ --ort <ort> home, dir:<pfad> oder profil:<name>, Standard home. profil kennt nur hermes
165
+ --composer <pfad> composer.json des Arbeitsverzeichnisses, wenn mehrere in Frage kommen
166
+ --local Plugin nicht in den Zeiger schreiben
167
+ --quelle <teil> nur bei add: Quelle eingrenzen, wenn der Name in mehreren Marketplaces steht
168
+
169
+ Beispiele:
170
+ hmgr plugin list
171
+ hmgr plugin add superpowers --ort dir:.
172
+ hmgr plugin remove superpowers
173
+
174
+ Häufige Fälle:
175
+ „Keine Quelle mit Marketplace": erst hmgr source add <url> der Quelle mit der marketplace.json.
176
+ „liegt in keinem Marketplace der bekannten Quellen": Namen mit hmgr plugin list prüfen.
177
+ „liegt in mehreren Marketplaces": mit --quelle <teilstring> entscheiden.
178
+ „braucht …, das im selben Marketplace nicht steht": die Abhängigkeit fehlt im Marketplace, das
179
+ kann nur die Quelle beheben.
180
+
181
+ Siehe auch: hmgr help source, hmgr help sync`;
182
+ const SYNC = `hmgr sync [pfad]
183
+
184
+ Bringt ein Arbeitsverzeichnis auf den Stand seines Zeigers: fehlende Quellen übernehmen, fehlende
185
+ Links legen, Links auf einen anderen Ref umlegen, Plugins herstellen. Ohne Pfad gilt das aktuelle
186
+ Verzeichnis. Das ist der Befehl nach einem frischen Clone eines Vorhabens und nach jeder Änderung
187
+ am Zeiger. Eigene Links, die im Zeiger fehlen, werden als abweichend gemeldet und nicht entfernt,
188
+ fremde Einträge bleiben unberührt.
189
+
190
+ Schalter: keine.
191
+
192
+ Beispiele:
193
+ hmgr sync
194
+ hmgr sync ~/Code/site
195
+
196
+ Häufige Fälle:
197
+ „Kein Zeiger unter …, nichts zu tun": im Verzeichnis liegt keine composer.json. Mit
198
+ hmgr dir add <pfad> --composer <pfad> eine nennen.
199
+ „steht nicht im Zeiger": den Link mit hmgr link <skill> --ort dir:<pfad> eintragen oder mit
200
+ hmgr unlink <skill> --ort dir:<pfad> --local entfernen.
201
+ „Kein Harness auf diesem Rechner": weder ~/.claude noch ~/.hermes noch ~/.agents ist angelegt.
202
+
203
+ Siehe auch: hmgr help dir, hmgr help link, hmgr help update`;
204
+ const UPDATE = `hmgr update
205
+
206
+ Holt für jeden Checkout unter ~/.hmgr den neuesten Stand seines Refs und zieht bei Harnesses mit
207
+ Plugin-Fähigkeit deren Plugins nach (E14). Die Links zeigen weiter auf dieselben Pfade und müssen
208
+ nicht erneuert werden. Ein Checkout mit lokaler Änderung wird übersprungen und gemeldet (E15),
209
+ damit nichts verloren geht. Lokale Quellen haben keinen Checkout und bleiben unberührt.
210
+
211
+ Danach räumt update verwaiste Ref-Verzeichnisse auf: Checkouts, in die kein Link mehr zeigt, weil
212
+ ein Link inzwischen auf einen anderen Ref umgelegt wurde. Vor dem Löschen fragt hmgr einmal für
213
+ alle Kandidaten. Ohne Bestätigung, also auch ohne Terminal, bleibt alles liegen und wird nur
214
+ gemeldet. Ein verwaister Checkout mit lokaler Änderung wird nie gelöscht (E15).
215
+
216
+ So wird eine Änderung an einem Skill überall wirksam: in der Quelle committen und pushen, dann
217
+ hmgr update.
218
+
219
+ Schalter: keine.
220
+
221
+ Beispiel:
222
+ hmgr update
223
+
224
+ Häufige Fälle:
225
+ „übersprungen … lokale Änderung (E15)": im Checkout liegt eine Änderung, die durch einen Link
226
+ hindurch gemacht wurde. Dort committen und pushen, dann erneut hmgr update.
227
+ „Keine Checkouts": es gibt nur lokale Quellen oder noch keinen Link.
228
+ „liegen lassen … ohne Bestätigung nicht gelöscht": die Frage kam ohne Ja zurück, etwa in einer
229
+ Pipe oder einem Composer-Script. In einem Terminal erneut hmgr update aufrufen.
230
+
231
+ Siehe auch: hmgr help sync, hmgr help status`;
232
+ const STATUS = `hmgr status
233
+
234
+ Liest den Ist-Zustand vom Dateisystem: je Ziel eines vorhandenen Harness die Einträge mit Quelle
235
+ und Ref, davor die Versionseinschränkung, falls der Zeiger eine nennt. Fremde Einträge und Links
236
+ ohne bekannte Quelle sind eigens gekennzeichnet, dazu kommen die Plugins des Ziels. Am Ende stehen
237
+ die Harnesses, die auf diesem Rechner nicht liegen.
238
+
239
+ Schalter: keine.
240
+
241
+ Beispiel:
242
+ hmgr status
243
+
244
+ Häufige Fälle:
245
+ „fremd": der Eintrag stammt nicht von hmgr. hmgr adopt <name> übernimmt ihn.
246
+ „Link ohne bekannte Quelle": der Link zeigt in einen hmgr-Checkout, dessen Quelle nicht mehr im
247
+ Zustand steht. Mit hmgr source add <url> die Quelle wieder eintragen.
248
+ „Nicht vorhanden": der Harness hat sein Verzeichnis auf diesem Rechner nicht angelegt, hmgr
249
+ überspringt seine Ziele.
250
+
251
+ Siehe auch: hmgr help adopt, hmgr help sync`;
252
+ /** Ebene 2, je Kommandofamilie ein Text. Unterkommandos teilen sich die Hilfe ihrer Familie. */
253
+ const THEMEN = new Map([
254
+ ['source', SOURCE],
255
+ ['dir', DIR],
256
+ ['link', LINK],
257
+ ['unlink', UNLINK],
258
+ ['adopt', ADOPT],
259
+ ['plugin', PLUGIN],
260
+ ['sync', SYNC],
261
+ ['update', UPDATE],
262
+ ['status', STATUS],
263
+ ['help', HILFE],
264
+ ]);
265
+ /** Hilfetext zu einem Kommando, undefined für ein unbekanntes Thema. */
266
+ export function hilfeText(thema) {
267
+ return THEMEN.get(thema);
268
+ }
package/dist/cli/main.js CHANGED
@@ -15,6 +15,7 @@ import { GitQuellen } from "../adapter/git.js";
15
15
  import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
16
16
  import { TerminalRueckfrage } from "../adapter/terminal.js";
17
17
  import { ZustandJson } from "../adapter/zustand-json.js";
18
+ import { HILFE, hilfeText } from "./hilfe.js";
18
19
  export function umgebungLesen(env = process.env) {
19
20
  const home = env.HOME ?? homedir();
20
21
  return { home, hmgrHome: env.HMGR_HOME ?? path.join(home, '.hmgr') };
@@ -35,9 +36,44 @@ export function bauen(umgebung, optionen = {}) {
35
36
  };
36
37
  return { anwendungsfaelle: new Anwendungsfaelle(ports), ports };
37
38
  }
38
- /** Schalter ohne Wert. Alles andere braucht einen. */
39
- const FLAGGEN = new Set(['ersetzen', 'local', 'lokal', 'probelauf']);
40
- function schalterLesen(args) {
39
+ /**
40
+ * Was jedes Kommando auswertet, gleichlautend mit seiner Hilfe. Alles andere bricht ab. `--help`
41
+ * steht nirgends, weil der Hilfe-Dispatch vor jedem schalterLesen läuft.
42
+ */
43
+ const SCHALTER = {
44
+ source: { werte: [], flaggen: [] },
45
+ dir: { werte: ['composer'], flaggen: [] },
46
+ link: {
47
+ werte: ['harness', 'ort', 'composer', 'quelle', 'version'],
48
+ flaggen: ['local', 'lokal', 'ersetzen'],
49
+ },
50
+ unlink: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal'] },
51
+ adopt: { werte: ['harness', 'ort', 'composer'], flaggen: ['local', 'lokal', 'probelauf'] },
52
+ plugin: { werte: ['harness', 'ort', 'composer', 'quelle'], flaggen: ['local', 'lokal'] },
53
+ sync: { werte: [], flaggen: [] },
54
+ update: { werte: [], flaggen: [] },
55
+ status: { werte: [], flaggen: [] },
56
+ };
57
+ /**
58
+ * Was ein einzelnes Unterkommando von seiner Familie liest, gleichlautend mit ihrer Hilfe. Ohne
59
+ * `schalter` bleibt es bei der Familienliste, das reicht, solange alle Unterkommandos dieselben
60
+ * Schalter lesen.
61
+ */
62
+ const UNTERKOMMANDOS = {
63
+ source: { add: { woerter: 2 }, list: { woerter: 1 } },
64
+ dir: {
65
+ add: { woerter: 2, schalter: ['composer'] },
66
+ forget: { woerter: 2, schalter: [] },
67
+ list: { woerter: 1, schalter: [] },
68
+ },
69
+ plugin: {
70
+ add: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'quelle', 'local', 'lokal'] },
71
+ remove: { woerter: 2, schalter: ['harness', 'ort', 'composer', 'local', 'lokal'] },
72
+ list: { woerter: 1, schalter: [] },
73
+ },
74
+ };
75
+ function schalterLesen(args, kommando) {
76
+ const erlaubt = SCHALTER[kommando];
41
77
  const rest = [];
42
78
  const werte = new Map();
43
79
  const flaggen = new Set();
@@ -48,17 +84,50 @@ function schalterLesen(args) {
48
84
  continue;
49
85
  }
50
86
  const name = arg.slice(2);
51
- if (FLAGGEN.has(name)) {
87
+ if (erlaubt.flaggen.includes(name)) {
52
88
  flaggen.add(name);
53
89
  continue;
54
90
  }
91
+ if (!erlaubt.werte.includes(name))
92
+ throw new Error(`Unbekannter Schalter --${name} für ${kommando}. hmgr help ${kommando} zeigt die Schalter.`);
55
93
  const wert = args[++i];
56
94
  if (wert === undefined)
57
- throw new Error(`Schalter --${name} braucht einen Wert`);
95
+ throw new Error(`Schalter --${name} braucht einen Wert, etwa --${name} <wert>. ` +
96
+ `hmgr help ${kommando} zeigt die Schalter.`);
58
97
  werte.set(name, wert);
59
98
  }
60
99
  return { rest, werte, flaggen };
61
100
  }
101
+ /** Ein Wort mehr, als das Kommando liest, ist ein Tippfehler und kein stiller Zusatz. */
102
+ function restPruefen(rest, woerter, kommando, unterkommando) {
103
+ const zuviel = rest[woerter];
104
+ if (zuviel === undefined)
105
+ return;
106
+ const verwendung = unterkommando === undefined ? kommando : `${kommando} ${unterkommando}`;
107
+ // Der Name des Unterkommandos zählt zu den Wörtern, aber nicht zu den Argumenten.
108
+ const argumente = woerter - (unterkommando === undefined ? 0 : 1);
109
+ throw new Error(`${verwendung} erwartet ${argumente === 0 ? 'kein' : 'kein weiteres'} Argument „${zuviel}". ` +
110
+ `hmgr help ${kommando} zeigt die Verwendung.`);
111
+ }
112
+ /**
113
+ * Prüft, was erst mit dem Unterkommando feststeht: liest es die gesetzten Schalter, und steht ein
114
+ * Wort zu viel da. Ein unbekanntes Unterkommando meldet die Familie selbst.
115
+ */
116
+ function unterkommandoPruefen(kommando, schalter) {
117
+ const unterkommando = schalter.rest[0];
118
+ if (unterkommando === undefined)
119
+ return;
120
+ const menge = UNTERKOMMANDOS[kommando][unterkommando];
121
+ if (menge === undefined)
122
+ return;
123
+ const erlaubt = menge.schalter;
124
+ if (erlaubt !== undefined)
125
+ for (const name of [...schalter.werte.keys(), ...schalter.flaggen])
126
+ if (!erlaubt.includes(name))
127
+ throw new Error(`Unbekannter Schalter --${name} für ${kommando} ${unterkommando}. ` +
128
+ `hmgr help ${kommando} zeigt die Schalter.`);
129
+ restPruefen(schalter.rest, menge.woerter, kommando, unterkommando);
130
+ }
62
131
  function ortLesen(text) {
63
132
  const roh = text ?? 'home';
64
133
  if (roh === 'home')
@@ -69,31 +138,6 @@ function ortLesen(text) {
69
138
  return { art: 'profil', name: roh.slice(7) };
70
139
  throw new Error(`Unbekannter Ort „${roh}", erwartet home, dir:<pfad> oder profil:<name>`);
71
140
  }
72
- const HILFE = `hmgr <kommando>
73
-
74
- source add <url|pfad> Quelle hinzufügen (URL wird geklont, Pfad bleibt Arbeitskopie)
75
- source list Quellen zeigen
76
- dir add <pfad> Arbeitsverzeichnis merken und an seinen Zeiger binden
77
- dir forget <pfad> Arbeitsverzeichnis und Bindung vergessen, Links bleiben
78
- dir list Arbeitsverzeichnisse mit ihrer Bindung zeigen
79
- link <skill> Skill in ein Ziel verlinken
80
- unlink <skill> Link entfernen
81
- adopt [name] Fremde Links im Ziel durch eigene hmgr-Links ersetzen
82
- plugin add <name> Plugin in ein Ziel legen (installieren oder Skills verlinken)
83
- plugin remove <name> Plugin aus einem Ziel entfernen
84
- plugin list Plugins der Marketplaces aller Quellen
85
- sync [pfad] Ziel auf den Stand seines Zeigers bringen
86
- update Alle Checkouts aktualisieren
87
- status Ist-Zustand der Ziele
88
-
89
- --harness <name> claude-code, hermes oder agents, Standard claude-code
90
- --ort home|dir:<pfad>|profil:<name> Standard home
91
- --quelle <teilstring> Quelle bei mehrdeutigem Skill-Namen
92
- --version <spanne> Versionseinschränkung nach Composer-Regeln, nur mit Zeiger (E33)
93
- --composer <pfad> composer.json wählen, wenn mehrere in Frage kommen
94
- --local Link nur ins Ziel legen, nicht in den Zeiger
95
- --ersetzen fremden Eintrag am Ziel ersetzen
96
- --probelauf bei adopt nur melden, nichts schreiben`;
97
141
  /** Klartext für den Grund, aus dem ein fremder Eintrag liegen bleibt (B12). */
98
142
  const UEBERNAHME_GRUND = {
99
143
  'kein-link': 'kein Symlink',
@@ -105,18 +149,46 @@ const UEBERNAHME_GRUND = {
105
149
  export async function ausfuehren(argv, umgebung, optionen = {}) {
106
150
  const { anwendungsfaelle, ports } = bauen(umgebung, optionen);
107
151
  const kommando = argv[0];
108
- if (kommando === undefined || kommando === 'help' || kommando === '--help') {
152
+ // Hilfe zuerst: --help ist überall eine Flagge, damit kein Kommando sie als Wert liest.
153
+ if (kommando === undefined) {
109
154
  console.log(HILFE);
110
- return kommando === undefined ? 1 : 0;
155
+ return 1;
156
+ }
157
+ if (kommando === 'help' || kommando === '--help' || kommando === '-h') {
158
+ const thema = argv[1];
159
+ if (thema === undefined || thema.startsWith('-')) {
160
+ console.log(HILFE);
161
+ return 0;
162
+ }
163
+ const text = hilfeText(thema);
164
+ if (text === undefined)
165
+ throw new Error(`Unbekanntes Hilfe-Thema „${thema}". hmgr --help zeigt alle Kommandos.`);
166
+ console.log(text);
167
+ return 0;
168
+ }
169
+ if (argv.slice(1).includes('--help')) {
170
+ const text = hilfeText(kommando);
171
+ if (text === undefined)
172
+ throw new Error(`Unbekanntes Kommando „${kommando}". hmgr --help zeigt alle Kommandos.`);
173
+ console.log(text);
174
+ return 0;
111
175
  }
112
176
  if (kommando === 'source') {
113
- const unterkommando = argv[1];
177
+ const schalter = schalterLesen(argv.slice(1), 'source');
178
+ unterkommandoPruefen('source', schalter);
179
+ const rest = schalter.rest;
180
+ const unterkommando = rest[0];
114
181
  if (unterkommando === 'add') {
115
- const text = argv[2];
182
+ const text = rest[1];
116
183
  if (text === undefined)
117
- throw new Error('source add braucht eine URL oder einen Pfad');
118
- const quelle = quelleAusText(text);
119
- const { skills, marketplace } = await anwendungsfaelle.quelleHinzufuegen(quelle);
184
+ throw new Error('source add braucht eine URL oder einen Pfad, etwa hmgr source add https://example.com/skills.git');
185
+ const genannt = quelleAusText(text);
186
+ const { quelle, skills, marketplace, bekannt } = await anwendungsfaelle.quelleHinzufuegen(genannt);
187
+ // Dieselbe Quelle in anderer Schreibweise legt keine zweite an (E36).
188
+ if (bekannt)
189
+ console.log(quelleAlsText(quelle) === quelleAlsText(genannt)
190
+ ? 'Quelle ist schon bekannt, keine zweite angelegt.'
191
+ : `Quelle ist schon bekannt, geführt als ${quelleAlsText(quelle)}. Keine zweite angelegt.`);
120
192
  console.log(`Quelle ${quelleAlsText(quelle)} (${quelle.art}), ${skills.length} Skills:`);
121
193
  for (const skill of skills)
122
194
  console.log(` ${skill.name} ${skill.pfad}`);
@@ -137,15 +209,17 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
137
209
  console.log(`${quelle.art} ${quelleAlsText(quelle)}`);
138
210
  return 0;
139
211
  }
140
- throw new Error('source add <url|pfad> oder source list');
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.`);
141
213
  }
142
214
  if (kommando === 'dir') {
143
- const { rest, werte } = schalterLesen(argv.slice(1));
215
+ const schalter = schalterLesen(argv.slice(1), 'dir');
216
+ unterkommandoPruefen('dir', schalter);
217
+ const { rest, werte } = schalter;
144
218
  const unterkommando = rest[0];
145
219
  if (unterkommando === 'add') {
146
220
  const pfad = rest[1];
147
221
  if (pfad === undefined)
148
- throw new Error('dir add braucht einen Pfad');
222
+ throw new Error('dir add braucht einen Pfad, etwa hmgr dir add .');
149
223
  const composer = werte.get('composer');
150
224
  const ergebnis = composer === undefined
151
225
  ? await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(pfad)
@@ -160,7 +234,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
160
234
  if (unterkommando === 'forget') {
161
235
  const pfad = rest[1];
162
236
  if (pfad === undefined)
163
- throw new Error('dir forget braucht einen Pfad');
237
+ throw new Error('dir forget braucht einen Pfad. hmgr dir list zeigt die gemerkten Arbeitsverzeichnisse.');
164
238
  await anwendungsfaelle.arbeitsverzeichnisVergessen(pfad);
165
239
  console.log(`Arbeitsverzeichnis ${path.resolve(pfad)} vergessen, die Links bleiben liegen.`);
166
240
  return 0;
@@ -175,13 +249,15 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
175
249
  }
176
250
  return 0;
177
251
  }
178
- throw new Error('dir add <pfad>, dir forget <pfad> oder dir list');
252
+ throw new Error(`${unterkommando === undefined ? 'dir braucht ein Unterkommando' : `Unbekanntes Unterkommando „${unterkommando}" für dir`}: dir add <pfad>, dir forget <pfad> oder dir list. hmgr help dir zeigt Beispiele.`);
179
253
  }
180
254
  if (kommando === 'link' || kommando === 'unlink') {
181
- const { rest, werte, flaggen } = schalterLesen(argv.slice(1));
255
+ const { rest, werte, flaggen } = schalterLesen(argv.slice(1), kommando);
256
+ restPruefen(rest, 1, kommando);
182
257
  const skill = rest[0];
183
258
  if (skill === undefined)
184
- throw new Error(`${kommando} braucht einen Skill-Namen`);
259
+ throw new Error(`${kommando} braucht einen Skill-Namen, etwa hmgr ${kommando} <skill>. ` +
260
+ 'hmgr status zeigt die verlinkten Skills, hmgr source add <url|pfad> die Skills einer Quelle.');
185
261
  const ziel = { harness: werte.get('harness') ?? 'claude-code', ort: ortLesen(werte.get('ort')) };
186
262
  const lokal = flaggen.has('local') || flaggen.has('lokal');
187
263
  if (ziel.ort.art === 'arbeitsverzeichnis') {
@@ -214,7 +290,8 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
214
290
  return 0;
215
291
  }
216
292
  if (kommando === 'adopt') {
217
- const { rest, werte, flaggen } = schalterLesen(argv.slice(1));
293
+ const { rest, werte, flaggen } = schalterLesen(argv.slice(1), 'adopt');
294
+ restPruefen(rest, 1, 'adopt');
218
295
  const ziel = { harness: werte.get('harness') ?? 'claude-code', ort: ortLesen(werte.get('ort')) };
219
296
  const probelauf = flaggen.has('probelauf');
220
297
  const name = rest[0];
@@ -253,7 +330,9 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
253
330
  return 0;
254
331
  }
255
332
  if (kommando === 'plugin') {
256
- const { rest, werte, flaggen } = schalterLesen(argv.slice(1));
333
+ const schalter = schalterLesen(argv.slice(1), 'plugin');
334
+ unterkommandoPruefen('plugin', schalter);
335
+ const { rest, werte, flaggen } = schalter;
257
336
  const unterkommando = rest[0];
258
337
  if (unterkommando === 'list') {
259
338
  const marketplaces = await anwendungsfaelle.marketplaces();
@@ -272,10 +351,10 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
272
351
  return 0;
273
352
  }
274
353
  if (unterkommando !== 'add' && unterkommando !== 'remove')
275
- throw new Error('plugin add <name>, plugin remove <name> oder plugin list');
354
+ throw new Error(`${unterkommando === undefined ? 'plugin braucht ein Unterkommando' : `Unbekanntes Unterkommando „${unterkommando}" für plugin`}: plugin add <name>, plugin remove <name> oder plugin list. hmgr help plugin zeigt Beispiele.`);
276
355
  const name = rest[1];
277
356
  if (name === undefined)
278
- throw new Error(`plugin ${unterkommando} braucht einen Plugin-Namen`);
357
+ throw new Error(`plugin ${unterkommando} braucht einen Plugin-Namen. hmgr plugin list zeigt die Plugins der bekannten Quellen.`);
279
358
  const ziel = { harness: werte.get('harness') ?? 'claude-code', ort: ortLesen(werte.get('ort')) };
280
359
  const lokal = flaggen.has('local') || flaggen.has('lokal');
281
360
  if (ziel.ort.art === 'arbeitsverzeichnis') {
@@ -302,7 +381,8 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
302
381
  return 0;
303
382
  }
304
383
  if (kommando === 'sync') {
305
- const { rest } = schalterLesen(argv.slice(1));
384
+ const { rest } = schalterLesen(argv.slice(1), 'sync');
385
+ restPruefen(rest, 1, 'sync');
306
386
  const ergebnis = await anwendungsfaelle.abgleich(rest[0]);
307
387
  if (ergebnis.zeigerPfad !== undefined)
308
388
  console.log(`Zeiger ${ergebnis.zeigerPfad}`);
@@ -319,7 +399,8 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
319
399
  return 0;
320
400
  }
321
401
  if (kommando === 'update') {
322
- const { checkouts: liste, pluginHarnesses } = await anwendungsfaelle.aktualisieren();
402
+ restPruefen(schalterLesen(argv.slice(1), 'update').rest, 0, 'update');
403
+ const { checkouts: liste, pluginHarnesses, aufraeumen } = await anwendungsfaelle.aktualisieren();
323
404
  if (liste.length === 0)
324
405
  console.log('Keine Checkouts.');
325
406
  for (const { checkout, aktualisierung } of liste) {
@@ -333,9 +414,24 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
333
414
  }
334
415
  for (const name of pluginHarnesses)
335
416
  console.log(` Plugins ${name} aktualisiert (E14)`);
417
+ // Zum Schluss die verwaisten Checkouts: was gelöscht wurde und was liegen bleibt.
418
+ for (const verwaist of aufraeumen.verwaiste) {
419
+ const kopf = `${quelleAlsText(verwaist.checkout.quelle)} @ ${verwaist.checkout.ref}`;
420
+ if (verwaist.geloescht)
421
+ console.log(` gelöscht ${kopf} verwaist (${verwaist.checkout.pfad})`);
422
+ else if (verwaist.lokaleAenderung)
423
+ console.log(` liegen lassen ${kopf} verwaist, aber lokale Änderung (E15)`);
424
+ else
425
+ console.log(` liegen lassen ${kopf} verwaist, ohne Bestätigung nicht gelöscht`);
426
+ }
427
+ if (aufraeumen.verwaiste.length > 0) {
428
+ const geloescht = aufraeumen.verwaiste.filter((v) => v.geloescht).length;
429
+ console.log(`${aufraeumen.verwaiste.length} verwaist, ${geloescht} gelöscht, ${aufraeumen.verwaiste.length - geloescht} liegen geblieben.`);
430
+ }
336
431
  return 0;
337
432
  }
338
433
  if (kommando === 'status') {
434
+ restPruefen(schalterLesen(argv.slice(1), 'status').rest, 0, 'status');
339
435
  for (const zielZustand of await anwendungsfaelle.istZustand()) {
340
436
  console.log(`Ziel ${zielZustand.ziel.harness} ${beschreibeOrt(zielZustand.ziel.ort)} (${zielZustand.verzeichnis})`);
341
437
  if (zielZustand.eintraege.length === 0)
@@ -360,7 +456,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
360
456
  console.log(`Nicht vorhanden: ${fehlend.join(', ')}`);
361
457
  return 0;
362
458
  }
363
- throw new Error(`Unbekanntes Kommando „${kommando}"`);
459
+ throw new Error(`Unbekanntes Kommando „${kommando}". hmgr --help zeigt alle Kommandos.`);
364
460
  }
365
461
  function beschreibeOrt(ort) {
366
462
  if (ort.art === 'home')
@@ -382,13 +478,43 @@ function istModellFehler(fehler) {
382
478
  fehler instanceof PluginUnbekannt ||
383
479
  fehler instanceof AbhaengigkeitFehlt);
384
480
  }
481
+ /**
482
+ * Der nächste Schritt zu einem Fehler aus dem Kern. Die Fehlertexte selbst bleiben, wie das Modell
483
+ * sie schreibt, die Handlungsanweisung gehört an die CLI, weil sie Kommandos nennt.
484
+ */
485
+ function naechsterSchritt(fehler) {
486
+ if (fehler instanceof SkillMehrdeutig)
487
+ return `Quelle eingrenzen: hmgr link ${fehler.name} --quelle <teilstring einer der genannten Quellen>`;
488
+ if (fehler instanceof SkillUnbekannt)
489
+ return 'Erst die Quelle hinzufügen: hmgr source add <url|pfad>. hmgr source list zeigt die bekannten Quellen.';
490
+ if (fehler instanceof NameBelegt)
491
+ return `Entweder mit --ersetzen überschreiben oder den fremden Eintrag mit hmgr adopt ${fehler.skillName} übernehmen.`;
492
+ if (fehler instanceof PluginMehrdeutig)
493
+ return 'Quelle eingrenzen: --quelle <teilstring der URL oder des Pfads>. hmgr plugin list zeigt die Marketplaces.';
494
+ if (fehler instanceof PluginUnbekannt)
495
+ return 'hmgr plugin list zeigt die Plugins der bekannten Quellen. Fehlt der Marketplace, erst hmgr source add <url>.';
496
+ if (fehler instanceof AbhaengigkeitFehlt)
497
+ return 'Die Abhängigkeit steht in keinem Plugin desselben Marketplace, das kann nur die Quelle beheben.';
498
+ if (fehler instanceof KeinPassenderRef)
499
+ return 'Die Tags der Quelle sind frisch geholt, keiner passt. Sonst einen vorhandenen Tag- oder Branchnamen als --version nennen.';
500
+ if (fehler instanceof HarnessNichtVorhanden)
501
+ return 'Der Harness legt sein Verzeichnis beim ersten Start an. hmgr status zeigt, welche Harnesses hier liegen.';
502
+ if (fehler instanceof OrtNichtUnterstuetzt)
503
+ 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.';
507
+ return undefined;
508
+ }
385
509
  export async function main(argv, umgebung) {
386
510
  try {
387
511
  return await ausfuehren(argv, umgebung);
388
512
  }
389
513
  catch (fehler) {
390
514
  const text = fehler instanceof Error ? fehler.message : String(fehler);
391
- console.error(istModellFehler(fehler) ? text : `Fehler: ${text}`);
515
+ const kopf = istModellFehler(fehler) ? text : `Fehler: ${text}`;
516
+ const schritt = naechsterSchritt(fehler);
517
+ console.error(schritt === undefined ? kopf : `${kopf}\n${schritt}`);
392
518
  return 1;
393
519
  }
394
520
  }
@@ -1,18 +1,41 @@
1
1
  // Anwendungsfälle. Pfade werden hier nur als Zeichenketten normalisiert, gelesen wird über die Ports.
2
2
  import path from 'node:path';
3
- import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, HarnessNichtVorhanden, KeinPassenderRef, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, SkillMehrdeutig, SkillUnbekannt, ZeigerMehrdeutig, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleSchluessel, zielSchluessel, } from "./modell.js";
3
+ import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, HarnessNichtVorhanden, KeinPassenderRef, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, SkillMehrdeutig, SkillUnbekannt, ZeigerMehrdeutig, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleAlsText, quelleSchluessel, zielSchluessel, } from "./modell.js";
4
4
  import { passenderRef } from "./version.js";
5
5
  import { kannPlugins, kannProfile } from "../ports/harness.js";
6
+ /**
7
+ * Frage vor dem Löschen, eine für alle Kandidaten (E28). Der Kern formuliert sie fertig, der
8
+ * Adapter stellt sie: Anzahl und Pfade, damit der Nutzer sieht, worüber er entscheidet.
9
+ */
10
+ export function aufraeumFrage(checkouts) {
11
+ const kopf = checkouts.length === 1
12
+ ? 'Ein Checkout ist verwaist, kein Link zeigt hinein:'
13
+ : `${checkouts.length} Checkouts sind verwaist, kein Link zeigt hinein:`;
14
+ const pfade = checkouts.map((c) => ` ${c.pfad}`).join('\n');
15
+ return `${kopf}\n${pfade}\nLöschen? [j/N]`;
16
+ }
6
17
  function arbeitsverzeichnisZiel(harness, pfad) {
7
18
  return { harness, ort: { art: 'arbeitsverzeichnis', pfad } };
8
19
  }
9
20
  function fehlertext(fehler) {
10
21
  return fehler instanceof Error ? fehler.message : String(fehler);
11
22
  }
23
+ /**
24
+ * Quellenhinweis bei Mehrdeutigkeit (E12): Teilstring des Schlüssels oder der Schreibweise. Beides
25
+ * zählt, damit `host/besitzer/repo` genauso greift wie ein Stück der URL, unter der die Quelle
26
+ * geführt wird (E36).
27
+ */
28
+ function quellePasst(quelle, hinweis) {
29
+ if (hinweis === undefined)
30
+ return true;
31
+ return quelleSchluessel(quelle).includes(hinweis) || quelleAlsText(quelle).includes(hinweis);
32
+ }
12
33
  export class Anwendungsfaelle {
13
34
  p;
14
35
  /** Schon beantwortete Orte, je Ziel der Hinweis, der davon übrig blieb (E34). */
15
36
  beantwortet = new Map();
37
+ /** Quellen, deren Refs in diesem Lauf schon nachgezogen wurden (E33). */
38
+ nachgezogen = new Set();
16
39
  constructor(p) {
17
40
  this.p = p;
18
41
  }
@@ -45,18 +68,25 @@ export class Anwendungsfaelle {
45
68
  ergebnis.push(harness.name);
46
69
  return ergebnis;
47
70
  }
48
- /** Quelle hinzufügen: prüft, dass sie erreichbar ist, und liest Skills und Marketplace (E24). */
71
+ /**
72
+ * Quelle hinzufügen: prüft, dass sie erreichbar ist, und liest Skills und Marketplace (E24).
73
+ * Steht dieselbe Quelle schon in einer anderen Schreibweise im Zustand, kommt keine zweite dazu
74
+ * (E36). Gelesen wird dann unter der Schreibweise, unter der sie geführt wird, denn mit ihr ist
75
+ * der Basis-Clone angelegt. `bekannt` sagt, ob es so kam, `quelle` nennt die geführte Fassung.
76
+ */
49
77
  async quelleHinzufuegen(quelle) {
50
- const ref = await this.p.quellen.standardRef(quelle);
51
- const skills = await this.p.quellen.skills(quelle, ref);
52
- const marketplace = await this.p.quellen.marketplace(quelle, ref);
53
78
  const zustand = await this.p.zustand.lesen();
54
79
  const key = quelleSchluessel(quelle);
55
- if (!zustand.quellen.some((q) => quelleSchluessel(q) === key)) {
80
+ const gefuehrt = zustand.quellen.find((q) => quelleSchluessel(q) === key);
81
+ const wirksam = gefuehrt ?? quelle;
82
+ const ref = await this.p.quellen.standardRef(wirksam);
83
+ const skills = await this.p.quellen.skills(wirksam, ref);
84
+ const marketplace = await this.p.quellen.marketplace(wirksam, ref);
85
+ if (gefuehrt === undefined) {
56
86
  zustand.quellen.push(quelle);
57
87
  await this.p.zustand.schreiben(zustand);
58
88
  }
59
- return { skills, marketplace };
89
+ return { quelle: wirksam, skills, marketplace, bekannt: gefuehrt !== undefined };
60
90
  }
61
91
  /** Marketplace einer Quelle am Standard-Ref, mit den Plugins als Modellwerte. */
62
92
  async marketplaceLesen(quelle) {
@@ -95,7 +125,7 @@ export class Anwendungsfaelle {
95
125
  const zustand = await this.p.zustand.lesen();
96
126
  const funde = [];
97
127
  for (const quelle of zustand.quellen) {
98
- if (quelleHinweis && !quelleSchluessel(quelle).includes(quelleHinweis))
128
+ if (!quellePasst(quelle, quelleHinweis))
99
129
  continue;
100
130
  const gelesen = await this.marketplaceLesen(quelle).catch(() => null);
101
131
  if (gelesen === null)
@@ -168,7 +198,7 @@ export class Anwendungsfaelle {
168
198
  if (fund.ergebnis === 'gefunden')
169
199
  zeigerPfad = fund.pfad;
170
200
  else if (fund.ergebnis === 'mehrdeutig')
171
- throw new ZeigerMehrdeutig(verzeichnis, fund.kandidaten);
201
+ zeigerPfad = await this.zeigerWaehlen(verzeichnis, fund.kandidaten);
172
202
  else
173
203
  hinweis = `Kein Zeiger unter ${verzeichnis}, alles bleibt lokal (E26).`;
174
204
  }
@@ -201,6 +231,20 @@ export class Anwendungsfaelle {
201
231
  zustand.bindungen = zustand.bindungen.filter((b) => !(b.ziel.ort.art === 'arbeitsverzeichnis' && b.ziel.ort.pfad === verzeichnis));
202
232
  await this.p.zustand.schreiben(zustand);
203
233
  }
234
+ /**
235
+ * Mehrere composer.json unter einem Verzeichnis (E26): hmgr rät nicht, es fragt. Bleibt die Frage
236
+ * ohne Antwort, weil niemand antworten kann oder die Eingabe keine der Kandidaten trifft, gilt
237
+ * wie bisher der Fehler mit dem Weg über --composer. Der Fragetext kommt von hier, der Port kennt
238
+ * kein Terminal.
239
+ */
240
+ async zeigerWaehlen(verzeichnis, kandidaten) {
241
+ const frage = `Mehrere composer.json unter ${verzeichnis}. Welche ist der Zeiger?`;
242
+ const gewaehlt = await this.p.rueckfrage.auswaehlen(frage, kandidaten);
243
+ const pfad = gewaehlt === undefined ? undefined : kandidaten[gewaehlt];
244
+ if (pfad === undefined)
245
+ throw new ZeigerMehrdeutig(verzeichnis, kandidaten);
246
+ return pfad;
247
+ }
204
248
  /** Zeiger eines Arbeitsverzeichnisses aus dem Zustand, egal für welchen Harness (E26). */
205
249
  async bindungFuerVerzeichnis(verzeichnis) {
206
250
  const zustand = await this.p.zustand.lesen();
@@ -217,7 +261,7 @@ export class Anwendungsfaelle {
217
261
  const zustand = await this.p.zustand.lesen();
218
262
  const funde = [];
219
263
  for (const quelle of zustand.quellen) {
220
- if (quelleHinweis && !quelleSchluessel(quelle).includes(quelleHinweis))
264
+ if (!quellePasst(quelle, quelleHinweis))
221
265
  continue;
222
266
  const ref = await this.p.quellen.standardRef(quelle);
223
267
  const skills = await this.p.quellen.skills(quelle, ref);
@@ -457,6 +501,19 @@ export class Anwendungsfaelle {
457
501
  await this.p.zeiger.schreiben(zeigerPfad, zeiger);
458
502
  return { ...ergebnis, imZeiger: true };
459
503
  }
504
+ /**
505
+ * Refs einer Quelle nachziehen, bevor eine Einschränkung gegen sie ausgewertet wird (E33). Ohne
506
+ * das sähe die Auswertung nur die Tags, die beim Clone schon da waren, ein später entstandener
507
+ * Tag bliebe unsichtbar. Einmal je Lauf und Quelle. Scheitert das Nachziehen, etwa ohne Netz,
508
+ * wird mit dem vorhandenen Stand weitergearbeitet.
509
+ */
510
+ async refsNachziehen(quelle) {
511
+ const schluessel = quelleSchluessel(quelle);
512
+ if (this.nachgezogen.has(schluessel))
513
+ return;
514
+ this.nachgezogen.add(schluessel);
515
+ await this.p.quellen.refsNachziehen(quelle).catch(() => undefined);
516
+ }
460
517
  /**
461
518
  * Fund zu einem Skill aus dem Zeiger (E33). Die Einschränkung wird zuerst nach Composer-Regeln
462
519
  * gegen die Tags der Quellen ausgewertet, der höchste passende Tag gewinnt. Findet sich keiner,
@@ -467,8 +524,9 @@ export class Anwendungsfaelle {
467
524
  if (einschraenkung === null)
468
525
  return await this.skillFinden(name, quelleHinweis);
469
526
  const zustand = await this.p.zustand.lesen();
470
- const quellen = zustand.quellen.filter((q) => quelleHinweis === undefined || quelleSchluessel(q).includes(quelleHinweis));
527
+ const quellen = zustand.quellen.filter((q) => quellePasst(q, quelleHinweis));
471
528
  for (const quelle of quellen) {
529
+ await this.refsNachziehen(quelle);
472
530
  const tags = await this.p.quellen.tags(quelle).catch(() => []);
473
531
  const ref = passenderRef(tags, einschraenkung);
474
532
  if (ref === null)
@@ -502,7 +560,7 @@ export class Anwendungsfaelle {
502
560
  if (fund.ergebnis === 'gefunden')
503
561
  zeigerPfad = fund.pfad;
504
562
  else if (fund.ergebnis === 'mehrdeutig')
505
- throw new ZeigerMehrdeutig(verzeichnis, fund.kandidaten);
563
+ zeigerPfad = await this.zeigerWaehlen(verzeichnis, fund.kandidaten);
506
564
  }
507
565
  if (zeigerPfad === undefined) {
508
566
  ergebnis.hinweise.push(`Kein Zeiger unter ${verzeichnis}, nichts zu tun.`);
@@ -520,7 +578,7 @@ export class Anwendungsfaelle {
520
578
  if (zustand.quellen.some((q) => quelleSchluessel(q) === key))
521
579
  continue;
522
580
  zustand.quellen.push(quelle);
523
- ergebnis.gelegt.push(`Quelle ${key}`);
581
+ ergebnis.gelegt.push(`Quelle ${quelleAlsText(quelle)}`);
524
582
  }
525
583
  if (!zustand.arbeitsverzeichnisse.includes(verzeichnis))
526
584
  zustand.arbeitsverzeichnisse.push(verzeichnis);
@@ -615,7 +673,51 @@ export class Anwendungsfaelle {
615
673
  await harness.plugins.aktualisieren();
616
674
  pluginHarnesses.push(harness.name);
617
675
  }
618
- return { checkouts, pluginHarnesses };
676
+ return { checkouts, pluginHarnesses, aufraeumen: await this.verwaisteAufraeumen(checkouts) };
677
+ }
678
+ /**
679
+ * Pfade, in die ein Link zeigt. Gelesen wird wie beim Ist-Zustand (E19): die Ziele der
680
+ * vorhandenen Harnesses, die Arbeitsverzeichnisse aus dem Zustand. Ein Link, dessen Quelle nicht
681
+ * im Zustand steht, zeigt trotzdem in einen Checkout und hält ihn.
682
+ */
683
+ async belegteCheckoutPfade() {
684
+ const pfade = new Set();
685
+ for (const zielZustand of await this.istZustand())
686
+ for (const eintrag of zielZustand.eintraege) {
687
+ if (eintrag.art === 'link')
688
+ pfade.add(path.resolve(eintrag.link.checkout.pfad));
689
+ else if (eintrag.art === 'link-ohne-quelle')
690
+ pfade.add(path.resolve(eintrag.checkoutPfad));
691
+ }
692
+ return pfade;
693
+ }
694
+ /**
695
+ * Verwaiste Ref-Checkouts aufräumen. Verwaist heißt: kein Link in irgendeinem Ziel zeigt hinein.
696
+ * Der Basis-Clone je Repo ist nie dabei, er steht in keiner Checkout-Liste (E22). Ein Checkout
697
+ * mit lokaler Änderung bleibt liegen und wird nur gemeldet (E15), er kommt gar nicht erst in die
698
+ * Frage. Gefragt wird einmal für alle Kandidaten (E28); wo niemand antwortet, gilt Nein und es
699
+ * wird nichts gelöscht.
700
+ */
701
+ async verwaisteAufraeumen(checkouts) {
702
+ const belegt = await this.belegteCheckoutPfade();
703
+ const verwaiste = [];
704
+ for (const { checkout, aktualisierung } of checkouts) {
705
+ if (belegt.has(path.resolve(checkout.pfad)))
706
+ continue;
707
+ // Übersprungen wird genau der Checkout mit lokaler Änderung, ein zweites Nachfragen erübrigt sich.
708
+ const lokaleAenderung = aktualisierung.ergebnis === 'uebersprungen';
709
+ verwaiste.push({ checkout, lokaleAenderung, geloescht: false });
710
+ }
711
+ const kandidaten = verwaiste.filter((v) => !v.lokaleAenderung);
712
+ if (kandidaten.length === 0)
713
+ return { verwaiste, gefragt: false };
714
+ if (!(await this.p.rueckfrage.bestaetigen(aufraeumFrage(kandidaten.map((k) => k.checkout)))))
715
+ return { verwaiste, gefragt: true };
716
+ for (const kandidat of kandidaten) {
717
+ await this.p.quellen.entfernen(kandidat.checkout);
718
+ kandidat.geloescht = true;
719
+ }
720
+ return { verwaiste, gefragt: true };
619
721
  }
620
722
  /**
621
723
  * Übernahme (B12, E31): fremde Symlinks eines Ziels durch eigene Links auf einen hmgr-Checkout
@@ -633,7 +735,10 @@ export class Anwendungsfaelle {
633
735
  throw new OrtNichtUnterstuetzt(harness.name, args.ziel.ort.art);
634
736
  const probelauf = args.probelauf === true;
635
737
  const verzeichnis = harness.zielVerzeichnis(args.ziel.ort);
636
- const bekannt = new Set((await this.p.zustand.lesen()).quellen.map(quelleSchluessel));
738
+ // Je Identität die Quelle, unter deren Schreibweise sie geführt wird (E36).
739
+ const bekannt = new Map();
740
+ for (const q of (await this.p.zustand.lesen()).quellen)
741
+ bekannt.set(quelleSchluessel(q), q);
637
742
  const eintraege = [];
638
743
  for (const eintrag of await this.p.ziele.eintraege(args.ziel, verzeichnis)) {
639
744
  // Eigene Links sind schon fertig, sie kommen nicht in die Meldung.
@@ -661,7 +766,9 @@ export class Anwendungsfaelle {
661
766
  liegenLassen('kein-branch');
662
767
  continue;
663
768
  }
664
- const quelle = { art: 'git', url: kopie.url };
769
+ // Kennt hmgr das Repo schon in anderer Schreibweise, zählt die geführte Fassung (E36).
770
+ const gefuehrt = bekannt.get(quelleSchluessel({ art: 'git', url: kopie.url }));
771
+ const quelle = gefuehrt ?? { art: 'git', url: kopie.url };
665
772
  const ref = kopie.ref;
666
773
  const skills = await this.p.quellen.skills(quelle, ref);
667
774
  const skill = skills.find((s) => s.pfad === kopie.relativerPfad);
@@ -669,8 +776,8 @@ export class Anwendungsfaelle {
669
776
  liegenLassen('skill-unbekannt');
670
777
  continue;
671
778
  }
672
- const quelleNeu = !bekannt.has(quelleSchluessel(quelle));
673
- bekannt.add(quelleSchluessel(quelle));
779
+ const quelleNeu = gefuehrt === undefined;
780
+ bekannt.set(quelleSchluessel(quelle), quelle);
674
781
  if (!probelauf) {
675
782
  if (quelleNeu)
676
783
  await this.quelleHinzufuegen(quelle);
@@ -1,4 +1,5 @@
1
1
  // Begriffe aus CONTEXT.md. Nur Werte und Regeln, keine Pfade zu Dateien außerhalb des Modells.
2
+ import { ortSchluessel, urlOrt } from "./quelle-ort.js";
2
3
  /** Ansprache eines Plugins im Zeiger und gegenüber der claude-CLI: `<plugin>@<marketplace>`. */
3
4
  export function pluginSchluessel(plugin) {
4
5
  return `${plugin.name}@${plugin.marketplace}`;
@@ -45,8 +46,22 @@ export function zielSchluessel(ziel) {
45
46
  const ort = o.art === 'home' ? 'home' : o.art === 'profil' ? `profil:${o.name}` : `dir:${o.pfad}`;
46
47
  return `${ziel.harness}/${ort}`;
47
48
  }
49
+ /** Wie eine Quelle geschrieben steht: ihre URL oder ihr Pfad. Für Ausgaben, nicht für Vergleiche. */
50
+ export function quelleAlsText(quelle) {
51
+ return quelle.art === 'git' ? quelle.url : quelle.pfad;
52
+ }
53
+ /**
54
+ * Identität einer Quelle (E36): bei einer Git-Quelle Host, Besitzer und Repo, nicht die
55
+ * Schreibweise der URL. SSH- und HTTPS-Form desselben Repos sind dieselbe Quelle, mit und ohne
56
+ * `.git`-Suffix ebenso, und der Host zählt ohne Rücksicht auf Groß- und Kleinschreibung. Ein Text,
57
+ * der sich nicht als URL lesen lässt, bleibt sein eigener Schlüssel. Lokale Quellen bleiben ihr
58
+ * Pfad (B11).
59
+ */
48
60
  export function quelleSchluessel(quelle) {
49
- return quelle.art === 'git' ? quelle.url : `lokal:${quelle.pfad}`;
61
+ if (quelle.art === 'lokal')
62
+ return `lokal:${quelle.pfad}`;
63
+ const ort = urlOrt(quelle.url);
64
+ return ort === undefined ? quelle.url : ortSchluessel(ort);
50
65
  }
51
66
  export class NameBelegt extends Error {
52
67
  ziel;
@@ -0,0 +1,42 @@
1
+ // Identität einer Git-Quelle (E36): Host, Besitzer und Repo, nicht die Schreibweise der URL.
2
+ function ohneGitSuffix(name) {
3
+ return name.endsWith('.git') ? name.slice(0, -4) : name;
4
+ }
5
+ /**
6
+ * Die letzten beiden nicht leeren Segmente sind Besitzer und Repo. Der Host wird kleingeschrieben,
7
+ * Hostnamen sind case-insensitiv; der Pfadanteil bleibt, wie er geschrieben steht (E36).
8
+ */
9
+ export function ortAusSegmenten(host, segmente) {
10
+ const gefiltert = segmente.filter((s) => s.length > 0);
11
+ const repo = ohneGitSuffix(gefiltert[gefiltert.length - 1] ?? 'repo');
12
+ const besitzer = gefiltert[gefiltert.length - 2] ?? '_';
13
+ return { host: host.toLowerCase(), besitzer, repo };
14
+ }
15
+ /**
16
+ * Zerlegt eine Git-URL: `git@host:besitzer/repo.git`, `ssh://git@host/besitzer/repo`,
17
+ * `https://host/besitzer/repo.git`, `file:///pfad`. Ein Text, der keine URL ist, gibt undefined.
18
+ */
19
+ export function urlOrt(url) {
20
+ const text = url.trim().replace(/\/+$/, '');
21
+ if (text.includes('://')) {
22
+ let zerlegt;
23
+ try {
24
+ zerlegt = new URL(text);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ const segmente = decodeURIComponent(zerlegt.pathname).split('/');
30
+ // file: hat keinen Host, alle lokalen Quellen liegen unter „local".
31
+ if (zerlegt.protocol === 'file:')
32
+ return ortAusSegmenten('local', segmente);
33
+ return ortAusSegmenten(zerlegt.hostname, segmente);
34
+ }
35
+ const scp = /^(?:[^@/]+@)?([^@/:]+):(.+)$/.exec(text);
36
+ if (scp)
37
+ return ortAusSegmenten(scp[1], scp[2].split('/'));
38
+ return undefined;
39
+ }
40
+ export function ortSchluessel(ort) {
41
+ return `${ort.host}/${ort.besitzer}/${ort.repo}`;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmgr",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",