hmgr 0.0.1

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.
@@ -0,0 +1,339 @@
1
+ // Git über die CLI, keine Bibliothek. Checkout-Schema und Worktrees nach E22, Discovery nach E11.
2
+ import { execFile, spawn } from 'node:child_process';
3
+ import { readFile, readdir, mkdir, realpath, stat } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { marketplaceLesen } from "./marketplace-json.js";
7
+ import { SKILL_DATEI, skillAusPfad } from "./skill-md.js";
8
+ import { BASIS_NAME, basisPfad, quelleZerlegen, refDekodieren, worktreePfad } from "./checkout-pfad.js";
9
+ const ausfuehren = promisify(execFile);
10
+ /** Bekannte Orte für Skills, in dieser Reihenfolge (E11). */
11
+ export const BEKANNTE_ORTE = ['skills', '.claude/skills', '.agents/skills'];
12
+ /** Eine lokale Arbeitskopie hat für hmgr keinen Ref, es zählt der Stand auf der Platte (B11). */
13
+ export const LOKAL_REF = 'lokal';
14
+ /** Pfad der Marketplace-Datei in einer Quelle (E24). */
15
+ export const MARKETPLACE_DATEI = '.claude-plugin/marketplace.json';
16
+ /**
17
+ * Muster, die in jedem Worktree gelten (E11): alle Dateien direkt an der Wurzel und das
18
+ * Verzeichnis .claude-plugin. Das zweite Muster nimmt die Wurzelverzeichnisse wieder heraus,
19
+ * die gewählten Skills kommen danach einzeln zurück.
20
+ */
21
+ export const WURZEL_MUSTER = ['/*', '!/*/', `/${MARKETPLACE_DATEI.split('/')[0]}/`];
22
+ async function existiert(pfad) {
23
+ try {
24
+ await stat(pfad);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /**
32
+ * Aus Dateipfaden die Skill-Verzeichnisse bilden: ein Verzeichnis mit SKILL.md ist ein Skill.
33
+ * Bekannte Orte zuerst (E11). Den Namen bekommt der Skill erst aus seiner SKILL.md (E32).
34
+ */
35
+ export function skillPfadeAusDateien(dateien) {
36
+ const verzeichnisse = new Set();
37
+ for (const datei of dateien) {
38
+ const teile = datei.split('/');
39
+ if (teile.at(-1) !== SKILL_DATEI || teile.length < 2)
40
+ continue;
41
+ verzeichnisse.add(teile.slice(0, -1).join('/'));
42
+ }
43
+ const sortiert = [...verzeichnisse].sort();
44
+ const genommen = new Set();
45
+ const ergebnis = [];
46
+ for (const ort of BEKANNTE_ORTE)
47
+ for (const verzeichnis of sortiert)
48
+ if (verzeichnis.startsWith(`${ort}/`) && verzeichnis.slice(ort.length + 1).indexOf('/') === -1) {
49
+ genommen.add(verzeichnis);
50
+ ergebnis.push(verzeichnis);
51
+ }
52
+ for (const verzeichnis of sortiert)
53
+ if (!genommen.has(verzeichnis))
54
+ ergebnis.push(verzeichnis);
55
+ return ergebnis;
56
+ }
57
+ /**
58
+ * Ausgabe von `git cat-file --batch` zerlegen: je Eingabezeile ein Kopf `<sha> <typ> <größe>`,
59
+ * danach `<größe>` Bytes Inhalt und ein Zeilenumbruch. Fehlt das Objekt, steht statt des Kopfes
60
+ * `<eingabe> missing`, dann folgt kein Inhalt. Die Größe zählt Bytes, deshalb wird auf dem Buffer
61
+ * gerechnet und erst der einzelne Inhalt als UTF-8 gelesen.
62
+ */
63
+ export function batchInhalte(ausgabe, schluessel) {
64
+ const ergebnis = new Map();
65
+ let pos = 0;
66
+ for (const eintrag of schluessel) {
67
+ const zeilenende = ausgabe.indexOf(10, pos);
68
+ if (zeilenende === -1)
69
+ break;
70
+ const kopf = ausgabe.toString('utf8', pos, zeilenende);
71
+ pos = zeilenende + 1;
72
+ const trenner = kopf.lastIndexOf(' ');
73
+ const groesse = trenner === -1 ? Number.NaN : Number(kopf.slice(trenner + 1));
74
+ if (!Number.isInteger(groesse) || groesse < 0)
75
+ continue;
76
+ ergebnis.set(eintrag, ausgabe.toString('utf8', pos, pos + groesse));
77
+ pos += groesse + 1;
78
+ }
79
+ return ergebnis;
80
+ }
81
+ /** Namen der Unterverzeichnisse, sortiert. Fehlt das Verzeichnis, ist die Liste leer. */
82
+ async function verzeichnisse(pfad) {
83
+ const eintraege = await readdir(pfad, { withFileTypes: true }).catch(() => []);
84
+ return eintraege
85
+ .filter((e) => e.isDirectory())
86
+ .map((e) => e.name)
87
+ .sort();
88
+ }
89
+ /** SKILL.md-Inhalte einer lokalen Quelle. Was nicht lesbar ist, fehlt in der Karte. */
90
+ async function inhalteAufPlatte(wurzel, pfade) {
91
+ const ergebnis = new Map();
92
+ for (const pfad of pfade) {
93
+ const roh = await readFile(path.join(wurzel, pfad, SKILL_DATEI), 'utf8').catch(() => undefined);
94
+ if (roh !== undefined)
95
+ ergebnis.set(pfad, roh);
96
+ }
97
+ return ergebnis;
98
+ }
99
+ async function dateienAufPlatte(wurzel, unter = '') {
100
+ const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
101
+ const dateien = [];
102
+ for (const eintrag of eintraege) {
103
+ if (eintrag.name === '.git' || eintrag.name === 'node_modules' || eintrag.name === 'vendor')
104
+ continue;
105
+ const rel = unter ? `${unter}/${eintrag.name}` : eintrag.name;
106
+ if (eintrag.isDirectory())
107
+ dateien.push(...(await dateienAufPlatte(wurzel, rel)));
108
+ else
109
+ dateien.push(rel);
110
+ }
111
+ return dateien;
112
+ }
113
+ export class GitQuellen {
114
+ wurzel;
115
+ constructor(wurzel) {
116
+ this.wurzel = wurzel;
117
+ }
118
+ async git(args, cwd) {
119
+ const { stdout } = await ausfuehren('git', args, { cwd, maxBuffer: 32 * 1024 * 1024 });
120
+ return stdout;
121
+ }
122
+ /** Wie `git`, aber mit Text auf stdin und Bytes als Ergebnis: `execFile` kennt keine Eingabe. */
123
+ async gitMitEingabe(args, cwd, eingabe) {
124
+ return await new Promise((erfuellen, ablehnen) => {
125
+ const kind = spawn('git', args, { cwd });
126
+ const stuecke = [];
127
+ const fehler = [];
128
+ kind.stdout.on('data', (teil) => stuecke.push(teil));
129
+ kind.stderr.on('data', (teil) => fehler.push(teil));
130
+ kind.on('error', ablehnen);
131
+ kind.on('close', (code) => {
132
+ if (code === 0)
133
+ erfuellen(Buffer.concat(stuecke));
134
+ else
135
+ ablehnen(new Error(`git ${args.join(' ')}: ${Buffer.concat(fehler).toString('utf8')}`));
136
+ });
137
+ kind.stdin.on('error', ablehnen);
138
+ kind.stdin.end(eingabe);
139
+ });
140
+ }
141
+ /** Basis-Clone je Repo: bare, ohne Blobs (E11, E22). */
142
+ async basis(quelle) {
143
+ if (quelle.art !== 'git')
144
+ throw new Error('Lokale Quellen haben keinen Basis-Clone');
145
+ const pfad = basisPfad(this.wurzel, quelleZerlegen(quelle));
146
+ if (await existiert(pfad))
147
+ return pfad;
148
+ await mkdir(path.dirname(pfad), { recursive: true });
149
+ await this.git(['clone', '--bare', '--filter=blob:none', quelle.url, pfad], path.dirname(pfad));
150
+ // Ein bare Clone bekommt keine Fetch-Refspec, ohne sie holt „fetch" nichts.
151
+ await this.git(['config', 'remote.origin.fetch', '+refs/heads/*:refs/heads/*'], pfad);
152
+ return pfad;
153
+ }
154
+ /**
155
+ * Inhalte der SKILL.md aller genannten Skills in einem Aufruf. `git cat-file --batch` holt beim
156
+ * Partial Clone (`--filter=blob:none`) die fehlenden Blobs gebündelt, ein `git show` je Skill
157
+ * würde je Skill einen Fetch auslösen. Ein Skill ohne lesbare SKILL.md fehlt in der Karte.
158
+ */
159
+ async skillMdInhalte(basis, ref, pfade) {
160
+ if (pfade.length === 0)
161
+ return new Map();
162
+ const eingabe = `${pfade.map((p) => `${ref}:${p}/${SKILL_DATEI}`).join('\n')}\n`;
163
+ const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
164
+ if (ausgabe === undefined)
165
+ return new Map();
166
+ return batchInhalte(ausgabe, pfade);
167
+ }
168
+ /** Skills der Quelle, Namen aus dem Frontmatter der SKILL.md (E32). */
169
+ async skills(quelle, ref) {
170
+ if (quelle.art === 'lokal') {
171
+ const pfade = skillPfadeAusDateien(await dateienAufPlatte(quelle.pfad));
172
+ const inhalte = await inhalteAufPlatte(quelle.pfad, pfade);
173
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
174
+ }
175
+ const basis = await this.basis(quelle);
176
+ const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
177
+ const pfade = skillPfadeAusDateien(ausgabe.split('\n').filter((z) => z.length > 0));
178
+ const inhalte = await this.skillMdInhalte(basis, ref, pfade);
179
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad)));
180
+ }
181
+ /**
182
+ * Marketplace einer Quelle (E24). Der Inhalt kommt aus dem Git-Baum, nicht aus einem Checkout:
183
+ * bei einem Clone mit `blob:none` holt `git show` den Blob nach.
184
+ */
185
+ async marketplace(quelle, ref) {
186
+ let roh;
187
+ let dateien;
188
+ if (quelle.art === 'lokal') {
189
+ roh = await readFile(path.join(quelle.pfad, MARKETPLACE_DATEI), 'utf8').catch(() => undefined);
190
+ dateien = await dateienAufPlatte(quelle.pfad);
191
+ }
192
+ else {
193
+ const basis = await this.basis(quelle);
194
+ roh = await this.git(['show', `${ref}:${MARKETPLACE_DATEI}`], basis).catch(() => undefined);
195
+ dateien = (await this.git(['ls-tree', '-r', '--name-only', ref], basis)).split('\n').filter((z) => z.length > 0);
196
+ }
197
+ if (roh === undefined)
198
+ return null;
199
+ const gelesen = marketplaceLesen(roh, dateien);
200
+ const pfade = [...new Set(gelesen.plugins.flatMap((p) => p.skills))];
201
+ const inhalte = quelle.art === 'lokal'
202
+ ? await inhalteAufPlatte(quelle.pfad, pfade)
203
+ : await this.skillMdInhalte(await this.basis(quelle), ref, pfade);
204
+ return {
205
+ name: gelesen.name,
206
+ plugins: gelesen.plugins.map((p) => ({
207
+ name: p.name,
208
+ dependencies: p.dependencies,
209
+ skills: p.skills.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad))),
210
+ })),
211
+ };
212
+ }
213
+ async refs(quelle) {
214
+ if (quelle.art === 'lokal')
215
+ return [LOKAL_REF];
216
+ const basis = await this.basis(quelle);
217
+ const ausgabe = await this.git(['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/tags'], basis);
218
+ return ausgabe.split('\n').filter((z) => z.length > 0);
219
+ }
220
+ /** Nur die Tags: eine Versionseinschränkung wählt nie einen Branch (E33). */
221
+ async tags(quelle) {
222
+ if (quelle.art === 'lokal')
223
+ return [];
224
+ const basis = await this.basis(quelle);
225
+ const ausgabe = await this.git(['for-each-ref', '--format=%(refname:short)', 'refs/tags'], basis);
226
+ return ausgabe.split('\n').filter((z) => z.length > 0);
227
+ }
228
+ async standardRef(quelle) {
229
+ if (quelle.art === 'lokal')
230
+ return LOKAL_REF;
231
+ const basis = await this.basis(quelle);
232
+ try {
233
+ return (await this.git(['symbolic-ref', '--short', 'HEAD'], basis)).trim();
234
+ }
235
+ catch {
236
+ return (await this.git(['rev-parse', 'HEAD'], basis)).trim();
237
+ }
238
+ }
239
+ async checkout(quelle, ref, skills) {
240
+ // Lokale Quellen werden nicht geklont, der Pfad selbst ist der Checkout (B11).
241
+ if (quelle.art === 'lokal')
242
+ return { quelle, ref, pfad: quelle.pfad };
243
+ const basis = await this.basis(quelle);
244
+ const pfad = worktreePfad(this.wurzel, quelleZerlegen(quelle), ref);
245
+ const neu = !(await existiert(pfad));
246
+ if (neu) {
247
+ await mkdir(path.dirname(pfad), { recursive: true });
248
+ await this.git(['worktree', 'add', '--detach', '--no-checkout', pfad, ref], basis);
249
+ await this.git(['sparse-checkout', 'init', '--no-cone'], pfad);
250
+ }
251
+ const vorhanden = neu
252
+ ? []
253
+ : (await this.git(['sparse-checkout', 'list'], pfad)).split('\n').filter((z) => z.length > 0);
254
+ // Wurzelmuster stehen immer vorn, danach die Skills. Das letzte passende Muster gewinnt.
255
+ const muster = new Set(WURZEL_MUSTER);
256
+ for (const eintrag of vorhanden)
257
+ if (!WURZEL_MUSTER.includes(eintrag))
258
+ muster.add(eintrag);
259
+ for (const skill of skills)
260
+ muster.add(`/${skill.pfad}/`);
261
+ await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
262
+ if (neu)
263
+ await this.git(['checkout', '--detach', ref], pfad);
264
+ return { quelle, ref, pfad };
265
+ }
266
+ /**
267
+ * Alle Checkouts unter der Wurzel, aus dem Dateisystem gelesen (E19). Die Quelle kommt aus der
268
+ * Remote-URL des Basis-Clones, sie wird nicht aus dem Pfad erraten (E22).
269
+ */
270
+ async checkouts() {
271
+ const ergebnis = [];
272
+ for (const host of await verzeichnisse(this.wurzel))
273
+ for (const besitzer of await verzeichnisse(path.join(this.wurzel, host)))
274
+ for (const repo of await verzeichnisse(path.join(this.wurzel, host, besitzer))) {
275
+ const repoPfad = path.join(this.wurzel, host, besitzer, repo);
276
+ const basis = path.join(repoPfad, BASIS_NAME);
277
+ if (!(await existiert(basis)))
278
+ continue;
279
+ const url = (await this.git(['config', '--get', 'remote.origin.url'], basis).catch(() => '')).trim();
280
+ if (url.length === 0)
281
+ continue;
282
+ for (const refTeil of await verzeichnisse(repoPfad)) {
283
+ if (refTeil === BASIS_NAME)
284
+ continue;
285
+ ergebnis.push({
286
+ quelle: { art: 'git', url },
287
+ ref: refDekodieren(refTeil),
288
+ pfad: path.join(repoPfad, refTeil),
289
+ });
290
+ }
291
+ }
292
+ return ergebnis;
293
+ }
294
+ async aktualisieren(checkout) {
295
+ if (checkout.quelle.art === 'lokal')
296
+ return { ergebnis: 'unveraendert' };
297
+ if (await this.lokaleAenderungen(checkout))
298
+ return { ergebnis: 'uebersprungen', grund: 'lokale-aenderung' }; // E15
299
+ const basis = await this.basis(checkout.quelle);
300
+ const von = (await this.git(['rev-parse', 'HEAD'], checkout.pfad)).trim();
301
+ await this.git(['fetch', '--prune', '--tags', 'origin'], basis);
302
+ const nach = (await this.git(['rev-parse', checkout.ref], basis)).trim();
303
+ if (von === nach)
304
+ return { ergebnis: 'unveraendert' };
305
+ await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
306
+ return { ergebnis: 'aktualisiert', von, nach };
307
+ }
308
+ /**
309
+ * Arbeitskopie um einen Pfad herum (B12). Die Wurzel kommt aus `rev-parse --show-toplevel`, vom
310
+ * Verzeichnis des Pfads aus gefragt. Beide Pfade werden über realpath aufgelöst, sonst passen
311
+ * ein Linkziel unter /var und eine Wurzel unter /private/var nicht zusammen.
312
+ */
313
+ async arbeitskopie(pfad) {
314
+ const echt = await realpath(pfad).catch(() => undefined);
315
+ if (echt === undefined)
316
+ return null;
317
+ const info = await stat(echt).catch(() => undefined);
318
+ const von = info?.isDirectory() === true ? echt : path.dirname(echt);
319
+ const roh = await this.git(['rev-parse', '--show-toplevel'], von).catch(() => undefined);
320
+ if (roh === undefined || roh.trim().length === 0)
321
+ return null;
322
+ const wurzel = await realpath(roh.trim()).catch(() => roh.trim());
323
+ const url = (await this.git(['config', '--get', 'remote.origin.url'], wurzel).catch(() => '')).trim();
324
+ // `--abbrev-ref HEAD` liefert bei detached HEAD den Namen „HEAD", das ist kein Branch.
325
+ const zweig = (await this.git(['rev-parse', '--abbrev-ref', 'HEAD'], wurzel).catch(() => '')).trim();
326
+ return {
327
+ wurzel,
328
+ url: url.length === 0 ? null : url,
329
+ ref: zweig.length === 0 || zweig === 'HEAD' ? null : zweig,
330
+ relativerPfad: path.relative(wurzel, echt).split(path.sep).join('/'),
331
+ };
332
+ }
333
+ async lokaleAenderungen(checkout) {
334
+ if (checkout.quelle.art === 'lokal')
335
+ return false;
336
+ const ausgabe = await this.git(['status', '--porcelain'], checkout.pfad);
337
+ return ausgabe.trim().length > 0;
338
+ }
339
+ }
@@ -0,0 +1,16 @@
1
+ // Verzeichnisse eines Harness dürfen nicht ins Repo eines Arbeitsverzeichnisses (B7).
2
+ import { readFile, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ export async function istGitRepo(verzeichnis) {
5
+ return (await stat(path.join(verzeichnis, '.git')).catch(() => undefined)) !== undefined;
6
+ }
7
+ /** Trägt `zeile` (etwa „.claude/") in die .gitignore ein, falls sie fehlt. Idempotent. */
8
+ export async function gitignoreEintragen(verzeichnis, zeile) {
9
+ const pfad = path.join(verzeichnis, '.gitignore');
10
+ const ohneSlash = zeile.endsWith('/') ? zeile.slice(0, -1) : zeile;
11
+ const inhalt = await readFile(pfad, 'utf8').catch(() => '');
12
+ if (inhalt.split('\n').some((z) => z.trim() === zeile || z.trim() === ohneSlash))
13
+ return;
14
+ const getrennt = inhalt.length === 0 || inhalt.endsWith('\n') ? inhalt : `${inhalt}\n`;
15
+ await writeFile(pfad, `${getrennt}${zeile}\n`);
16
+ }
@@ -0,0 +1,142 @@
1
+ // Hermes Agent: Grundverhalten plus Profile entdecken und Verzeichnis in der config.yaml registrieren.
2
+ // Skills landen nie in <hermes-home>/skills, sondern in einem Ordner aus skills.external_dirs (E13).
3
+ import { readFileSync } from 'node:fs';
4
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { Document, isSeq, parseDocument } from 'yaml';
7
+ import { OrtNichtUnterstuetzt } from "../kern/modell.js";
8
+ import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
9
+ const HERMES_VERZEICHNIS = '.hermes';
10
+ const PROFILE_VERZEICHNIS = 'profiles';
11
+ const CONFIG = 'config.yaml';
12
+ const STANDARD_ORDNER = 'external-skills';
13
+ /** Von Hermes selbst verwalteter Ordner, in den hmgr nichts legt (E13). */
14
+ const EIGENER_ORDNER = 'skills';
15
+ const GITIGNORE_ZEILE = '.hermes/';
16
+ function unterhalb(pfad, basis) {
17
+ const rel = path.relative(basis, pfad);
18
+ return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel);
19
+ }
20
+ /** Liest skills.external_dirs aus einer geparsten config.yaml. Fehlt etwas, ist die Liste leer. */
21
+ function externalDirs(daten) {
22
+ if (typeof daten !== 'object' || daten === null)
23
+ return [];
24
+ const skills = daten['skills'];
25
+ if (typeof skills !== 'object' || skills === null)
26
+ return [];
27
+ const dirs = skills['external_dirs'];
28
+ if (!Array.isArray(dirs))
29
+ return [];
30
+ return dirs.filter((d) => typeof d === 'string');
31
+ }
32
+ export class Hermes {
33
+ name = 'hermes';
34
+ home;
35
+ /** `home` ist das HOME des Nutzers, das Hermes-Home liegt darunter als `.hermes`. */
36
+ constructor(home) {
37
+ this.home = home;
38
+ }
39
+ /** Hermes liegt auf dem Rechner, wenn es sein Home angelegt hat. */
40
+ async vorhanden() {
41
+ return (await stat(path.join(this.home, HERMES_VERZEICHNIS)).catch(() => undefined)) !== undefined;
42
+ }
43
+ orte() {
44
+ return ['home', 'profil', 'arbeitsverzeichnis'];
45
+ }
46
+ /** Home und Profil sind dasselbe Konzept: ein vollständiges Hermes-Home (E13). */
47
+ hermesHome(ort) {
48
+ const basis = path.join(this.home, HERMES_VERZEICHNIS);
49
+ if (ort.art === 'home')
50
+ return basis;
51
+ if (ort.art === 'profil')
52
+ return path.join(basis, PROFILE_VERZEICHNIS, ort.name);
53
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
54
+ }
55
+ /** Tilde gegen HOME, relative Pfade gegen das Hermes-Home. Variablen löst hmgr nicht auf. */
56
+ aufloesen(eintrag, hermesHome) {
57
+ if (eintrag.includes('$'))
58
+ return undefined;
59
+ if (eintrag === '~')
60
+ return this.home;
61
+ if (eintrag.startsWith('~/'))
62
+ return path.join(this.home, eintrag.slice(2));
63
+ return path.resolve(hermesHome, eintrag);
64
+ }
65
+ /** Schreibweise für die config.yaml: `~/…`, solange der Ordner unter HOME liegt. */
66
+ alsEintrag(verzeichnis) {
67
+ if (!unterhalb(verzeichnis, this.home))
68
+ return verzeichnis;
69
+ return `~/${path.relative(this.home, verzeichnis).split(path.sep).join('/')}`;
70
+ }
71
+ gelesen(hermesHome) {
72
+ const pfad = path.join(hermesHome, CONFIG);
73
+ let text;
74
+ try {
75
+ text = readFileSync(pfad, 'utf8');
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ return parseDocument(text).toJS();
81
+ }
82
+ zielVerzeichnis(ort) {
83
+ if (ort.art === 'arbeitsverzeichnis')
84
+ return path.join(ort.pfad, HERMES_VERZEICHNIS, EIGENER_ORDNER);
85
+ const hermesHome = this.hermesHome(ort);
86
+ const eigenerOrdner = path.join(hermesHome, EIGENER_ORDNER);
87
+ for (const eintrag of externalDirs(this.gelesen(hermesHome))) {
88
+ const pfad = this.aufloesen(eintrag, hermesHome);
89
+ if (pfad === undefined || !unterhalb(pfad, hermesHome))
90
+ continue;
91
+ if (pfad === eigenerOrdner || unterhalb(pfad, eigenerOrdner))
92
+ continue;
93
+ return pfad;
94
+ }
95
+ return path.join(hermesHome, STANDARD_ORDNER);
96
+ }
97
+ async vorbereiten(ort) {
98
+ const verzeichnis = this.zielVerzeichnis(ort);
99
+ await mkdir(verzeichnis, { recursive: true });
100
+ if (ort.art === 'arbeitsverzeichnis') {
101
+ // .hermes/ darf nicht ins Repo (B7).
102
+ if (await istGitRepo(ort.pfad))
103
+ await gitignoreEintragen(ort.pfad, GITIGNORE_ZEILE);
104
+ return;
105
+ }
106
+ await this.registrieren(this.hermesHome(ort), verzeichnis);
107
+ }
108
+ /** Trägt das Verzeichnis in skills.external_dirs ein, falls es fehlt. Kommentare bleiben erhalten. */
109
+ async registrieren(hermesHome, verzeichnis) {
110
+ const pfad = path.join(hermesHome, CONFIG);
111
+ const text = await readFile(pfad, 'utf8').catch(() => undefined);
112
+ const doc = text === undefined ? new Document({ skills: { external_dirs: [] } }) : parseDocument(text);
113
+ const vorhanden = externalDirs(doc.toJS()).some((e) => this.aufloesen(e, hermesHome) === verzeichnis);
114
+ if (vorhanden)
115
+ return;
116
+ const eintrag = this.alsEintrag(verzeichnis);
117
+ const liste = doc.getIn(['skills', 'external_dirs']);
118
+ if (isSeq(liste))
119
+ liste.add(eintrag);
120
+ else
121
+ doc.setIn(['skills', 'external_dirs'], [eintrag]);
122
+ await mkdir(hermesHome, { recursive: true });
123
+ await writeFile(pfad, doc.toString());
124
+ }
125
+ folgtLinks() {
126
+ return true;
127
+ }
128
+ /** Fähigkeit Profile entdecken: Verzeichnisse mit config.yaml, bei jedem Aufruf frisch gelesen (E19). */
129
+ async profile() {
130
+ const basis = path.join(this.home, HERMES_VERZEICHNIS, PROFILE_VERZEICHNIS);
131
+ const eintraege = await readdir(basis, { withFileTypes: true }).catch(() => []);
132
+ const namen = [];
133
+ for (const eintrag of eintraege) {
134
+ if (!eintrag.isDirectory())
135
+ continue;
136
+ const config = await stat(path.join(basis, eintrag.name, CONFIG)).catch(() => undefined);
137
+ if (config?.isFile())
138
+ namen.push(eintrag.name);
139
+ }
140
+ return namen.sort();
141
+ }
142
+ }
@@ -0,0 +1,98 @@
1
+ // .claude-plugin/marketplace.json lesen (E24, HARNESS-FAKTEN „Plugins und Marketplaces").
2
+ // Nur die beiden dokumentierten Formen, alles andere wird abgelehnt statt geraten.
3
+ /** Standardordner für die Skills eines Plugins, relativ zu seiner Quelle. */
4
+ const SKILL_ORDNER = 'skills';
5
+ export class MarketplaceUnbekannteForm extends Error {
6
+ constructor(text) {
7
+ super(`marketplace.json: ${text}`);
8
+ }
9
+ }
10
+ /** „./skills/a" und „skills/a/" werden zu „skills/a", „." und „./" zu "". */
11
+ function normalisieren(pfad) {
12
+ const teile = [];
13
+ for (const teil of pfad.split('/')) {
14
+ if (teil === '' || teil === '.')
15
+ continue;
16
+ if (teil === '..') {
17
+ // Dateien außerhalb des Plugin-Verzeichnisses gehören nicht zum Plugin (HARNESS-FAKTEN).
18
+ throw new MarketplaceUnbekannteForm(`Pfad „${pfad}" führt aus der Quelle heraus`);
19
+ }
20
+ teile.push(teil);
21
+ }
22
+ return teile.join('/');
23
+ }
24
+ function verbinden(quelle, rest) {
25
+ const a = normalisieren(quelle);
26
+ const b = normalisieren(rest);
27
+ if (a.length === 0)
28
+ return b;
29
+ if (b.length === 0)
30
+ return a;
31
+ return `${a}/${b}`;
32
+ }
33
+ /** Unterverzeichnisse von `ordner`, die eine SKILL.md enthalten, aus der Dateiliste des Baums. */
34
+ function skillsImOrdner(dateien, ordner) {
35
+ const praefix = ordner.length === 0 ? '' : `${ordner}/`;
36
+ const gefunden = new Set();
37
+ for (const datei of dateien) {
38
+ if (!datei.startsWith(praefix))
39
+ continue;
40
+ const rest = datei.slice(praefix.length).split('/');
41
+ if (rest.length !== 2 || rest[1] !== 'SKILL.md')
42
+ continue;
43
+ gefunden.add(`${praefix}${rest[0]}`);
44
+ }
45
+ return [...gefunden].sort();
46
+ }
47
+ function texteLesen(wert, feld) {
48
+ if (wert === undefined)
49
+ return [];
50
+ if (!Array.isArray(wert))
51
+ throw new MarketplaceUnbekannteForm(`„${feld}" ist keine Liste`);
52
+ return wert.map((e) => {
53
+ if (typeof e !== 'string')
54
+ throw new MarketplaceUnbekannteForm(`„${feld}" enthält einen Eintrag, der kein Text ist`);
55
+ return e;
56
+ });
57
+ }
58
+ function pluginLesen(roh, dateien) {
59
+ if (typeof roh !== 'object' || roh === null || Array.isArray(roh))
60
+ throw new MarketplaceUnbekannteForm('Ein Eintrag unter „plugins" ist kein Objekt');
61
+ const eintrag = roh;
62
+ const name = eintrag['name'];
63
+ if (typeof name !== 'string' || name.length === 0)
64
+ throw new MarketplaceUnbekannteForm('Ein Plugin hat keinen Namen');
65
+ const quelle = eintrag['source'];
66
+ // Die Doku kennt nur eine Pfadangabe. Fremde Quellen ({ source: "github", … }) und ein
67
+ // fehlendes Feld lehnt hmgr ab, statt einen Ordner zu raten.
68
+ if (typeof quelle !== 'string')
69
+ throw new MarketplaceUnbekannteForm(`Plugin „${name}": „source" ist kein Pfad in dieser Quelle`);
70
+ const wurzel = normalisieren(quelle);
71
+ // Mit „skills" gilt genau diese Liste, sonst der Ordner <source>/skills (HARNESS-FAKTEN:
72
+ // mehrere Plugins teilen sich so einen gemeinsamen skills-Ordner).
73
+ const genannt = eintrag['skills'];
74
+ const skills = genannt === undefined
75
+ ? skillsImOrdner(dateien, verbinden(wurzel, SKILL_ORDNER))
76
+ : texteLesen(genannt, `Plugin „${name}": skills`).map((p) => verbinden(wurzel, p));
77
+ return { name, skills, dependencies: texteLesen(eintrag['dependencies'], `Plugin „${name}": dependencies`) };
78
+ }
79
+ /** Liest den Text der marketplace.json. `dateien` ist der Dateibaum der Quelle am selben Ref. */
80
+ export function marketplaceLesen(text, dateien) {
81
+ let daten;
82
+ try {
83
+ daten = JSON.parse(text);
84
+ }
85
+ catch (fehler) {
86
+ throw new MarketplaceUnbekannteForm(`kein gültiges JSON (${fehler instanceof Error ? fehler.message : fehler})`);
87
+ }
88
+ if (typeof daten !== 'object' || daten === null || Array.isArray(daten))
89
+ throw new MarketplaceUnbekannteForm('kein Objekt');
90
+ const wurzel = daten;
91
+ const name = wurzel['name'];
92
+ if (typeof name !== 'string' || name.length === 0)
93
+ throw new MarketplaceUnbekannteForm('kein „name"');
94
+ const plugins = wurzel['plugins'];
95
+ if (!Array.isArray(plugins))
96
+ throw new MarketplaceUnbekannteForm('kein „plugins" als Liste');
97
+ return { name, plugins: plugins.map((p) => pluginLesen(p, dateien)) };
98
+ }
@@ -0,0 +1,11 @@
1
+ import path from 'node:path';
2
+ /** URL heißt Git-Quelle mit Checkout, ein nackter Pfad heißt lokale Arbeitskopie ohne Clone (B11). */
3
+ export function quelleAusText(text) {
4
+ const roh = text.trim();
5
+ if (roh.includes('://') || /^(?:[^@/\s]+@)?[^@/:\s]+:[^/\s]/.test(roh))
6
+ return { art: 'git', url: roh };
7
+ return { art: 'lokal', pfad: path.resolve(roh) };
8
+ }
9
+ export function quelleAlsText(quelle) {
10
+ return quelle.art === 'git' ? quelle.url : quelle.pfad;
11
+ }
@@ -0,0 +1,49 @@
1
+ // Name eines Skills: `name` aus dem Frontmatter der SKILL.md, sonst der Verzeichnisname (E32).
2
+ import { parse } from 'yaml';
3
+ export const SKILL_DATEI = 'SKILL.md';
4
+ /** Der Block zwischen der ersten Zeile `---` und der nächsten Zeile `---`. Fehlt er, undefined. */
5
+ function frontmatter(inhalt) {
6
+ const zeilen = inhalt.replace(/^\uFEFF/, '').split('\n');
7
+ if (zeilen[0]?.trimEnd() !== '---')
8
+ return undefined;
9
+ for (let i = 1; i < zeilen.length; i += 1)
10
+ if (zeilen[i].trimEnd() === '---')
11
+ return zeilen.slice(1, i).join('\n');
12
+ return undefined;
13
+ }
14
+ /**
15
+ * Der Verzeichnisname ist nur Konvention, das Frontmatter ist die Aussage des Autors (E18, E32).
16
+ * Ohne Frontmatter, ohne YAML-Objekt, bei kaputtem YAML oder ohne nicht leeren `name` gilt der
17
+ * Verzeichnisname. Ein Name mit Pfadtrenner gilt nicht, aus ihm würde im Ziel kein Eintrag,
18
+ * sondern ein Pfad.
19
+ */
20
+ export function skillName(verzeichnisname, skillMdInhalt) {
21
+ if (skillMdInhalt === undefined)
22
+ return verzeichnisname;
23
+ const block = frontmatter(skillMdInhalt);
24
+ if (block === undefined)
25
+ return verzeichnisname;
26
+ let daten;
27
+ try {
28
+ daten = parse(block);
29
+ }
30
+ catch {
31
+ return verzeichnisname;
32
+ }
33
+ if (typeof daten !== 'object' || daten === null || Array.isArray(daten))
34
+ return verzeichnisname;
35
+ const roh = daten['name'];
36
+ if (typeof roh !== 'string')
37
+ return verzeichnisname;
38
+ const name = roh.trim();
39
+ if (name.length === 0 || name === '.' || name === '..')
40
+ return verzeichnisname;
41
+ if (name.includes('/') || name.includes('\\'))
42
+ return verzeichnisname;
43
+ return name;
44
+ }
45
+ /** Skill aus seinem Pfad in der Quelle, Name nach `skillName`. */
46
+ export function skillAusPfad(pfad, skillMdInhalt) {
47
+ const verzeichnisname = pfad.split('/').filter((t) => t.length > 0).at(-1) ?? pfad;
48
+ return { name: skillName(verzeichnisname, skillMdInhalt), pfad };
49
+ }