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.
- package/dist/adapter/agents.js +14 -5
- package/dist/adapter/bundle-yaml.js +87 -0
- package/dist/adapter/claude-code.js +53 -12
- package/dist/adapter/dateisystem.js +85 -43
- package/dist/adapter/git.js +148 -0
- package/dist/adapter/heime.js +76 -0
- package/dist/adapter/hermes.js +16 -7
- package/dist/adapter/npm-registry.js +61 -0
- package/dist/adapter/paket-version.js +25 -0
- package/dist/adapter/umgebung.js +6 -1
- package/dist/adapter/verlauf-json.js +39 -0
- package/dist/adapter/wsl.js +212 -0
- package/dist/adapter/zustand-json.js +40 -12
- package/dist/cli/hilfe.js +197 -11
- package/dist/cli/main.js +376 -21
- package/dist/gui/hmgr-cli.js +44 -0
- package/dist/gui/main.js +445 -4
- package/dist/gui/preload.cjs +30 -2
- package/dist/gui/renderer/index.html +79 -16
- package/dist/gui/renderer/renderer.js +807 -286
- package/dist/gui/renderer/styles.css +89 -16
- package/dist/kern/anwendungsfaelle.js +687 -18
- package/dist/kern/modell.js +192 -1
- package/dist/kern/version.js +13 -0
- package/dist/ports/dateizugriff.js +1 -0
- package/dist/ports/harness.js +4 -0
- package/dist/ports/heime.js +1 -0
- package/dist/ports/verlauf.js +4 -0
- package/dist/ports/zustand.js +16 -1
- package/package.json +26 -5
package/dist/adapter/agents.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
158
|
+
* ebenso wenig ein Symlink, der auf kein Verzeichnis zeigt.
|
|
120
159
|
*/
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
// `
|
|
153
|
-
if (name.length === 0 || name.includes('/') || name.includes(
|
|
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
|
|
156
|
-
const pfad =
|
|
157
|
-
const info = await
|
|
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
|
|
193
|
+
const zielPfad = await zugriff.linkZiel(pfad);
|
|
160
194
|
const eigen = zielPfad ? await this.deuten(zielPfad) : undefined;
|
|
161
195
|
if (eigen)
|
|
162
|
-
await
|
|
196
|
+
await zugriff.linkEntfernen(pfad);
|
|
163
197
|
else if (ersetzen)
|
|
164
|
-
await
|
|
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
|
|
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(
|
|
181
|
-
const
|
|
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
|
|
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(
|
|
193
|
-
const
|
|
194
|
-
const
|
|
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.
|
|
198
|
-
await
|
|
234
|
+
if (info.istLink)
|
|
235
|
+
await zugriff.linkEntfernen(pfad);
|
|
199
236
|
else
|
|
200
|
-
await
|
|
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
|
package/dist/adapter/git.js
CHANGED
|
@@ -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
|