hmgr 0.0.4 → 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.
- package/dist/adapter/claude-code.js +21 -17
- package/dist/adapter/dateisystem.js +105 -5
- package/dist/adapter/git.js +96 -10
- package/dist/adapter/hermes.js +163 -10
- package/dist/adapter/skill-md.js +94 -18
- package/dist/adapter/terminal.js +1 -1
- package/dist/adapter/zustand-json.js +4 -0
- package/dist/cli/hilfe.js +129 -26
- package/dist/cli/main.js +268 -20
- package/dist/gui/icons.js +114 -0
- package/dist/gui/main.js +481 -0
- package/dist/gui/oeffnen.js +33 -0
- package/dist/gui/preload.cjs +55 -0
- package/dist/gui/renderer/fonts/Inter-LICENSE.txt +92 -0
- package/dist/gui/renderer/fonts/InterVariable.woff2 +0 -0
- package/dist/gui/renderer/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/dist/gui/renderer/fonts/JetBrainsMono-OFL.txt +93 -0
- package/dist/gui/renderer/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/dist/gui/renderer/index.html +491 -0
- package/dist/gui/renderer/renderer.js +2611 -0
- package/dist/gui/renderer/styles.css +638 -0
- package/dist/kern/anwendungsfaelle.js +688 -65
- package/dist/kern/modell.js +159 -0
- package/dist/ports/harness.js +13 -0
- package/dist/ports/zustand.js +1 -1
- package/package.json +5 -2
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
// Claude Code: Grundverhalten plus Plugins über die claude-CLI (E16, E24, E27).
|
|
2
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/adapter/git.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// Git über die CLI, keine Bibliothek. Checkout-Schema und Worktrees nach E22, Discovery nach E11.
|
|
2
|
-
import {
|
|
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 { 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
|
-
|
|
120
|
-
|
|
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));
|
|
@@ -265,8 +286,10 @@ export class GitQuellen {
|
|
|
265
286
|
for (const eintrag of vorhanden)
|
|
266
287
|
if (!WURZEL_MUSTER.includes(eintrag))
|
|
267
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.
|
|
268
291
|
for (const skill of skills)
|
|
269
|
-
muster.add(`/${skill.pfad}/`);
|
|
292
|
+
muster.add(skill.pfad === '' ? '/**' : `/${skill.pfad}/`);
|
|
270
293
|
await this.git(['sparse-checkout', 'set', '--no-cone', '--', ...muster], pfad);
|
|
271
294
|
if (neu)
|
|
272
295
|
await this.git(['checkout', '--detach', ref], pfad);
|
|
@@ -300,6 +323,12 @@ export class GitQuellen {
|
|
|
300
323
|
}
|
|
301
324
|
return ergebnis;
|
|
302
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
|
+
*/
|
|
303
332
|
async aktualisieren(checkout) {
|
|
304
333
|
if (checkout.quelle.art === 'lokal')
|
|
305
334
|
return { ergebnis: 'unveraendert' };
|
|
@@ -307,7 +336,12 @@ export class GitQuellen {
|
|
|
307
336
|
return { ergebnis: 'uebersprungen', grund: 'lokale-aenderung' }; // E15
|
|
308
337
|
const basis = await this.basis(checkout.quelle);
|
|
309
338
|
const von = (await this.git(['rev-parse', 'HEAD'], checkout.pfad)).trim();
|
|
310
|
-
|
|
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
|
+
}
|
|
311
345
|
const nach = (await this.git(['rev-parse', checkout.ref], basis)).trim();
|
|
312
346
|
if (von === nach)
|
|
313
347
|
return { ergebnis: 'unveraendert' };
|
|
@@ -357,10 +391,62 @@ export class GitQuellen {
|
|
|
357
391
|
relativerPfad: path.relative(wurzel, echt).split(path.sep).join('/'),
|
|
358
392
|
};
|
|
359
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
|
+
*/
|
|
360
398
|
async lokaleAenderungen(checkout) {
|
|
361
399
|
if (checkout.quelle.art === 'lokal')
|
|
362
400
|
return false;
|
|
363
|
-
const ausgabe = await this.git(['status', '--porcelain'], checkout.pfad);
|
|
401
|
+
const ausgabe = await this.git(['--no-optional-locks', 'status', '--porcelain'], checkout.pfad);
|
|
364
402
|
return ausgabe.trim().length > 0;
|
|
365
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
|
+
}
|
|
366
452
|
}
|
package/dist/adapter/hermes.js
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
},
|