hmgr 0.0.6 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,22 @@ import { mkdir, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { OrtNichtUnterstuetzt } from "../kern/modell.js";
6
6
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
7
+ import { nurHome } from "./heime.js";
7
8
  const AGENTS_VERZEICHNIS = '.agents';
8
9
  const SKILLS_ORDNER = 'skills';
9
10
  const GITIGNORE_ZEILE = '.agents/';
10
11
  export class Agents {
11
12
  name = 'agents';
12
- home;
13
- /** `home` ist das HOME des Nutzers, das Verzeichnis liegt darunter als `.agents`. */
14
- constructor(home) {
15
- this.home = home;
13
+ heime;
14
+ /**
15
+ * `heime` sagt, wo das Homedir liegt (E78, F89): umstellbar, aber ohne zusätzliche Homedirs —
16
+ * die gibt es bisher nur bei claude-code. Ein blanker Pfad steht für „genau dieses Home".
17
+ */
18
+ constructor(heime) {
19
+ this.heime = typeof heime === 'string' ? nurHome(heime) : heime;
20
+ }
21
+ get home() {
22
+ return this.heime.primaer(this.name);
16
23
  }
17
24
  /**
18
25
  * Wie bei den anderen Harness-Typen zählt das angelegte Verzeichnis. Legt es niemand an, gibt es
@@ -25,8 +32,10 @@ export class Agents {
25
32
  return ['home', 'arbeitsverzeichnis'];
26
33
  }
27
34
  zielVerzeichnis(ort) {
35
+ // Über `heime.pfad` statt über `this.home`: nennt der Ort ein zusätzliches Homedir, wirft der
36
+ // Port (E78). agents hat die Fähigkeit nicht, ein solcher Ort dürfte hier gar nicht ankommen.
28
37
  if (ort.art === 'home')
29
- return path.join(this.home, AGENTS_VERZEICHNIS, SKILLS_ORDNER);
38
+ return path.join(this.heime.pfad(this.name, ort), AGENTS_VERZEICHNIS, SKILLS_ORDNER);
30
39
  if (ort.art === 'arbeitsverzeichnis')
31
40
  return path.join(ort.pfad, AGENTS_VERZEICHNIS, SKILLS_ORDNER);
32
41
  throw new OrtNichtUnterstuetzt(this.name, ort.art);
@@ -0,0 +1,87 @@
1
+ // Liest eine Bundle-Definition aus einer Quelle (E69). hmgr schreibt solche Dateien nie.
2
+ import { parseDocument } from 'yaml';
3
+ import { BundleDateiFehler } from "../kern/modell.js";
4
+ import { quelleAusText } from "./quelle-text.js";
5
+ /** Konvention für Bundle-Definitionen in einer Quelle (E69): `bundles/<name>.yaml`. */
6
+ export const BUNDLE_VERZEICHNIS = 'bundles';
7
+ /**
8
+ * Ob ein Pfad in der Quelle eine Bundle-Definition ist: eine Datei direkt unter `bundles/`, mit
9
+ * Endung `.yaml` oder `.yml`. Nichts Verschachteltes — die Konvention nennt genau eine Ebene, und
10
+ * ein Unterordner wäre eine stille zweite Bedeutung.
11
+ */
12
+ export function istBundleDatei(pfad) {
13
+ return /^bundles\/[^/]+\.(?:yaml|yml)$/.test(pfad);
14
+ }
15
+ /** Der Dateiname ohne Verzeichnis und Endung: Ersatzname, wenn das Feld `name` fehlt. */
16
+ export function bundleDateiName(pfad) {
17
+ return pfad.replace(/^bundles\//, '').replace(/\.(?:yaml|yml)$/, '');
18
+ }
19
+ function fehlertext(fehler) {
20
+ return fehler instanceof Error ? fehler.message : String(fehler);
21
+ }
22
+ function istObjekt(wert) {
23
+ return typeof wert === 'object' && wert !== null && !Array.isArray(wert);
24
+ }
25
+ /** Erlaubte Felder auf oberster Ebene und je Mitglied. Alles andere ist ein Fehler mit Feldnamen. */
26
+ const FELDER = new Set(['name', 'description', 'skills']);
27
+ const MITGLIED_FELDER = new Set(['source', 'path']);
28
+ function mitgliedLesen(datei, roh, i) {
29
+ if (!istObjekt(roh))
30
+ throw new BundleDateiFehler(datei, `skills[${i}] muss ein Objekt mit source und path sein`);
31
+ for (const feld of Object.keys(roh))
32
+ if (!MITGLIED_FELDER.has(feld))
33
+ throw new BundleDateiFehler(datei, `skills[${i}]: unbekanntes Feld „${feld}"`);
34
+ const source = roh['source'];
35
+ if (typeof source !== 'string' || source.trim().length === 0)
36
+ throw new BundleDateiFehler(datei, `skills[${i}].source muss die Adresse einer Quelle sein (URL oder Pfad)`);
37
+ const pfad = roh['path'];
38
+ if (typeof pfad !== 'string' || pfad.trim().length === 0)
39
+ throw new BundleDateiFehler(datei, `skills[${i}].path muss der Pfad des Skills in der Quelle sein`);
40
+ // Führende und abschließende Schrägstriche fallen weg: `skills/x/` und `skills/x` meinen denselben
41
+ // Ordner, und der Katalog führt ihn ohne (E11). Ein Wurzel-Skill (leerer Pfad) ist nicht
42
+ // adressierbar — er hieße in der Definition „", das ist kein Text und fliegt oben schon auf.
43
+ return { quelle: quelleAusText(source.trim()), pfad: pfad.trim().replace(/^\/+/, '').replace(/\/+$/, '') };
44
+ }
45
+ /**
46
+ * Eine Bundle-Definition aus dem YAML-Text lesen und validieren (E69). `datei` ist der Pfad in der
47
+ * Quelle, er dient als Ersatzname (ohne Verzeichnis und Endung), wenn das Feld `name` fehlt: der
48
+ * Dateiname ist Konvention, das Feld ist die Aussage des Autors — dieselbe Rangfolge wie beim
49
+ * Skill-Namen (E32). `quelle` ist die Quelle, in der die Datei liegt.
50
+ */
51
+ export function bundleLesen(text, datei, quelle) {
52
+ let daten;
53
+ try {
54
+ const dokument = parseDocument(text, { strict: true });
55
+ // `parseDocument` sammelt Syntaxfehler, statt zu werfen: ohne diese Prüfung käme eine kaputte
56
+ // Datei als halb gelesenes Objekt durch und der Nutzer bekäme eine Meldung über ein Feld statt
57
+ // über die Datei.
58
+ const erster = dokument.errors[0];
59
+ if (erster !== undefined)
60
+ throw new BundleDateiFehler(datei, `kaputtes YAML: ${erster.message}`);
61
+ daten = dokument.toJS();
62
+ }
63
+ catch (fehler) {
64
+ if (fehler instanceof BundleDateiFehler)
65
+ throw fehler;
66
+ throw new BundleDateiFehler(datei, `kaputtes YAML: ${fehlertext(fehler)}`);
67
+ }
68
+ if (!istObjekt(daten))
69
+ throw new BundleDateiFehler(datei, 'die Datei muss ein Objekt mit name, description und skills sein');
70
+ for (const feld of Object.keys(daten))
71
+ if (!FELDER.has(feld))
72
+ throw new BundleDateiFehler(datei, `unbekanntes Feld „${feld}"`);
73
+ const rohName = daten['name'];
74
+ if (rohName !== undefined && (typeof rohName !== 'string' || rohName.trim().length === 0))
75
+ throw new BundleDateiFehler(datei, 'name muss nicht leerer Text sein');
76
+ const name = typeof rohName === 'string' ? rohName.trim() : bundleDateiName(datei);
77
+ const beschreibung = daten['description'];
78
+ if (typeof beschreibung !== 'string' || beschreibung.trim().length === 0)
79
+ throw new BundleDateiFehler(datei, 'description muss nicht leerer Text sein');
80
+ const skills = daten['skills'];
81
+ if (!Array.isArray(skills))
82
+ throw new BundleDateiFehler(datei, 'skills muss eine Liste sein');
83
+ if (skills.length === 0)
84
+ throw new BundleDateiFehler(datei, 'skills darf nicht leer sein');
85
+ const mitglieder = skills.map((roh, i) => mitgliedLesen(datei, roh, i));
86
+ return { name, beschreibung: beschreibung.trim(), mitglieder, quelle, datei };
87
+ }
@@ -1,12 +1,24 @@
1
1
  // Claude Code: Grundverhalten plus Plugins und MCP-Server über die claude-CLI (E16, E24, E27, E61).
2
2
  import spawn from 'cross-spawn';
3
3
  import { realpathSync } from 'node:fs';
4
- import { mkdir, readFile, stat } from 'node:fs/promises';
4
+ import { readFile, stat } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
- import { McpAbweichenderEintrag, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, OrtNichtUnterstuetzt, pluginSchluessel, } from "../kern/modell.js";
6
+ import { HeimOhneCliZugriff, McpAbweichenderEintrag, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, OrtNichtUnterstuetzt, pluginSchluessel, } from "../kern/modell.js";
7
7
  import { ohneBom } from "./datei-text.js";
8
+ import { LokalerZugriff } from "./dateisystem.js";
8
9
  import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
10
+ import { heimVerbinden, nurHome } from "./heime.js";
9
11
  import { tiefGleich } from "./vergleich.js";
12
+ const CLAUDE_VERZEICHNIS = '.claude';
13
+ const SKILLS_ORDNER = 'skills';
14
+ /**
15
+ * Die Adresse eines zusätzlichen Homedirs an diesem Ort (E78), sonst `undefined`. Was die
16
+ * claude-CLI angeht — MCP-Server, Plugins —, gilt nur für das primäre Homedir: die CLI auf diesem
17
+ * Rechner kennt die Konfiguration einer WSL-Distro nicht.
18
+ */
19
+ function zusaetzlichesHeim(ort) {
20
+ return ort.art === 'home' ? ort.heim : undefined;
21
+ }
10
22
  const GITIGNORE_ZEILE = '.claude/';
11
23
  /**
12
24
  * Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme.
@@ -113,6 +125,9 @@ class ClaudePlugins {
113
125
  * Startverzeichnis ab, mit dem Zielverzeichnis als cwd landete sie unter `.claude/skills/`.
114
126
  */
115
127
  scope(ziel) {
128
+ const heim = zusaetzlichesHeim(ziel.ort);
129
+ if (heim !== undefined)
130
+ throw new HeimOhneCliZugriff(this.harness.name, heim, 'Plugins');
116
131
  if (ziel.ort.art === 'home')
117
132
  return { scope: 'user' };
118
133
  if (ziel.ort.art === 'arbeitsverzeichnis')
@@ -219,6 +234,7 @@ async function projektMcpJsonLesen(verzeichnis) {
219
234
  class ClaudeMcp {
220
235
  harness;
221
236
  ausfuehrer;
237
+ /** Das primäre Homedir, jedes Mal frisch: es lässt sich zur Laufzeit umstellen (E78, F89). */
222
238
  home;
223
239
  constructor(harness, ausfuehrer, home) {
224
240
  this.harness = harness;
@@ -232,6 +248,9 @@ class ClaudeMcp {
232
248
  return 'Claude Code übernimmt die Änderung mit der nächsten Sitzung.';
233
249
  }
234
250
  scope(ort) {
251
+ const heim = zusaetzlichesHeim(ort);
252
+ if (heim !== undefined)
253
+ throw new HeimOhneCliZugriff(this.harness.name, heim, 'MCP-Server');
235
254
  if (ort.art === 'home')
236
255
  return { scope: 'user' };
237
256
  if (ort.art === 'arbeitsverzeichnis')
@@ -343,6 +362,9 @@ class ClaudeMcp {
343
362
  * sich Arbeitsverzeichnisse aber so, wie der Nutzer sie genannt hat, also zählen beide.
344
363
  */
345
364
  async mcpServersAm(ort) {
365
+ const heim = zusaetzlichesHeim(ort);
366
+ if (heim !== undefined)
367
+ throw new HeimOhneCliZugriff(this.harness.name, heim, 'MCP-Server');
346
368
  const daten = await this.claudeJsonLesen();
347
369
  if (ort.art === 'home')
348
370
  return mcpServersVon(daten);
@@ -355,7 +377,7 @@ class ClaudeMcp {
355
377
  }
356
378
  async claudeJsonLesen() {
357
379
  try {
358
- const text = await readFile(path.join(this.home, '.claude.json'), 'utf8');
380
+ const text = await readFile(path.join(this.home(), '.claude.json'), 'utf8');
359
381
  return JSON.parse(ohneBom(text));
360
382
  }
361
383
  catch {
@@ -365,29 +387,42 @@ class ClaudeMcp {
365
387
  }
366
388
  export class ClaudeCode {
367
389
  name = 'claude-code';
368
- home;
390
+ heime;
369
391
  ausfuehrer;
392
+ /** Zugriff je Ort (E78): lokal wie bisher, für ein WSL-Heim die Bridge. */
393
+ zugriffFuer;
370
394
  /** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt. */
371
395
  plugins;
372
396
  /** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt (E61). */
373
397
  mcp;
374
398
  geprueft = false;
375
- constructor(home, ausfuehrer = claudeAusfuehrer) {
376
- this.home = home;
399
+ /**
400
+ * `heime` sagt, wo die Homedirs liegen (E78): das primäre und, seit F87, zusätzliche in einer
401
+ * WSL-Distro. Ein blanker Pfad steht weiterhin für „nur dieses eine Home", damit Tests und
402
+ * Aufrufer ohne Zustand unverändert bleiben.
403
+ */
404
+ constructor(heime, ausfuehrer = claudeAusfuehrer, zugriffFuer) {
405
+ this.heime = typeof heime === 'string' ? nurHome(heime) : heime;
377
406
  this.ausfuehrer = ausfuehrer;
407
+ const lokal = new LokalerZugriff();
408
+ this.zugriffFuer = zugriffFuer ?? (() => lokal);
378
409
  }
379
410
  /**
380
- * Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin- und
411
+ * Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat — oder wenn ein zusätzliches
412
+ * Homedir eingetragen ist (E78): wer eines eingetragen hat, hat Claude Code dort laufen, und ein
413
+ * Windows-Home ohne `.claude` soll die WSL-Ziele nicht mit verschwinden lassen. Die Plugin- und
381
414
  * MCP-Fähigkeit kommen dazu, sobald `claude --version` durchläuft: ohne CLI gibt es beides nicht.
382
415
  */
383
416
  async vorhanden() {
384
- const da = (await stat(path.join(this.home, '.claude')).catch(() => undefined)) !== undefined;
417
+ const primaer = this.heime.primaer(this.name);
418
+ const da = (await stat(path.join(primaer, CLAUDE_VERZEICHNIS)).catch(() => undefined)) !== undefined ||
419
+ this.heime.zusaetzliche(this.name).length > 0;
385
420
  if (!this.geprueft) {
386
421
  this.geprueft = true;
387
422
  const { code } = await this.ausfuehrer(['--version']).catch(() => ({ code: 1 }));
388
423
  if (code === 0) {
389
424
  this.plugins = new ClaudePlugins(this, this.ausfuehrer);
390
- this.mcp = new ClaudeMcp(this, this.ausfuehrer, this.home);
425
+ this.mcp = new ClaudeMcp(this, this.ausfuehrer, () => this.heime.primaer(this.name));
391
426
  }
392
427
  }
393
428
  return da;
@@ -395,15 +430,21 @@ export class ClaudeCode {
395
430
  orte() {
396
431
  return ['home', 'arbeitsverzeichnis'];
397
432
  }
433
+ /** Fähigkeit: zusätzliche Homedirs (E78, F87). Das Layout ist ein fester Ordner, kein Konfigeintrag. */
434
+ zusaetzlicheHeime() {
435
+ return true;
436
+ }
398
437
  zielVerzeichnis(ort) {
399
438
  if (ort.art === 'home')
400
- return path.join(this.home, '.claude', 'skills');
439
+ return heimVerbinden(ort, this.heime.pfad(this.name, ort), CLAUDE_VERZEICHNIS, SKILLS_ORDNER);
401
440
  if (ort.art === 'arbeitsverzeichnis')
402
- return path.join(ort.pfad, '.claude', 'skills');
441
+ return path.join(ort.pfad, CLAUDE_VERZEICHNIS, SKILLS_ORDNER);
403
442
  throw new OrtNichtUnterstuetzt(this.name, ort.art);
404
443
  }
405
444
  async vorbereiten(ort) {
406
- await mkdir(this.zielVerzeichnis(ort), { recursive: true });
445
+ // Über den Zugriff, nicht über `mkdir`: in einem WSL-Heim liegt das Verzeichnis im
446
+ // Linux-Dateisystem, wo Windows-Node nicht anlegen kann (E78).
447
+ await this.zugriffFuer(ort).verzeichnisAnlegen(this.zielVerzeichnis(ort));
407
448
  if (ort.art === 'arbeitsverzeichnis' && (await istGitRepo(ort.pfad)))
408
449
  await gitignoreEintragen(ort.pfad, GITIGNORE_ZEILE);
409
450
  }
@@ -45,15 +45,63 @@ export function linkFehlerHinweis(plattform = process.platform) {
45
45
  ? 'Aktiviere den Windows-Entwicklermodus (Einstellungen → System → Für Entwickler) oder starte hmgr mit Administratorrechten.'
46
46
  : 'Prüfe die Schreibrechte auf das Zielverzeichnis und ob das Linkziel vorhanden ist.';
47
47
  }
48
+ /**
49
+ * Zugriff auf das lokale Dateisystem (E78): der Weg, den hmgr immer schon ging, jetzt hinter dem
50
+ * Port. Linkziele brauchen keine Übersetzung, sie stehen schon in der Schreibweise des Rechners.
51
+ */
52
+ export class LokalerZugriff {
53
+ trenner = path.sep;
54
+ verbinden(verzeichnis, name) {
55
+ return path.join(verzeichnis, name);
56
+ }
57
+ async namen(verzeichnis) {
58
+ return await readdir(verzeichnis).catch(() => []);
59
+ }
60
+ async info(pfad) {
61
+ const roh = await lstat(pfad).catch(() => undefined);
62
+ if (roh === undefined)
63
+ return undefined;
64
+ if (!roh.isSymbolicLink())
65
+ return { istLink: false, istVerzeichnis: roh.isDirectory() };
66
+ return { istLink: true, istVerzeichnis: (await stat(pfad).catch(() => undefined))?.isDirectory() === true };
67
+ }
68
+ async linkZiel(pfad) {
69
+ const info = await lstat(pfad).catch(() => undefined);
70
+ if (!info || !info.isSymbolicLink())
71
+ return undefined;
72
+ // Junctions meldet Node als Symlink, ihr Ziel aber mit `\\?\`-Präfix (E66): erst kürzen, dann
73
+ // auflösen, sonst ergäbe `path.resolve` einen Pfad, der zu keinem Soll-Ziel passt.
74
+ return path.resolve(path.dirname(pfad), ohneWindowsPraefix(await readlink(pfad)));
75
+ }
76
+ async verzeichnisAnlegen(pfad) {
77
+ await mkdir(pfad, { recursive: true });
78
+ }
79
+ async linkAnlegen(pfad, hostZiel) {
80
+ await symlink(hostZiel, pfad, symlinkTyp());
81
+ }
82
+ async linkEntfernen(pfad) {
83
+ await unlink(pfad);
84
+ }
85
+ async loeschen(pfad) {
86
+ await rm(pfad, { recursive: true, force: true });
87
+ }
88
+ }
48
89
  export class DateisystemZiele {
49
90
  wurzel;
50
91
  zustand;
51
92
  /** $XDG_STATE_HOME, falls gesetzt und nicht leer, für den vorrangigen Lockfile-Ort (E50). */
52
93
  xdgStateHome;
53
- constructor(wurzel, zustand, xdgStateHome) {
94
+ /**
95
+ * Welcher Zugriff zu welchem Ziel gehört (E78): lokal wie bisher, für ein WSL-Heim die Bridge.
96
+ * Ohne Angabe ist alles lokal — so bleibt der Adapter ohne Windows-Umgebung derselbe wie vorher.
97
+ */
98
+ zugriffFuer;
99
+ lokal = new LokalerZugriff();
100
+ constructor(wurzel, zustand, xdgStateHome, zugriffFuer) {
54
101
  this.wurzel = wurzel;
55
102
  this.zustand = zustand;
56
103
  this.xdgStateHome = xdgStateHome;
104
+ this.zugriffFuer = zugriffFuer ?? (() => this.lokal);
57
105
  }
58
106
  async quellen() {
59
107
  return (await this.zustand.lesen()).quellen;
@@ -105,37 +153,22 @@ export class DateisystemZiele {
105
153
  }
106
154
  return undefined;
107
155
  }
108
- async linkZiel(verzeichnis, name) {
109
- const pfad = path.join(verzeichnis, name);
110
- const info = await lstat(pfad).catch(() => undefined);
111
- if (!info || !info.isSymbolicLink())
112
- return undefined;
113
- // Junctions meldet Node als Symlink, ihr Ziel aber mit `\\?\`-Präfix (E66): erst kürzen, dann
114
- // auflösen, sonst ergäbe `path.resolve` einen Pfad, der zu keinem Soll-Ziel passt.
115
- return path.resolve(verzeichnis, ohneWindowsPraefix(await readlink(pfad)));
116
- }
117
156
  /**
118
157
  * Ein Skill ist ein Verzeichnis. Dateien wie .DS_Store zählen nicht als fremder Eintrag,
119
- * ebenso wenig ein Symlink, der auf keine Verzeichnis zeigt.
158
+ * ebenso wenig ein Symlink, der auf kein Verzeichnis zeigt.
120
159
  */
121
- async istVerzeichnis(verzeichnis, name) {
122
- const pfad = path.join(verzeichnis, name);
123
- const info = await lstat(pfad).catch(() => undefined);
124
- if (info === undefined)
125
- return false;
126
- if (info.isDirectory())
127
- return true;
128
- if (!info.isSymbolicLink())
129
- return false;
130
- return (await stat(pfad).catch(() => undefined))?.isDirectory() === true;
160
+ istVerzeichnis(info) {
161
+ return info !== undefined && info.istVerzeichnis;
131
162
  }
132
163
  async eintraege(ziel, verzeichnis) {
133
- const namen = await readdir(verzeichnis).catch(() => []);
164
+ const zugriff = this.zugriffFuer(ziel);
165
+ const namen = await zugriff.namen(verzeichnis);
134
166
  const ergebnis = [];
135
- for (const name of namen.sort()) {
136
- if (!(await this.istVerzeichnis(verzeichnis, name)))
167
+ for (const name of [...namen].sort()) {
168
+ const pfad = zugriff.verbinden(verzeichnis, name);
169
+ if (!this.istVerzeichnis(await zugriff.info(pfad)))
137
170
  continue;
138
- const zielPfad = await this.linkZiel(verzeichnis, name);
171
+ const zielPfad = await zugriff.linkZiel(pfad);
139
172
  const gedeutet = zielPfad ? await this.deuten(zielPfad) : undefined;
140
173
  if (gedeutet?.art === 'link')
141
174
  ergebnis.push({ art: 'link', link: { ziel, name, checkout: gedeutet.checkout, skill: gedeutet.skill } });
@@ -148,20 +181,21 @@ export class DateisystemZiele {
148
181
  return ergebnis;
149
182
  }
150
183
  async linkLegen(ziel, verzeichnis, name, checkout, skill, ersetzen) {
184
+ const zugriff = this.zugriffFuer(ziel);
151
185
  // Ein leerer Name oder einer mit Pfadtrenner ergäbe keinen Eintrag im Ziel, sondern einen Pfad:
152
- // `path.join` fiele auf das Zielverzeichnis selbst zurück, und `ersetzen` löschte es (E12, E32).
153
- if (name.length === 0 || name.includes('/') || name.includes(path.sep))
186
+ // `verbinden` fiele auf das Zielverzeichnis selbst zurück, und `ersetzen` löschte es (E12, E32).
187
+ if (name.length === 0 || name.includes('/') || name.includes(zugriff.trenner))
154
188
  throw new Error(`Ungültiger Name für einen Link: „${name}"`);
155
- await mkdir(verzeichnis, { recursive: true });
156
- const pfad = path.join(verzeichnis, name);
157
- const info = await lstat(pfad).catch(() => undefined);
189
+ await zugriff.verzeichnisAnlegen(verzeichnis);
190
+ const pfad = zugriff.verbinden(verzeichnis, name);
191
+ const info = await zugriff.info(pfad);
158
192
  if (info) {
159
- const zielPfad = await this.linkZiel(verzeichnis, name);
193
+ const zielPfad = await zugriff.linkZiel(pfad);
160
194
  const eigen = zielPfad ? await this.deuten(zielPfad) : undefined;
161
195
  if (eigen)
162
- await unlink(pfad);
196
+ await zugriff.linkEntfernen(pfad);
163
197
  else if (ersetzen)
164
- await rm(pfad, { recursive: true, force: true });
198
+ await zugriff.loeschen(pfad);
165
199
  else
166
200
  throw new NameBelegt(ziel, name);
167
201
  }
@@ -171,33 +205,36 @@ export class DateisystemZiele {
171
205
  // relativer Pfad. `path.resolve` kostet nichts und macht die Zusage hier fest.
172
206
  const checkoutZiel = path.resolve(checkout.pfad, skill.pfad);
173
207
  try {
174
- await symlink(checkoutZiel, pfad, symlinkTyp());
208
+ await zugriff.linkAnlegen(pfad, checkoutZiel);
175
209
  }
176
210
  catch (ursache) {
177
211
  throw new LinkAnlegenFehlgeschlagen(pfad, checkoutZiel, ursache instanceof Error ? ursache.message : String(ursache), linkFehlerHinweis());
178
212
  }
179
213
  }
180
- async linkEntfernen(_ziel, verzeichnis, name) {
181
- const zielPfad = await this.linkZiel(verzeichnis, name);
214
+ async linkEntfernen(ziel, verzeichnis, name) {
215
+ const zugriff = this.zugriffFuer(ziel);
216
+ const pfad = zugriff.verbinden(verzeichnis, name);
217
+ const zielPfad = await zugriff.linkZiel(pfad);
182
218
  if (!zielPfad)
183
219
  return;
184
220
  if (await this.deuten(zielPfad))
185
- await unlink(path.join(verzeichnis, name));
221
+ await zugriff.linkEntfernen(pfad);
186
222
  }
187
223
  /**
188
224
  * Löscht einen fremden Eintrag von der Platte (E46): ein Symlink wird als Link entfernt, ohne
189
225
  * ihm zu folgen, die Arbeitskopie dahinter bleibt unangetastet; ein Verzeichnis wird rekursiv
190
226
  * gelöscht. Ob der Eintrag tatsächlich fremd ist, hat der Kern schon geprüft.
191
227
  */
192
- async eintragLoeschen(_ziel, verzeichnis, name) {
193
- const pfad = path.join(verzeichnis, name);
194
- const info = await lstat(pfad).catch(() => undefined);
228
+ async eintragLoeschen(ziel, verzeichnis, name) {
229
+ const zugriff = this.zugriffFuer(ziel);
230
+ const pfad = zugriff.verbinden(verzeichnis, name);
231
+ const info = await zugriff.info(pfad);
195
232
  if (info === undefined)
196
233
  return;
197
- if (info.isSymbolicLink())
198
- await unlink(pfad);
234
+ if (info.istLink)
235
+ await zugriff.linkEntfernen(pfad);
199
236
  else
200
- await rm(pfad, { recursive: true, force: true });
237
+ await zugriff.loeschen(pfad);
201
238
  }
202
239
  /**
203
240
  * Lockfile-Kandidaten in Suchreihenfolge (E50): zuerst $XDG_STATE_HOME/skills/.skill-lock.json,
@@ -235,6 +272,11 @@ export class DateisystemZiele {
235
272
  }
236
273
  }
237
274
  /**
275
+ * Lockfile und Ordner-Hash lesen immer lokal, nie über die Bridge (E78): beide gehören zur
276
+ * Übernahme-Erkennung eines agents-Ziels (E48 b), und zusätzliche Homedirs gibt es bisher nur bei
277
+ * claude-code. Käme das je zusammen, wäre der Ordner-Hash über die Bridge außerdem ein Lesen
278
+ * jeder Datei einzeln — dann lieber ein eigener Schnitt als ein stiller Sonderweg hier.
279
+ *
238
280
  * Lockfile der skills-CLI (E48 b, E50, HARNESS-FAKTEN). Fehlt die Datei am ersten Kandidaten,
239
281
  * wird der nächste versucht; ist sie da, aber kein gültiges JSON oder trägt der Eintrag nicht die
240
282
  * nötigen Felder, gibt es undefined zurück statt zu werfen: ein kaputtes Lockfile bricht die
@@ -2,6 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { readFile, readdir, mkdir, realpath, rm, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import { bundleLesen, istBundleDatei } from "./bundle-yaml.js";
5
6
  import { marketplaceLesen } from "./marketplace-json.js";
6
7
  import { SKILL_DATEI, skillAusPfad, skillName } from "./skill-md.js";
7
8
  import { BASIS_NAME, basisPfad, quelleZerlegen, refDekodieren, worktreePfad } from "./checkout-pfad.js";
@@ -253,6 +254,55 @@ export class GitQuellen {
253
254
  })),
254
255
  };
255
256
  }
257
+ /**
258
+ * Inhalte beliebiger Dateien eines Refs in einem Aufruf, dasselbe Bündel-Muster wie
259
+ * `skillMdInhalte`: `git cat-file --batch` holt beim Partial Clone die fehlenden Blobs gebündelt.
260
+ * Was nicht lesbar ist, fehlt in der Karte.
261
+ */
262
+ async dateiInhalte(basis, ref, pfade) {
263
+ if (pfade.length === 0)
264
+ return new Map();
265
+ const eingabe = `${pfade.map((p) => `${ref}:${p}`).join('\n')}\n`;
266
+ const ausgabe = await this.gitMitEingabe(['cat-file', '--batch'], basis, eingabe).catch(() => undefined);
267
+ if (ausgabe === undefined)
268
+ return new Map();
269
+ return batchInhalte(ausgabe, pfade);
270
+ }
271
+ /**
272
+ * Bundle-Definitionen der Quelle (E69): jede Datei unter `bundles/`, gelesen wie der Marketplace
273
+ * über den Git-Baum statt über einen Checkout. Eine kaputte Datei ergibt einen Fehler-Eintrag,
274
+ * keinen Abbruch: die übrigen Bundles derselben Quelle bleiben nutzbar.
275
+ */
276
+ async bundles(quelle, ref) {
277
+ let dateien;
278
+ let inhalte;
279
+ if (quelle.art === 'lokal') {
280
+ dateien = (await dateienAufPlatte(quelle.pfad)).filter(istBundleDatei).sort();
281
+ inhalte = new Map();
282
+ for (const datei of dateien) {
283
+ const roh = await readFile(path.join(quelle.pfad, datei), 'utf8').catch(() => undefined);
284
+ if (roh !== undefined)
285
+ inhalte.set(datei, roh);
286
+ }
287
+ }
288
+ else {
289
+ const basis = await this.basis(quelle);
290
+ const ausgabe = await this.git(['ls-tree', '-r', '--name-only', ref], basis);
291
+ dateien = ausgabe.split('\n').filter(istBundleDatei).sort();
292
+ inhalte = await this.dateiInhalte(basis, ref, dateien);
293
+ }
294
+ return dateien.map((datei) => {
295
+ const inhalt = inhalte.get(datei);
296
+ if (inhalt === undefined)
297
+ return { art: 'fehler', datei, fehler: 'Datei nicht lesbar' };
298
+ try {
299
+ return { art: 'bundle', datei, bundle: bundleLesen(inhalt, datei, quelle) };
300
+ }
301
+ catch (fehler) {
302
+ return { art: 'fehler', datei, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
303
+ }
304
+ });
305
+ }
256
306
  async refs(quelle) {
257
307
  if (quelle.art === 'lokal')
258
308
  return [LOKAL_REF];
@@ -374,6 +424,104 @@ export class GitQuellen {
374
424
  await this.git(['checkout', '--detach', checkout.ref], checkout.pfad);
375
425
  return { ergebnis: 'aktualisiert', von, nach };
376
426
  }
427
+ /**
428
+ * Geänderte Pfade zwischen zwei Commits (E70). `--no-renames` aus zwei Gründen: eine Umbenennung
429
+ * soll hier als Änderung an beiden Pfaden zählen, und die Ähnlichkeitsprüfung bräuchte die Blobs,
430
+ * die ein Partial Clone (`--filter=blob:none`) erst aus dem Netz holen müsste. Eine lokale Quelle
431
+ * hat keinen Checkout und damit keine zwei Commits (B11), sie kommt hier nie an.
432
+ */
433
+ async geaenderteDateien(quelle, von, nach) {
434
+ if (quelle.art === 'lokal')
435
+ return [];
436
+ try {
437
+ const basis = await this.basis(quelle);
438
+ const ausgabe = await this.git(['diff', '--name-only', '--no-renames', von, nach], basis);
439
+ return ausgabe.split('\n').filter((z) => z.length > 0);
440
+ }
441
+ catch {
442
+ return [];
443
+ }
444
+ }
445
+ /**
446
+ * Skills, die in diesem Checkout auf der Platte liegen (E70): dieselbe Regel wie bei einer
447
+ * lokalen Quelle, nur auf das Worktree-Verzeichnis angewandt. Der Sparse-Checkout hat dort nur
448
+ * die gewählten Skills materialisiert (E22), also nennt der Update-Bericht auch nur diese.
449
+ */
450
+ async ausgecheckteSkills(checkout) {
451
+ const pfade = skillPfadeAusDateien(await dateienAufPlatte(checkout.pfad));
452
+ const inhalte = await inhalteAufPlatte(checkout.pfad, pfade);
453
+ const ersatzname = ersatznameDerQuelle(checkout.quelle);
454
+ return pfade.map((pfad) => skillAusPfad(pfad, inhalte.get(pfad), ersatzname));
455
+ }
456
+ /**
457
+ * Ein `git log --name-only` je Quelle statt eines Aufrufs je Skill (E70). Die Ausgabe wird
458
+ * gelesen, während sie entsteht, und der Lauf abgebrochen, sobald jeder gesuchte Pfad sein Datum
459
+ * hat: der jüngste Commit steht vorn, alles Ältere ist unnötig. Gemessen an einem Repo mit 20
460
+ * Skills und 401 Commits: 6 ms gegen 110 ms für 20 einzelne `git log -1`.
461
+ *
462
+ * Format: `%x00%cI` stellt jeder Datumszeile ein NUL voran, das in keinem Dateipfad vorkommt —
463
+ * daran trennen sich Kopf und Dateiliste. `--no-renames` hält die Ähnlichkeitsprüfung heraus, die
464
+ * beim Partial Clone Blobs nachladen würde. Scheitert der Aufruf (kein Repo, unbekannter Ref),
465
+ * bleibt die Karte leer, das ist kein Fehler.
466
+ */
467
+ async logDaten(cwd, ref, pfade) {
468
+ const daten = new Map();
469
+ const offen = new Set(pfade);
470
+ if (offen.size === 0)
471
+ return daten;
472
+ return await new Promise((erfuellen) => {
473
+ const kind = spawn('git', ['log', '--format=%x00%cI', '--name-only', '--no-renames', ref], {
474
+ cwd,
475
+ stdio: ['ignore', 'pipe', 'ignore'],
476
+ timeout: 300_000,
477
+ killSignal: 'SIGKILL',
478
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
479
+ });
480
+ let rest = '';
481
+ let datum;
482
+ const fertig = () => erfuellen(daten);
483
+ kind.on('error', fertig);
484
+ kind.on('close', fertig);
485
+ // Nach dem Abbruch schreibt git in eine Pipe, die niemand mehr liest: der Fehler darf den
486
+ // Prozess nicht mitnehmen, das Ergebnis steht da schon fest.
487
+ kind.stdout.on('error', () => { });
488
+ kind.stdout.on('data', (teil) => {
489
+ rest += teil.toString('utf8');
490
+ const zeilen = rest.split('\n');
491
+ rest = zeilen.pop() ?? '';
492
+ for (const zeile of zeilen) {
493
+ if (zeile.startsWith('\0')) {
494
+ datum = zeile.slice(1);
495
+ continue;
496
+ }
497
+ if (zeile.length === 0 || datum === undefined)
498
+ continue;
499
+ for (const pfad of [...offen])
500
+ // Ein Wurzel-Skill (Pfad `''`, E11-Nachtrag) ist das ganze Repo: jede Datei zählt.
501
+ if (pfad === '' || zeile === pfad || zeile.startsWith(`${pfad}/`)) {
502
+ daten.set(pfad, datum);
503
+ offen.delete(pfad);
504
+ }
505
+ }
506
+ if (offen.size === 0)
507
+ kind.kill('SIGTERM');
508
+ });
509
+ });
510
+ }
511
+ /**
512
+ * Änderungsdatum je Skill (E70, F70=O1). Eine lokale Quelle hat keinen Basis-Clone, wohl aber
513
+ * meist eine Arbeitskopie (B11): dort zählt der HEAD-Stand auf der Platte, wie überall sonst bei
514
+ * lokalen Quellen. Ist der Pfad kein Git-Repo, bleibt die Karte leer und der Katalog nennt für
515
+ * diese Quelle kein Datum.
516
+ */
517
+ async letzteAenderungen(quelle, ref, pfade) {
518
+ if (quelle.art === 'lokal')
519
+ return await this.logDaten(quelle.pfad, 'HEAD', pfade);
520
+ const basis = await this.basis(quelle).catch(() => undefined);
521
+ if (basis === undefined)
522
+ return new Map();
523
+ return await this.logDaten(basis, ref, pfade);
524
+ }
377
525
  /**
378
526
  * Checkout von der Platte nehmen. Ein Worktree steht im Basis-Clone eingetragen, ein blankes
379
527
  * Löschen des Verzeichnisses ließe den Eintrag stehen und ein späterer `worktree add` auf