hmgr 0.0.3 → 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.
@@ -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,26 +1,30 @@
1
1
  // Claude Code: Grundverhalten plus Plugins über die claude-CLI (E16, E24, E27).
2
- import { execFile } from 'node:child_process';
2
+ import { spawn } from 'node:child_process';
3
3
  import { mkdir, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { promisify } from 'node:util';
6
5
  import { OrtNichtUnterstuetzt, pluginSchluessel } from "../kern/modell.js";
7
6
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
8
7
  const GITIGNORE_ZEILE = '.claude/';
9
- const ausfuehrenRoh = promisify(execFile);
10
- /** Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme. */
11
- export const claudeAusfuehrer = async (args, cwd) => {
12
- try {
13
- const { stdout } = await ausfuehrenRoh('claude', args, {
14
- ...(cwd === undefined ? {} : { cwd }),
15
- maxBuffer: 32 * 1024 * 1024,
16
- });
17
- return { stdout, code: 0 };
18
- }
19
- catch (fehler) {
20
- const f = fehler;
21
- return { stdout: `${f.stdout ?? ''}${f.stderr ?? ''}`, code: typeof f.code === 'number' ? f.code : 1 };
22
- }
23
- };
8
+ /**
9
+ * Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme.
10
+ * stdin ist zu, sonst hängt eine CLI, die auf Eingabe wartet, den ganzen Ablauf; der Timeout
11
+ * fängt alles Übrige (beobachtet: 13 Minuten ohne Rückkehr in der GUI). SIGKILL, weil die
12
+ * CLI SIGTERM überleben kann.
13
+ */
14
+ export const claudeAusfuehrer = (args, cwd) => new Promise((aufloesen) => {
15
+ const kind = spawn('claude', args, {
16
+ ...(cwd === undefined ? {} : { cwd }),
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ timeout: 120_000,
19
+ killSignal: 'SIGKILL',
20
+ });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ kind.stdout.on('data', (d) => (stdout += d));
24
+ kind.stderr.on('data', (d) => (stderr += d));
25
+ kind.on('error', (fehler) => aufloesen({ stdout: fehler.message, code: 1 }));
26
+ kind.on('close', (code) => aufloesen({ stdout: code === 0 ? stdout : `${stdout}${stderr}`, code: code ?? 1 }));
27
+ });
24
28
  export class PluginListeUnbekannteForm extends Error {
25
29
  constructor(text) {
26
30
  super(`claude plugin list --json: ${text}`);
@@ -1,16 +1,33 @@
1
1
  // Ziele im Dateisystem: Links legen, lesen und entfernen. Fremde Einträge bleiben unangetastet (E12).
2
+ import { createHash } from 'node:crypto';
2
3
  import { lstat, mkdir, readFile, readdir, readlink, rm, stat, symlink, unlink } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { NameBelegt } from "../kern/modell.js";
5
6
  import { checkoutZerlegen, quelleZerlegen } from "./checkout-pfad.js";
6
7
  import { LOKAL_REF } from "./git.js";
7
- import { SKILL_DATEI, skillName } from "./skill-md.js";
8
+ import { SKILL_DATEI, skillAusPfad } from "./skill-md.js";
9
+ /** Dateien eines Ordners rekursiv, `.git` und `node_modules` ausgenommen, Symlinks nicht gefolgt. */
10
+ async function ordnerDateien(wurzel, unter, ergebnis) {
11
+ const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
12
+ for (const eintrag of eintraege) {
13
+ if (eintrag.name === '.git' || eintrag.name === 'node_modules')
14
+ continue;
15
+ const rel = unter ? `${unter}/${eintrag.name}` : eintrag.name;
16
+ if (eintrag.isDirectory())
17
+ await ordnerDateien(wurzel, rel, ergebnis);
18
+ else if (eintrag.isFile())
19
+ ergebnis.push({ relativerPfad: rel, inhalt: await readFile(path.join(wurzel, rel)) });
20
+ }
21
+ }
8
22
  export class DateisystemZiele {
9
23
  wurzel;
10
24
  zustand;
11
- constructor(wurzel, zustand) {
25
+ /** $XDG_STATE_HOME, falls gesetzt und nicht leer, für den vorrangigen Lockfile-Ort (E50). */
26
+ xdgStateHome;
27
+ constructor(wurzel, zustand, xdgStateHome) {
12
28
  this.wurzel = wurzel;
13
29
  this.zustand = zustand;
30
+ this.xdgStateHome = xdgStateHome;
14
31
  }
15
32
  async quellen() {
16
33
  return (await this.zustand.lesen()).quellen;
@@ -18,12 +35,14 @@ export class DateisystemZiele {
18
35
  /**
19
36
  * Der Skill hinter einem Linkziel. Der Name kommt aus der SKILL.md am Linkziel, nicht aus dem
20
37
  * Namen des Eintrags im Ziel: derselbe Skill soll überall gleich heißen, auch wenn ein Eintrag
21
- * von Hand umbenannt wurde (E32).
38
+ * von Hand umbenannt wurde (E32). Über `skillAusPfad` kommt zugleich die Beschreibung (E39) und
39
+ * die übrigen Frontmatter-Felder (E55) mit, die die Ziel-Tabellen der GUI zeigen (E60) — vorher
40
+ * fehlten sie hier, weil nur der Name gelesen wurde. Ein Aufruf, ein Lesen, ein Parsen des
41
+ * Frontmatters: bei vielen Links summiert sich jedes zweite Parsen derselben Datei.
22
42
  */
23
43
  async skill(zielPfad, skillPfad, ersatz) {
24
- const verzeichnisname = skillPfad.split('/').filter((t) => t.length > 0).at(-1) ?? ersatz;
25
44
  const inhalt = await readFile(path.join(zielPfad, SKILL_DATEI), 'utf8').catch(() => undefined);
26
- return { name: skillName(verzeichnisname, inhalt), pfad: skillPfad };
45
+ return skillAusPfad(skillPfad, inhalt, ersatz);
27
46
  }
28
47
  /** Liest aus dem Linkziel Quelle, Ref und Skill zurück. Undefined heißt: kein eigener Link. */
29
48
  async deuten(zielPfad) {
@@ -101,6 +120,10 @@ export class DateisystemZiele {
101
120
  return ergebnis;
102
121
  }
103
122
  async linkLegen(ziel, verzeichnis, name, checkout, skill, ersetzen) {
123
+ // Ein leerer Name oder einer mit Pfadtrenner ergäbe keinen Eintrag im Ziel, sondern einen Pfad:
124
+ // `path.join` fiele auf das Zielverzeichnis selbst zurück, und `ersetzen` löschte es (E12, E32).
125
+ if (name.length === 0 || name.includes('/') || name.includes(path.sep))
126
+ throw new Error(`Ungültiger Name für einen Link: „${name}"`);
104
127
  await mkdir(verzeichnis, { recursive: true });
105
128
  const pfad = path.join(verzeichnis, name);
106
129
  const info = await lstat(pfad).catch(() => undefined);
@@ -123,4 +146,81 @@ export class DateisystemZiele {
123
146
  if (await this.deuten(zielPfad))
124
147
  await unlink(path.join(verzeichnis, name));
125
148
  }
149
+ /**
150
+ * Löscht einen fremden Eintrag von der Platte (E46): ein Symlink wird als Link entfernt, ohne
151
+ * ihm zu folgen, die Arbeitskopie dahinter bleibt unangetastet; ein Verzeichnis wird rekursiv
152
+ * gelöscht. Ob der Eintrag tatsächlich fremd ist, hat der Kern schon geprüft.
153
+ */
154
+ async eintragLoeschen(_ziel, verzeichnis, name) {
155
+ const pfad = path.join(verzeichnis, name);
156
+ const info = await lstat(pfad).catch(() => undefined);
157
+ if (info === undefined)
158
+ return;
159
+ if (info.isSymbolicLink())
160
+ await unlink(pfad);
161
+ else
162
+ await rm(pfad, { recursive: true, force: true });
163
+ }
164
+ /**
165
+ * Lockfile-Kandidaten in Suchreihenfolge (E50): zuerst $XDG_STATE_HOME/skills/.skill-lock.json,
166
+ * nur wenn die Variable gesetzt und nicht leer ist, sonst wie bisher neben `skillsVerzeichnis`.
167
+ */
168
+ lockPfadKandidaten(skillsVerzeichnis) {
169
+ const kandidaten = [];
170
+ if (this.xdgStateHome !== undefined && this.xdgStateHome.length > 0)
171
+ kandidaten.push(path.join(this.xdgStateHome, 'skills', '.skill-lock.json'));
172
+ kandidaten.push(path.join(path.dirname(skillsVerzeichnis), '.skill-lock.json'));
173
+ return kandidaten;
174
+ }
175
+ /** Eintrag zu `name` aus dem Lockfile-Inhalt. Kaputtes JSON oder fehlende Felder: undefined. */
176
+ lockEintragAusInhalt(roh, name) {
177
+ try {
178
+ const geparst = JSON.parse(roh);
179
+ const eintrag = geparst.skills?.[name];
180
+ if (eintrag === undefined)
181
+ return undefined;
182
+ const sourceUrl = eintrag.sourceUrl;
183
+ const skillFolderHash = eintrag.skillFolderHash;
184
+ if (typeof sourceUrl !== 'string' || typeof skillFolderHash !== 'string')
185
+ return undefined;
186
+ const ref = eintrag.ref;
187
+ const skillPath = eintrag.skillPath;
188
+ return {
189
+ sourceUrl,
190
+ skillFolderHash,
191
+ ...(typeof ref === 'string' ? { ref } : {}),
192
+ ...(typeof skillPath === 'string' ? { skillPath } : {}),
193
+ };
194
+ }
195
+ catch {
196
+ return undefined;
197
+ }
198
+ }
199
+ /**
200
+ * Lockfile der skills-CLI (E48 b, E50, HARNESS-FAKTEN). Fehlt die Datei am ersten Kandidaten,
201
+ * wird der nächste versucht; ist sie da, aber kein gültiges JSON oder trägt der Eintrag nicht die
202
+ * nötigen Felder, gibt es undefined zurück statt zu werfen: ein kaputtes Lockfile bricht die
203
+ * Erhebung nicht ab.
204
+ */
205
+ async skillLockEintrag(skillsVerzeichnis, name) {
206
+ for (const lockPfad of this.lockPfadKandidaten(skillsVerzeichnis)) {
207
+ const roh = await readFile(lockPfad, 'utf8').catch(() => undefined);
208
+ if (roh === undefined)
209
+ continue;
210
+ return this.lockEintragAusInhalt(roh, name);
211
+ }
212
+ return undefined;
213
+ }
214
+ /** SHA-256 über relativen Pfad und Inhalt aller Dateien, wie `computeSkillFolderHash` (E48 b). */
215
+ async ordnerHash(verzeichnis, name) {
216
+ const dateien = [];
217
+ await ordnerDateien(path.join(verzeichnis, name), '', dateien);
218
+ dateien.sort((a, b) => a.relativerPfad.localeCompare(b.relativerPfad));
219
+ const hash = createHash('sha256');
220
+ for (const datei of dateien) {
221
+ hash.update(datei.relativerPfad);
222
+ hash.update(datei.inhalt);
223
+ }
224
+ return hash.digest('hex');
225
+ }
126
226
  }
@@ -1,12 +1,10 @@
1
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';
2
+ import { spawn } from 'node:child_process';
3
+ import { readFile, readdir, mkdir, realpath, rm, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { promisify } from 'node:util';
6
5
  import { marketplaceLesen } from "./marketplace-json.js";
7
- import { SKILL_DATEI, skillAusPfad } from "./skill-md.js";
6
+ import { SKILL_DATEI, skillAusPfad, skillName } from "./skill-md.js";
8
7
  import { BASIS_NAME, basisPfad, quelleZerlegen, refDekodieren, worktreePfad } from "./checkout-pfad.js";
9
- const ausfuehren = promisify(execFile);
10
8
  /** Bekannte Orte für Skills, in dieser Reihenfolge (E11). */
11
9
  export const BEKANNTE_ORTE = ['skills', '.claude/skills', '.agents/skills'];
12
10
  /** Eine lokale Arbeitskopie hat für hmgr keinen Ref, es zählt der Stand auf der Platte (B11). */
@@ -115,14 +113,37 @@ export class GitQuellen {
115
113
  constructor(wurzel) {
116
114
  this.wurzel = wurzel;
117
115
  }
116
+ /**
117
+ * stdin ist zu und git darf nicht nach Zugangsdaten fragen, sonst hängt ein wartender Aufruf
118
+ * den ganzen Ablauf (dasselbe Muster wie beim claude-Ausführer). Der Timeout ist großzügiger
119
+ * als dort, ein erster Clone über ein langsames Netz braucht Zeit.
120
+ */
118
121
  async git(args, cwd) {
119
- const { stdout } = await ausfuehren('git', args, { cwd, maxBuffer: 32 * 1024 * 1024 });
120
- return stdout;
122
+ return await new Promise((erfuellen, ablehnen) => {
123
+ const kind = spawn('git', args, {
124
+ cwd,
125
+ stdio: ['ignore', 'pipe', 'pipe'],
126
+ timeout: 300_000,
127
+ killSignal: 'SIGKILL',
128
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
129
+ });
130
+ const stuecke = [];
131
+ const fehler = [];
132
+ kind.stdout.on('data', (teil) => stuecke.push(teil));
133
+ kind.stderr.on('data', (teil) => fehler.push(teil));
134
+ kind.on('error', ablehnen);
135
+ kind.on('close', (code) => {
136
+ if (code === 0)
137
+ erfuellen(Buffer.concat(stuecke).toString('utf8'));
138
+ else
139
+ ablehnen(new Error(`git ${args.join(' ')}: ${Buffer.concat(fehler).toString('utf8').trim()}`));
140
+ });
141
+ });
121
142
  }
122
143
  /** Wie `git`, aber mit Text auf stdin und Bytes als Ergebnis: `execFile` kennt keine Eingabe. */
123
144
  async gitMitEingabe(args, cwd, eingabe) {
124
145
  return await new Promise((erfuellen, ablehnen) => {
125
- const kind = spawn('git', args, { cwd });
146
+ const kind = spawn('git', args, { cwd, timeout: 300_000, killSignal: 'SIGKILL' });
126
147
  const stuecke = [];
127
148
  const fehler = [];
128
149
  kind.stdout.on('data', (teil) => stuecke.push(teil));
@@ -225,6 +246,15 @@ export class GitQuellen {
225
246
  const ausgabe = await this.git(['for-each-ref', '--format=%(refname:short)', 'refs/tags'], basis);
226
247
  return ausgabe.split('\n').filter((z) => z.length > 0);
227
248
  }
249
+ /**
250
+ * Refs vom Remote nachziehen, damit eine Versionseinschränkung auch Tags sieht, die nach dem
251
+ * Clone entstanden sind (E33). Lokale Quellen haben keinen Clone und keinen Remote (B11).
252
+ */
253
+ async refsNachziehen(quelle) {
254
+ if (quelle.art === 'lokal')
255
+ return;
256
+ await this.git(['fetch', '--prune', '--tags', 'origin'], await this.basis(quelle));
257
+ }
228
258
  async standardRef(quelle) {
229
259
  if (quelle.art === 'lokal')
230
260
  return LOKAL_REF;
@@ -256,8 +286,10 @@ export class GitQuellen {
256
286
  for (const eintrag of vorhanden)
257
287
  if (!WURZEL_MUSTER.includes(eintrag))
258
288
  muster.add(eintrag);
289
+ // Ein Wurzel-Skill (E48 a, leerer Pfad) ist der ganze Baum: `/**` nimmt ihn nach `!/*/` wieder
290
+ // rekursiv auf, ein Muster `//` wäre kein gültiges Sparse-Checkout-Muster.
259
291
  for (const skill of skills)
260
- muster.add(`/${skill.pfad}/`);
292
+ muster.add(skill.pfad === '' ? '/**' : `/${skill.pfad}/`);
261
293
  await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
262
294
  if (neu)
263
295
  await this.git(['checkout', '--detach', ref], pfad);
@@ -291,6 +323,12 @@ export class GitQuellen {
291
323
  }
292
324
  return ergebnis;
293
325
  }
326
+ /**
327
+ * Scheitert der Abruf der Quelle, etwa ohne Netz (E51), ergibt es `fehlgeschlagen` mit
328
+ * Fehlertext statt eines geworfenen Fehlers: `aktualisieren` (Kern) ruft das je Checkout auf,
329
+ * ein einzelner Fehlschlag darf die übrigen Checkouts nicht abbrechen. Gefangen wird genau der
330
+ * Abruf und nichts sonst, damit kein Programmierfehler als Fehlschlag der Quelle erscheint.
331
+ */
294
332
  async aktualisieren(checkout) {
295
333
  if (checkout.quelle.art === 'lokal')
296
334
  return { ergebnis: 'unveraendert' };
@@ -298,13 +336,36 @@ export class GitQuellen {
298
336
  return { ergebnis: 'uebersprungen', grund: 'lokale-aenderung' }; // E15
299
337
  const basis = await this.basis(checkout.quelle);
300
338
  const von = (await this.git(['rev-parse', 'HEAD'], checkout.pfad)).trim();
301
- await this.git(['fetch', '--prune', '--tags', 'origin'], basis);
339
+ try {
340
+ await this.git(['fetch', '--prune', '--tags', 'origin'], basis);
341
+ }
342
+ catch (fehler) {
343
+ return { ergebnis: 'fehlgeschlagen', fehler: fehler instanceof Error ? fehler.message : String(fehler) };
344
+ }
302
345
  const nach = (await this.git(['rev-parse', checkout.ref], basis)).trim();
303
346
  if (von === nach)
304
347
  return { ergebnis: 'unveraendert' };
305
348
  await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
306
349
  return { ergebnis: 'aktualisiert', von, nach };
307
350
  }
351
+ /**
352
+ * Checkout von der Platte nehmen. Ein Worktree steht im Basis-Clone eingetragen, ein blankes
353
+ * Löschen des Verzeichnisses ließe den Eintrag stehen und ein späterer `worktree add` auf
354
+ * denselben Pfad scheiterte daran. Bleibt der Eintrag defekt, wird das Verzeichnis entfernt und
355
+ * der Eintrag danach mit `prune` aufgeräumt. Der Basis-Clone bleibt, er trägt alle Refs.
356
+ */
357
+ async entfernen(checkout) {
358
+ if (checkout.quelle.art === 'lokal')
359
+ return;
360
+ const basis = await this.basis(checkout.quelle);
361
+ try {
362
+ await this.git(['worktree', 'remove', '--force', checkout.pfad], basis);
363
+ }
364
+ catch {
365
+ await rm(checkout.pfad, { recursive: true, force: true });
366
+ await this.git(['worktree', 'prune'], basis);
367
+ }
368
+ }
308
369
  /**
309
370
  * Arbeitskopie um einen Pfad herum (B12). Die Wurzel kommt aus `rev-parse --show-toplevel`, vom
310
371
  * Verzeichnis des Pfads aus gefragt. Beide Pfade werden über realpath aufgelöst, sonst passen
@@ -330,10 +391,62 @@ export class GitQuellen {
330
391
  relativerPfad: path.relative(wurzel, echt).split(path.sep).join('/'),
331
392
  };
332
393
  }
394
+ /**
395
+ * `--no-optional-locks`, weil die Prüfung auch fremde Arbeitskopien trifft (E48 a): ohne den
396
+ * Schalter schreibt `status` deren Index-Cache neu, und die Erhebung wäre nicht mehr rein lesend.
397
+ */
333
398
  async lokaleAenderungen(checkout) {
334
399
  if (checkout.quelle.art === 'lokal')
335
400
  return false;
336
- const ausgabe = await this.git(['status', '--porcelain'], checkout.pfad);
401
+ const ausgabe = await this.git(['--no-optional-locks', 'status', '--porcelain'], checkout.pfad);
337
402
  return ausgabe.trim().length > 0;
338
403
  }
404
+ /**
405
+ * Enthaltensein ohne Netzzugriff (E48): `merge-base --is-ancestor` gegen den lokalen Stand des
406
+ * Tracking-Refs, kein `fetch`. Schlägt der Aufruf fehl, etwa ohne Tracking-Ref, gilt „nicht
407
+ * enthalten" als sicherer Standard.
408
+ */
409
+ async imRemoteEnthalten(pfad, ref) {
410
+ try {
411
+ await this.git(['merge-base', '--is-ancestor', 'HEAD', `refs/remotes/origin/${ref}`], pfad);
412
+ return true;
413
+ }
414
+ catch {
415
+ return false;
416
+ }
417
+ }
418
+ async wurzelSkill(pfad) {
419
+ const inhalt = await readFile(path.join(pfad, SKILL_DATEI), 'utf8').catch(() => undefined);
420
+ if (inhalt === undefined)
421
+ return null;
422
+ // Der Skill-Pfad ist leer, der Verzeichnisname ist deshalb der Ordner der Arbeitskopie selbst
423
+ // (E32). Aus dem leeren Pfad allein käme ein leerer Name, und ein leerer Name zeigte im Ziel
424
+ // auf das Zielverzeichnis statt auf einen Eintrag darin.
425
+ const { beschreibung } = skillAusPfad('', inhalt);
426
+ const name = skillName(path.basename(pfad), inhalt);
427
+ return beschreibung === undefined ? { name, pfad: '' } : { name, pfad: '', beschreibung };
428
+ }
429
+ /**
430
+ * Gitignorierte Dateien einer Arbeitskopie, für die Vorschau vor dem Ersetzen (E50). Wie bei
431
+ * `lokaleAenderungen`: `--no-optional-locks`, damit das Lesen auch einer fremden Arbeitskopie
432
+ * nichts auf der Platte verändert. Kein Git-Repo oder ein Git-Fehler ergibt eine leere Liste.
433
+ */
434
+ async gitignorierteDateien(pfad) {
435
+ try {
436
+ // Nur die Arbeitskopie selbst zählt. Liegt der Ordner bloß in einem umgebenden Repo, meldete
437
+ // `status` dessen ignorierte Dateien, die mit dem Ordner nichts zu tun haben, und die
438
+ // Vorschau behauptete einen Verlust, den das Ersetzen gar nicht anrichtet.
439
+ const wurzel = await this.git(['--no-optional-locks', 'rev-parse', '--show-toplevel'], pfad);
440
+ if ((await realpath(wurzel.trim())) !== (await realpath(pfad)))
441
+ return [];
442
+ const ausgabe = await this.git(['--no-optional-locks', 'status', '--porcelain', '--ignored'], pfad);
443
+ return ausgabe
444
+ .split('\n')
445
+ .filter((zeile) => zeile.startsWith('!! '))
446
+ .map((zeile) => zeile.slice(3));
447
+ }
448
+ catch {
449
+ return [];
450
+ }
451
+ }
339
452
  }
@@ -1,10 +1,10 @@
1
1
  // Hermes Agent: Grundverhalten plus Profile entdecken und Verzeichnis in der config.yaml registrieren.
2
2
  // Skills landen nie in <hermes-home>/skills, sondern in einem Ordner aus skills.external_dirs (E13).
3
3
  import { readFileSync, realpathSync } from 'node:fs';
4
- import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
4
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
- import { Document, isSeq, parseDocument } from 'yaml';
7
- import { OrtNichtUnterstuetzt } from "../kern/modell.js";
6
+ import { Document, isMap, isScalar, isSeq, parseDocument } from 'yaml';
7
+ import { OrtNichtUnterstuetzt, SkillOrdnerUngueltig } from "../kern/modell.js";
8
8
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
9
9
  const HERMES_VERZEICHNIS = '.hermes';
10
10
  const PROFILE_VERZEICHNIS = 'profiles';
@@ -96,10 +96,12 @@ export class Hermes {
96
96
  }
97
97
  return parseDocument(text).toJS();
98
98
  }
99
- zielVerzeichnis(ort) {
100
- if (ort.art === 'arbeitsverzeichnis')
101
- return path.join(ort.pfad, HERMES_VERZEICHNIS, EIGENER_ORDNER);
102
- const hermesHome = this.hermesHome(ort);
99
+ /**
100
+ * Der eine von hmgr verwaltete `external_dirs`-Eintrag (E13, E56): der erste unterhalb des
101
+ * Hermes-Home, ohne Hermes' eigenen Ordner `skills`. Alle weiteren Einträge der config.yaml
102
+ * ignoriert hmgr vollständig. `undefined` heißt: kein solcher Eintrag steht dort.
103
+ */
104
+ ersterEintrag(hermesHome) {
103
105
  const eigenerOrdner = path.join(hermesHome, EIGENER_ORDNER);
104
106
  for (const eintrag of externalDirs(this.gelesen(hermesHome))) {
105
107
  const pfad = this.aufloesen(eintrag, hermesHome);
@@ -109,7 +111,98 @@ export class Hermes {
109
111
  continue;
110
112
  return pfad;
111
113
  }
112
- return path.join(hermesHome, STANDARD_ORDNER);
114
+ return undefined;
115
+ }
116
+ zielVerzeichnis(ort) {
117
+ if (ort.art === 'arbeitsverzeichnis')
118
+ return path.join(ort.pfad, HERMES_VERZEICHNIS, EIGENER_ORDNER);
119
+ const hermesHome = this.hermesHome(ort);
120
+ return this.ersterEintrag(hermesHome) ?? path.join(hermesHome, STANDARD_ORDNER);
121
+ }
122
+ /**
123
+ * Orte mit einem verwalteten Skill-Ordner (E56): Home und Profil, beides ein vollständiges
124
+ * Hermes-Home mit config.yaml. Ein Arbeitsverzeichnis hat keine, dort steht `.hermes/skills`
125
+ * fest (E13).
126
+ */
127
+ skillOrdnerOrte() {
128
+ return ['home', 'profil'];
129
+ }
130
+ /**
131
+ * Fähigkeit: verwalteter Skill-Ordner (E56). Anders als `zielVerzeichnis` ohne Ausweichen auf den
132
+ * Standard-Ordner: `undefined` heißt wirklich „kein Eintrag verwaltet". Arbeitsverzeichnis kennt
133
+ * die Fähigkeit nicht, dort steht `.hermes/skills` fest (E13).
134
+ */
135
+ async verwalteterSkillOrdner(ort) {
136
+ if (ort.art === 'arbeitsverzeichnis')
137
+ return undefined;
138
+ return this.ersterEintrag(this.hermesHome(ort));
139
+ }
140
+ /**
141
+ * Prüft einen genannten Pfad als verwalteten Skill-Ordner (E56) und gibt ihn aufgelöst zurück:
142
+ * `~` und relative Angaben deutet er wie Hermes selbst (relativ zum Hermes-Home). Er muss
143
+ * unterhalb des Hermes-Home liegen und darf nicht Hermes' eigener Ordner `skills` sein, sonst
144
+ * `SkillOrdnerUngueltig`. Beides wird zusätzlich am echten Pfad gemessen, damit ein Symlink im Weg
145
+ * nicht aus dem Hermes-Home herausführt; geliefert wird trotzdem der ungefolgte Pfad, denn so
146
+ * steht er auch in der config.yaml.
147
+ */
148
+ gepruefterOrdner(hermesHome, pfad) {
149
+ const ordner = this.aufloesen(pfad, hermesHome);
150
+ // Variablen löst hmgr nicht auf (siehe `aufloesen`): ein solcher Pfad ist kein Ort auf der Platte.
151
+ if (ordner === undefined)
152
+ throw new SkillOrdnerUngueltig(pfad, 'ausserhalb');
153
+ // Nur die Elternkette wird aufgelöst: ist der Ordner selbst ein Symlink, gilt er als das, was er
154
+ // ist, und ein Löschen träfe später den Symlink, nicht sein Ziel.
155
+ const echt = path.join(this.echterPfad(path.dirname(ordner)), path.basename(ordner));
156
+ const basis = this.echterPfad(hermesHome);
157
+ if (!unterhalb(ordner, hermesHome) || !unterhalb(echt, basis))
158
+ throw new SkillOrdnerUngueltig(ordner, 'ausserhalb');
159
+ for (const [kandidat, wurzel] of [
160
+ [ordner, path.join(hermesHome, EIGENER_ORDNER)],
161
+ [echt, path.join(basis, EIGENER_ORDNER)],
162
+ ])
163
+ if (kandidat === wurzel || unterhalb(kandidat, wurzel))
164
+ throw new SkillOrdnerUngueltig(ordner, 'eigener-ordner');
165
+ return ordner;
166
+ }
167
+ /**
168
+ * Fähigkeit: Skill-Ordner festlegen (E56). Nur unterhalb des Hermes-Home und außerhalb des
169
+ * eigenen Ordners `skills`, sonst `SkillOrdnerUngueltig`. Legt den Ordner an und trägt ihn ein,
170
+ * wie `vorbereiten`/`registrieren` es für den Standard-Ordner schon tun. Gibt den aufgelösten
171
+ * Pfad zurück, wie er in der config.yaml landet.
172
+ */
173
+ async skillOrdnerFestlegen(ort, pfad) {
174
+ if (ort.art === 'arbeitsverzeichnis')
175
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
176
+ const hermesHome = this.hermesHome(ort);
177
+ const ordner = this.gepruefterOrdner(hermesHome, pfad);
178
+ await mkdir(ordner, { recursive: true });
179
+ await this.registrieren(hermesHome, ordner);
180
+ return ordner;
181
+ }
182
+ /**
183
+ * Fähigkeit: Skill-Ordner entfernen (E56), Gegenstück zu `skillOrdnerFestlegen`: nur der
184
+ * `external_dirs`-Eintrag verschwindet aus der config.yaml, der Ordner selbst bleibt unangetastet
185
+ * (dafür `skillOrdnerLoeschen`).
186
+ */
187
+ async skillOrdnerEntfernen(ort) {
188
+ if (ort.art === 'arbeitsverzeichnis')
189
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
190
+ const hermesHome = this.hermesHome(ort);
191
+ const pfad = this.ersterEintrag(hermesHome);
192
+ if (pfad === undefined)
193
+ return;
194
+ await this.deregistrieren(hermesHome, pfad);
195
+ }
196
+ /**
197
+ * Fähigkeit: den (zuvor verwalteten) Ordner mit Inhalt löschen (E56). Sicherheitsnetz: dieselbe
198
+ * Prüfung wie beim Festlegen, egal was der Aufrufer meint — nur unterhalb des Hermes-Home, nie
199
+ * Hermes' eigener Ordner `skills`, kein Ausbruch über einen Symlink im Weg.
200
+ */
201
+ async skillOrdnerLoeschen(ort, pfad) {
202
+ if (ort.art === 'arbeitsverzeichnis')
203
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
204
+ const ordner = this.gepruefterOrdner(this.hermesHome(ort), pfad);
205
+ await rm(ordner, { recursive: true, force: true });
113
206
  }
114
207
  async vorbereiten(ort) {
115
208
  const verzeichnis = this.zielVerzeichnis(ort);
@@ -132,16 +225,76 @@ export class Hermes {
132
225
  return;
133
226
  const eintrag = this.alsEintrag(verzeichnis);
134
227
  const liste = doc.getIn(['skills', 'external_dirs']);
228
+ const skills = doc.get('skills');
135
229
  if (isSeq(liste))
136
230
  liste.add(eintrag);
137
- else
231
+ // `skills:` ohne Inhalt ist ein Skalar (null), kein Abschnitt: `setIn` bräche daran ab
232
+ // („Expected YAML collection at skills"). Ein Abschnitt mit Inhalt bekommt nur den neuen
233
+ // Schlüssel, alles daneben bleibt stehen.
234
+ else if (skills === null || skills === undefined)
235
+ doc.set('skills', { external_dirs: [eintrag] });
236
+ else if (isMap(skills))
138
237
  doc.setIn(['skills', 'external_dirs'], [eintrag]);
238
+ // Steht unter `skills` etwas anderes als ein Abschnitt, hat es jemand von Hand so gemeint:
239
+ // überschreiben hieße Daten wegwerfen, also rührt hmgr die Datei nicht an.
240
+ else
241
+ throw new Error(`In ${pfad} ist „skills" kein Abschnitt, hmgr trägt dort nichts ein.`);
139
242
  await mkdir(hermesHome, { recursive: true });
140
243
  await writeFile(pfad, doc.toString());
141
244
  }
142
245
  folgtLinks() {
143
246
  return true;
144
247
  }
248
+ /**
249
+ * Fähigkeit Zurückbauen (E38), Gegenstück zu `registrieren`: entfernt den `external_dirs`-Eintrag,
250
+ * den hmgr für diesen Ort eingetragen hat, falls einer da ist. Arbeitsverzeichnisse kennen keinen
251
+ * solchen Eintrag, dort steht `.hermes/skills` fest (E13).
252
+ */
253
+ async zurueckbauen(ort) {
254
+ if (ort.art === 'arbeitsverzeichnis')
255
+ return false;
256
+ const hermesHome = this.hermesHome(ort);
257
+ return await this.deregistrieren(hermesHome, this.zielVerzeichnis(ort));
258
+ }
259
+ /**
260
+ * Fähigkeit „ist registriert" (E38 R7), Gegenstück zu `vorbereiten`/`zurueckbauen`, ohne etwas zu
261
+ * ändern: ob der `external_dirs`-Eintrag für diesen Ort noch in der config.yaml steht, unabhängig
262
+ * davon, ob dort noch Links liegen. Arbeitsverzeichnisse kennen keinen solchen Eintrag (E13),
263
+ * dort steht `.hermes/skills` fest, wie bei `zurueckbauen`.
264
+ */
265
+ async istRegistriert(ort) {
266
+ if (ort.art === 'arbeitsverzeichnis')
267
+ return false;
268
+ const hermesHome = this.hermesHome(ort);
269
+ const verzeichnis = this.zielVerzeichnis(ort);
270
+ return externalDirs(this.gelesen(hermesHome)).some((eintrag) => this.aufloesen(eintrag, hermesHome) === verzeichnis);
271
+ }
272
+ /** Entfernt jeden Eintrag aus skills.external_dirs, der auf `verzeichnis` zeigt. Kommentare bleiben. */
273
+ async deregistrieren(hermesHome, verzeichnis) {
274
+ const pfad = path.join(hermesHome, CONFIG);
275
+ const text = await readFile(pfad, 'utf8').catch(() => undefined);
276
+ if (text === undefined)
277
+ return false;
278
+ const doc = parseDocument(text);
279
+ const liste = doc.getIn(['skills', 'external_dirs']);
280
+ if (!isSeq(liste))
281
+ return false;
282
+ let geaendert = false;
283
+ for (let i = liste.items.length - 1; i >= 0; i--) {
284
+ const item = liste.items[i];
285
+ const wert = typeof item === 'string' ? item : isScalar(item) ? item.value : undefined;
286
+ if (typeof wert !== 'string')
287
+ continue;
288
+ if (this.aufloesen(wert, hermesHome) !== verzeichnis)
289
+ continue;
290
+ liste.delete(i);
291
+ geaendert = true;
292
+ }
293
+ if (!geaendert)
294
+ return false;
295
+ await writeFile(pfad, doc.toString());
296
+ return true;
297
+ }
145
298
  /** Vergleich wie bei Hermes über den aufgelösten Pfad, Symlinks eingeschlossen. */
146
299
  echterPfad(pfad) {
147
300
  try {
@@ -178,7 +331,7 @@ export class Hermes {
178
331
  return {
179
332
  hinweis: `Hermes lädt Projekt-Skills in ${wurzel} erst nach \`hermes skills trust ${wurzel}\`.`,
180
333
  abhilfe: {
181
- frage: `Hermes lädt Projekt-Skills in ${wurzel} erst nach Vertrauen. Jetzt als vertrauenswürdig eintragen? [j/N]`,
334
+ frage: `Hermes lädt Projekt-Skills in ${wurzel} erst nach Vertrauen. Jetzt als vertrauenswürdig eintragen?`,
182
335
  erledigt: `${wurzel} als vertrauenswürdig in Hermes eingetragen.`,
183
336
  ausfuehren: async () => await this.vertrauen(wurzel),
184
337
  },
@@ -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
- }