hmgr 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tim Kaufmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # hmgr
2
+
3
+ hmgr verwaltet Skills und Plugins für AI-Harnesses auf den Rechnern eines Nutzers. Ein Harness ist ein Agentenprogramm, das Skills aus Verzeichnissen lädt, derzeit Claude Code und Hermes Agent. hmgr holt Skills aus Quellen, legt sie als Links in Ziele (Harness plus Ort, also Home, Profil oder Arbeitsverzeichnis) und hält sie aktuell. Welche Skills und Plugins zu einem Ziel gehören, steht in einem Zeiger, der an das Ziel gebunden ist.
4
+
5
+ ## Installation
6
+
7
+ Das Paket liegt öffentlich auf npmjs, es enthält nur `dist/` und diese README.
8
+
9
+ ```
10
+ npm install -g hmgr
11
+ ```
12
+
13
+ Danach steht das Kommando `hmgr` bereit. Aus dem Repo geht es auch ohne Registry: `npm pack` baut `dist/` und packt es, `npm install -g hmgr-0.0.1.tgz` installiert das Tarball.
14
+
15
+ ## Entwicklung
16
+
17
+ Node ab 22.6, weil im Entwicklungsmodus `.ts` direkt ausgeführt wird.
18
+
19
+ ```
20
+ npm run dev -- <kommando>
21
+ npm test
22
+ npm run build
23
+ ```
24
+
25
+ `npm run dev` startet `src/cli/main.ts`, `npm run build` erzeugt `dist/`.
26
+
27
+ ## Kommandos
28
+
29
+ ```
30
+ source add <url|pfad> Quelle hinzufügen (URL wird geklont, Pfad bleibt Arbeitskopie)
31
+ source list Quellen zeigen
32
+ dir add <pfad> Arbeitsverzeichnis merken und an seinen Zeiger binden
33
+ dir forget <pfad> Arbeitsverzeichnis und Bindung vergessen, Links bleiben
34
+ dir list Arbeitsverzeichnisse mit ihrer Bindung zeigen
35
+ link <skill> Skill in ein Ziel verlinken
36
+ unlink <skill> Link entfernen
37
+ adopt [name] Fremde Links im Ziel durch eigene hmgr-Links ersetzen
38
+ plugin add <name> Plugin in ein Ziel legen (installieren oder Skills verlinken)
39
+ plugin remove <name> Plugin aus einem Ziel entfernen
40
+ plugin list Plugins der Marketplaces aller Quellen
41
+ sync [pfad] Ziel auf den Stand seines Zeigers bringen
42
+ update Alle Checkouts aktualisieren
43
+ status Ist-Zustand der Ziele
44
+ ```
45
+
46
+ Optionen:
47
+
48
+ ```
49
+ --harness <name> claude-code oder hermes, Standard claude-code
50
+ --ort home|dir:<pfad>|profil:<name> Standard home
51
+ --quelle <teilstring> Quelle bei mehrdeutigem Skill-Namen
52
+ --version <spanne> Versionseinschränkung nach Composer-Regeln, nur mit Zeiger
53
+ --composer <pfad> composer.json wählen, wenn mehrere in Frage kommen
54
+ --local Link nur ins Ziel legen, nicht in den Zeiger
55
+ --ersetzen fremden Eintrag am Ziel ersetzen
56
+ --probelauf bei adopt nur melden, nichts schreiben
57
+ ```
58
+
59
+ `hmgr help` zeigt dieselbe Übersicht.
@@ -0,0 +1,66 @@
1
+ // Pfadschema der Checkouts (E22): <wurzel>/<host>/<besitzer>/<repo>/<ref>/,
2
+ // je Repo ein Basis-Clone unter <wurzel>/<host>/<besitzer>/<repo>/.repo.
3
+ import path from 'node:path';
4
+ export const BASIS_NAME = '.repo';
5
+ /** Refs mit „/" im Namen werden im Pfad kodiert, damit ein Ref ein Verzeichnis bleibt. */
6
+ export function refKodieren(ref) {
7
+ return ref.replaceAll('%', '%25').replaceAll('/', '%2F');
8
+ }
9
+ export function refDekodieren(text) {
10
+ return text.replaceAll('%2F', '/').replaceAll('%25', '%');
11
+ }
12
+ function ohneGitSuffix(name) {
13
+ return name.endsWith('.git') ? name.slice(0, -4) : name;
14
+ }
15
+ function ausSegmenten(host, segmente) {
16
+ const gefiltert = segmente.filter((s) => s.length > 0);
17
+ const repo = ohneGitSuffix(gefiltert[gefiltert.length - 1] ?? 'repo');
18
+ const besitzer = gefiltert[gefiltert.length - 2] ?? '_';
19
+ return { host, besitzer, repo };
20
+ }
21
+ /** Zerlegt „git@host:besitzer/repo.git", „https://host/besitzer/repo", „file:///pfad" und nackte Pfade. */
22
+ export function urlZerlegen(url) {
23
+ const text = url.trim().replace(/\/+$/, '');
24
+ if (text.includes('://')) {
25
+ const u = new URL(text);
26
+ if (u.protocol === 'file:')
27
+ return ausSegmenten('local', decodeURIComponent(u.pathname).split('/'));
28
+ return ausSegmenten(u.hostname, decodeURIComponent(u.pathname).split('/'));
29
+ }
30
+ const scp = /^(?:[^@/]+@)?([^@/:]+):(.+)$/.exec(text);
31
+ if (scp)
32
+ return ausSegmenten(scp[1], scp[2].split('/'));
33
+ // Nackter lokaler Pfad: Besitzer aus dem Elternverzeichnis.
34
+ return ausSegmenten('local', path.resolve(text).split(path.sep));
35
+ }
36
+ export function quelleZerlegen(quelle) {
37
+ return urlZerlegen(quelle.art === 'git' ? quelle.url : quelle.pfad);
38
+ }
39
+ export function repoPfad(wurzel, ort) {
40
+ return path.join(wurzel, ort.host, ort.besitzer, ort.repo);
41
+ }
42
+ export function basisPfad(wurzel, ort) {
43
+ return path.join(repoPfad(wurzel, ort), BASIS_NAME);
44
+ }
45
+ export function worktreePfad(wurzel, ort, ref) {
46
+ return path.join(repoPfad(wurzel, ort), refKodieren(ref));
47
+ }
48
+ /** Liest aus einem Pfad unter der Wurzel Host, Besitzer, Repo, Ref und den Skill-Pfad zurück (E22). */
49
+ export function checkoutZerlegen(wurzel, pfad) {
50
+ const rel = path.relative(path.resolve(wurzel), path.resolve(pfad));
51
+ if (rel.startsWith('..') || path.isAbsolute(rel) || rel.length === 0)
52
+ return undefined;
53
+ const teile = rel.split(path.sep);
54
+ if (teile.length < 4)
55
+ return undefined;
56
+ const [host, besitzer, repo, refTeil, ...rest] = teile;
57
+ if (refTeil === BASIS_NAME)
58
+ return undefined;
59
+ const ort = { host, besitzer, repo };
60
+ return {
61
+ ort,
62
+ ref: refDekodieren(refTeil),
63
+ checkoutPfad: worktreePfad(wurzel, ort, refDekodieren(refTeil)),
64
+ skillPfad: rest.join('/'),
65
+ };
66
+ }
@@ -0,0 +1,184 @@
1
+ // Claude Code: Grundverhalten plus Plugins über die claude-CLI (E16, E24, E27).
2
+ import { execFile } from 'node:child_process';
3
+ import { mkdir, stat } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { OrtNichtUnterstuetzt, pluginSchluessel } from "../kern/modell.js";
7
+ import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
8
+ const GITIGNORE_ZEILE = '.claude/';
9
+ const ausfuehrenRoh = promisify(execFile);
10
+ /** Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme. */
11
+ export const claudeAusfuehrer = async (args, cwd) => {
12
+ try {
13
+ const { stdout } = await ausfuehrenRoh('claude', args, {
14
+ ...(cwd === undefined ? {} : { cwd }),
15
+ maxBuffer: 32 * 1024 * 1024,
16
+ });
17
+ return { stdout, code: 0 };
18
+ }
19
+ catch (fehler) {
20
+ const f = fehler;
21
+ return { stdout: `${f.stdout ?? ''}${f.stderr ?? ''}`, code: typeof f.code === 'number' ? f.code : 1 };
22
+ }
23
+ };
24
+ export class PluginListeUnbekannteForm extends Error {
25
+ constructor(text) {
26
+ super(`claude plugin list --json: ${text}`);
27
+ }
28
+ }
29
+ function istScope(wert) {
30
+ return wert === 'user' || wert === 'project' || wert === 'local';
31
+ }
32
+ /**
33
+ * `claude plugin list --json` liefert ein Array, je Eintrag `id` als „plugin@marketplace" und
34
+ * `scope` als user, project oder local. Alles andere wird abgelehnt statt geraten.
35
+ * Format geprüft gegen Claude Code 2.1.251 am 2026-08-30.
36
+ */
37
+ export function pluginListeLesen(text) {
38
+ let daten;
39
+ try {
40
+ daten = JSON.parse(text);
41
+ }
42
+ catch {
43
+ throw new PluginListeUnbekannteForm('kein JSON');
44
+ }
45
+ if (!Array.isArray(daten))
46
+ throw new PluginListeUnbekannteForm('kein Array');
47
+ return daten.map((roh) => {
48
+ if (typeof roh !== 'object' || roh === null || Array.isArray(roh))
49
+ throw new PluginListeUnbekannteForm('ein Eintrag ist kein Objekt');
50
+ const o = roh;
51
+ const id = o['id'];
52
+ if (typeof id !== 'string')
53
+ throw new PluginListeUnbekannteForm('ein Eintrag hat kein Feld „id"');
54
+ const trenner = id.lastIndexOf('@');
55
+ if (trenner <= 0 || trenner === id.length - 1)
56
+ throw new PluginListeUnbekannteForm(`„${id}" ist kein „plugin@marketplace"`);
57
+ const scope = o['scope'];
58
+ if (!istScope(scope))
59
+ throw new PluginListeUnbekannteForm(`„${id}" hat keinen bekannten Scope`);
60
+ return { name: id.slice(0, trenner), marketplace: id.slice(trenner + 1), scope };
61
+ });
62
+ }
63
+ /**
64
+ * `claude plugin marketplace list` schreibt je Marketplace eine mit `❯` eingeleitete Namenszeile,
65
+ * darunter eingerückt „Source: …". Nur die Namenszeilen zählen, die Source-Zeile nicht.
66
+ * Format geprüft gegen Claude Code 2.1.251 am 2026-08-30.
67
+ */
68
+ export function marketplaceListeLesen(text) {
69
+ const namen = [];
70
+ for (const zeile of text.split('\n')) {
71
+ const treffer = /^ *❯ +(.+)$/.exec(zeile);
72
+ if (treffer !== null)
73
+ namen.push(treffer[1].trim());
74
+ }
75
+ return namen;
76
+ }
77
+ /**
78
+ * Plugins über `claude plugin …`. Scope ist immer `local` mit dem Arbeitsverzeichnis selbst als
79
+ * cwd (E27). Nur das Home des Nutzers läuft als `--scope user` ohne cwd.
80
+ */
81
+ class ClaudePlugins {
82
+ harness;
83
+ ausfuehrer;
84
+ constructor(harness, ausfuehrer) {
85
+ this.harness = harness;
86
+ this.ausfuehrer = ausfuehrer;
87
+ }
88
+ async rufen(args, cwd) {
89
+ const { stdout, code } = await this.ausfuehrer(args, cwd);
90
+ if (code !== 0)
91
+ throw new Error(`claude ${args.join(' ')} scheiterte (Code ${code}): ${stdout.trim()}`);
92
+ return stdout;
93
+ }
94
+ /**
95
+ * Scope und cwd eines Ortes (E27). Das cwd ist das Arbeitsverzeichnis selbst, nicht sein
96
+ * Zielverzeichnis: außerhalb eines Git-Repos legt Claude Code die `settings.local.json` im
97
+ * Startverzeichnis ab, mit dem Zielverzeichnis als cwd landete sie unter `.claude/skills/`.
98
+ */
99
+ scope(ziel) {
100
+ if (ziel.ort.art === 'home')
101
+ return { scope: 'user' };
102
+ if (ziel.ort.art === 'arbeitsverzeichnis')
103
+ return { scope: 'local', cwd: ziel.ort.pfad };
104
+ throw new OrtNichtUnterstuetzt(this.harness.name, ziel.ort.art);
105
+ }
106
+ async marketplaceBekannt(name) {
107
+ const { stdout, code } = await this.ausfuehrer(['plugin', 'marketplace', 'list']);
108
+ if (code !== 0)
109
+ return false;
110
+ return marketplaceListeLesen(stdout).includes(name);
111
+ }
112
+ async marketplaceHinzufuegen(_name, url) {
113
+ await this.rufen(['plugin', 'marketplace', 'add', url]);
114
+ }
115
+ async installieren(plugin, marketplace, ziel) {
116
+ const { scope, cwd } = this.scope(ziel);
117
+ await this.rufen(['plugin', 'install', pluginSchluessel({ name: plugin, marketplace }), '--scope', scope], cwd);
118
+ }
119
+ async entfernen(plugin, marketplace, ziel) {
120
+ const { scope, cwd } = this.scope(ziel);
121
+ await this.rufen(['plugin', 'uninstall', pluginSchluessel({ name: plugin, marketplace }), '--scope', scope], cwd);
122
+ }
123
+ async aktualisieren() {
124
+ await this.rufen(['plugin', 'update']);
125
+ }
126
+ /**
127
+ * Die Liste gilt nur für das cwd des Aufrufs und enthält dort alle Scopes. Zum Ort gehören im
128
+ * Home die Plugins mit Scope `user`, im Arbeitsverzeichnis die mit `local` und `project`.
129
+ */
130
+ async installierte(ziel) {
131
+ const { cwd } = this.scope(ziel);
132
+ const { stdout, code } = await this.ausfuehrer(['plugin', 'list', '--json'], cwd);
133
+ if (code !== 0)
134
+ return [];
135
+ const erlaubt = ziel.ort.art === 'home' ? ['user'] : ['local', 'project'];
136
+ return pluginListeLesen(stdout)
137
+ .filter((eintrag) => erlaubt.includes(eintrag.scope))
138
+ .map((eintrag) => pluginSchluessel(eintrag));
139
+ }
140
+ }
141
+ export class ClaudeCode {
142
+ name = 'claude-code';
143
+ home;
144
+ ausfuehrer;
145
+ /** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt. */
146
+ plugins;
147
+ geprueft = false;
148
+ constructor(home, ausfuehrer = claudeAusfuehrer) {
149
+ this.home = home;
150
+ this.ausfuehrer = ausfuehrer;
151
+ }
152
+ /**
153
+ * Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin-Fähigkeit
154
+ * kommt dazu, sobald `claude --version` durchläuft: ohne CLI gibt es keine Plugins.
155
+ */
156
+ async vorhanden() {
157
+ const da = (await stat(path.join(this.home, '.claude')).catch(() => undefined)) !== undefined;
158
+ if (!this.geprueft) {
159
+ this.geprueft = true;
160
+ const { code } = await this.ausfuehrer(['--version']).catch(() => ({ code: 1 }));
161
+ if (code === 0)
162
+ this.plugins = new ClaudePlugins(this, this.ausfuehrer);
163
+ }
164
+ return da;
165
+ }
166
+ orte() {
167
+ return ['home', 'arbeitsverzeichnis'];
168
+ }
169
+ zielVerzeichnis(ort) {
170
+ if (ort.art === 'home')
171
+ return path.join(this.home, '.claude', 'skills');
172
+ if (ort.art === 'arbeitsverzeichnis')
173
+ return path.join(ort.pfad, '.claude', 'skills');
174
+ throw new OrtNichtUnterstuetzt(this.name, ort.art);
175
+ }
176
+ async vorbereiten(ort) {
177
+ await mkdir(this.zielVerzeichnis(ort), { recursive: true });
178
+ if (ort.art === 'arbeitsverzeichnis' && (await istGitRepo(ort.pfad)))
179
+ await gitignoreEintragen(ort.pfad, GITIGNORE_ZEILE);
180
+ }
181
+ folgtLinks() {
182
+ return true;
183
+ }
184
+ }
@@ -0,0 +1,94 @@
1
+ // Zeiger in der composer.json des Repos, unter extra.hmgr (B8, E25, E26).
2
+ import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { quelleAusText } from "./quelle-text.js";
5
+ const DATEI = 'composer.json';
6
+ /** Ruft hmgr nur auf, wenn es vorhanden ist, und lässt composer nie scheitern (E20). */
7
+ export const SCRIPT_EINTRAG = 'command -v hmgr >/dev/null 2>&1 && hmgr sync || true';
8
+ const SCRIPT_SCHLUESSEL = ['post-install-cmd', 'post-update-cmd'];
9
+ async function istDatei(pfad) {
10
+ const info = await stat(pfad).catch(() => undefined);
11
+ return info !== undefined && info.isFile();
12
+ }
13
+ function quelleAusEintrag(eintrag, verzeichnis) {
14
+ if (typeof eintrag === 'string')
15
+ return quelleAusText(eintrag);
16
+ return { art: 'lokal', pfad: path.resolve(verzeichnis, eintrag.path) };
17
+ }
18
+ function quelleAlsEintrag(quelle) {
19
+ return quelle.art === 'git' ? quelle.url : { path: quelle.pfad };
20
+ }
21
+ export class ComposerZeiger {
22
+ async finden(verzeichnis) {
23
+ const eigen = path.join(verzeichnis, DATEI);
24
+ if (await istDatei(eigen))
25
+ return { ergebnis: 'gefunden', pfad: eigen };
26
+ const eintraege = await readdir(verzeichnis, { withFileTypes: true }).catch(() => []);
27
+ const kandidaten = [];
28
+ for (const eintrag of eintraege) {
29
+ if (!eintrag.isDirectory())
30
+ continue;
31
+ const pfad = path.join(verzeichnis, eintrag.name, DATEI);
32
+ if (await istDatei(pfad))
33
+ kandidaten.push(pfad);
34
+ }
35
+ kandidaten.sort();
36
+ if (kandidaten.length === 1)
37
+ return { ergebnis: 'gefunden', pfad: kandidaten[0] };
38
+ if (kandidaten.length === 0)
39
+ return { ergebnis: 'keiner' };
40
+ return { ergebnis: 'mehrdeutig', kandidaten };
41
+ }
42
+ async lesen(pfad) {
43
+ const daten = JSON.parse(await readFile(pfad, 'utf8'));
44
+ const eintrag = daten.extra?.hmgr ?? {};
45
+ const verzeichnis = path.dirname(pfad);
46
+ return {
47
+ quellen: (eintrag.sources ?? []).map((q) => quelleAusEintrag(q, verzeichnis)),
48
+ skills: new Map(Object.entries(eintrag.skills ?? {})),
49
+ plugins: eintrag.plugins ?? [],
50
+ };
51
+ }
52
+ async schreiben(pfad, zeiger) {
53
+ const daten = JSON.parse(await readFile(pfad, 'utf8'));
54
+ const eintrag = {
55
+ sources: zeiger.quellen.map(quelleAlsEintrag),
56
+ skills: Object.fromEntries(zeiger.skills),
57
+ plugins: zeiger.plugins,
58
+ };
59
+ daten.extra = { ...(daten.extra ?? {}), hmgr: eintrag };
60
+ await writeFile(pfad, `${JSON.stringify(daten, null, 4)}\n`);
61
+ }
62
+ /**
63
+ * Trägt den hmgr-Aufruf in post-install-cmd und post-update-cmd ein (E20). Bestehende Einträge
64
+ * und ihre Reihenfolge bleiben, der Rest der Datei bleibt unberührt. Idempotent.
65
+ */
66
+ async scriptEintragen(pfad) {
67
+ const daten = JSON.parse(await readFile(pfad, 'utf8'));
68
+ const scripts = { ...(daten.scripts ?? {}) };
69
+ let geaendert = false;
70
+ for (const schluessel of SCRIPT_SCHLUESSEL) {
71
+ const vorher = scripts[schluessel];
72
+ if (vorher === undefined) {
73
+ scripts[schluessel] = [SCRIPT_EINTRAG];
74
+ geaendert = true;
75
+ }
76
+ else if (typeof vorher === 'string') {
77
+ if (vorher === SCRIPT_EINTRAG)
78
+ continue;
79
+ scripts[schluessel] = [vorher, SCRIPT_EINTRAG];
80
+ geaendert = true;
81
+ }
82
+ else if (Array.isArray(vorher)) {
83
+ if (vorher.includes(SCRIPT_EINTRAG))
84
+ continue;
85
+ scripts[schluessel] = [...vorher, SCRIPT_EINTRAG];
86
+ geaendert = true;
87
+ }
88
+ }
89
+ if (!geaendert)
90
+ return;
91
+ daten.scripts = scripts;
92
+ await writeFile(pfad, `${JSON.stringify(daten, null, 4)}\n`);
93
+ }
94
+ }
@@ -0,0 +1,126 @@
1
+ // Ziele im Dateisystem: Links legen, lesen und entfernen. Fremde Einträge bleiben unangetastet (E12).
2
+ import { lstat, mkdir, readFile, readdir, readlink, rm, stat, symlink, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { NameBelegt } from "../kern/modell.js";
5
+ import { checkoutZerlegen, quelleZerlegen } from "./checkout-pfad.js";
6
+ import { LOKAL_REF } from "./git.js";
7
+ import { SKILL_DATEI, skillName } from "./skill-md.js";
8
+ export class DateisystemZiele {
9
+ wurzel;
10
+ zustand;
11
+ constructor(wurzel, zustand) {
12
+ this.wurzel = wurzel;
13
+ this.zustand = zustand;
14
+ }
15
+ async quellen() {
16
+ return (await this.zustand.lesen()).quellen;
17
+ }
18
+ /**
19
+ * Der Skill hinter einem Linkziel. Der Name kommt aus der SKILL.md am Linkziel, nicht aus dem
20
+ * Namen des Eintrags im Ziel: derselbe Skill soll überall gleich heißen, auch wenn ein Eintrag
21
+ * von Hand umbenannt wurde (E32).
22
+ */
23
+ async skill(zielPfad, skillPfad, ersatz) {
24
+ const verzeichnisname = skillPfad.split('/').filter((t) => t.length > 0).at(-1) ?? ersatz;
25
+ const inhalt = await readFile(path.join(zielPfad, SKILL_DATEI), 'utf8').catch(() => undefined);
26
+ return { name: skillName(verzeichnisname, inhalt), pfad: skillPfad };
27
+ }
28
+ /** Liest aus dem Linkziel Quelle, Ref und Skill zurück. Undefined heißt: kein eigener Link. */
29
+ async deuten(zielPfad) {
30
+ const quellen = await this.quellen();
31
+ const teile = checkoutZerlegen(this.wurzel, zielPfad);
32
+ if (teile) {
33
+ const bekannt = quellen.find((q) => {
34
+ const o = quelleZerlegen(q);
35
+ return (q.art === 'git' && o.host === teile.ort.host && o.besitzer === teile.ort.besitzer && o.repo === teile.ort.repo);
36
+ });
37
+ // Ohne bekannte Quelle bleibt es ein eigener Link, dessen Quelle unbekannt ist. Keine URL raten.
38
+ if (!bekannt)
39
+ return { art: 'link-ohne-quelle', checkoutPfad: teile.checkoutPfad };
40
+ return {
41
+ art: 'link',
42
+ checkout: { quelle: bekannt, ref: teile.ref, pfad: teile.checkoutPfad },
43
+ skill: await this.skill(zielPfad, teile.skillPfad, teile.ort.repo),
44
+ };
45
+ }
46
+ // Außerhalb der Wurzel: nur bekannte lokale Quellen sind eigene Links (B11).
47
+ for (const quelle of quellen) {
48
+ if (quelle.art !== 'lokal')
49
+ continue;
50
+ const basis = path.resolve(quelle.pfad);
51
+ const rel = path.relative(basis, zielPfad);
52
+ if (rel.startsWith('..') || path.isAbsolute(rel))
53
+ continue;
54
+ const skillPfad = rel.split(path.sep).join('/');
55
+ return {
56
+ art: 'link',
57
+ checkout: { quelle, ref: LOKAL_REF, pfad: basis },
58
+ skill: await this.skill(zielPfad, skillPfad, path.basename(basis)),
59
+ };
60
+ }
61
+ return undefined;
62
+ }
63
+ async linkZiel(verzeichnis, name) {
64
+ const pfad = path.join(verzeichnis, name);
65
+ const info = await lstat(pfad).catch(() => undefined);
66
+ if (!info || !info.isSymbolicLink())
67
+ return undefined;
68
+ return path.resolve(verzeichnis, await readlink(pfad));
69
+ }
70
+ /**
71
+ * Ein Skill ist ein Verzeichnis. Dateien wie .DS_Store zählen nicht als fremder Eintrag,
72
+ * ebenso wenig ein Symlink, der auf keine Verzeichnis zeigt.
73
+ */
74
+ async istVerzeichnis(verzeichnis, name) {
75
+ const pfad = path.join(verzeichnis, name);
76
+ const info = await lstat(pfad).catch(() => undefined);
77
+ if (info === undefined)
78
+ return false;
79
+ if (info.isDirectory())
80
+ return true;
81
+ if (!info.isSymbolicLink())
82
+ return false;
83
+ return (await stat(pfad).catch(() => undefined))?.isDirectory() === true;
84
+ }
85
+ async eintraege(ziel, verzeichnis) {
86
+ const namen = await readdir(verzeichnis).catch(() => []);
87
+ const ergebnis = [];
88
+ for (const name of namen.sort()) {
89
+ if (!(await this.istVerzeichnis(verzeichnis, name)))
90
+ continue;
91
+ const zielPfad = await this.linkZiel(verzeichnis, name);
92
+ const gedeutet = zielPfad ? await this.deuten(zielPfad) : undefined;
93
+ if (gedeutet?.art === 'link')
94
+ ergebnis.push({ art: 'link', link: { ziel, name, checkout: gedeutet.checkout, skill: gedeutet.skill } });
95
+ else if (gedeutet)
96
+ ergebnis.push({ art: 'link-ohne-quelle', name, checkoutPfad: gedeutet.checkoutPfad });
97
+ // Ist der fremde Eintrag ein Symlink, wandert sein Ziel mit: darauf setzt die Übernahme auf (B12).
98
+ else
99
+ ergebnis.push({ art: 'fremd', name, ...(zielPfad === undefined ? {} : { linkZiel: zielPfad }) });
100
+ }
101
+ return ergebnis;
102
+ }
103
+ async linkLegen(ziel, verzeichnis, name, checkout, skill, ersetzen) {
104
+ await mkdir(verzeichnis, { recursive: true });
105
+ const pfad = path.join(verzeichnis, name);
106
+ const info = await lstat(pfad).catch(() => undefined);
107
+ if (info) {
108
+ const zielPfad = await this.linkZiel(verzeichnis, name);
109
+ const eigen = zielPfad ? await this.deuten(zielPfad) : undefined;
110
+ if (eigen)
111
+ await unlink(pfad);
112
+ else if (ersetzen)
113
+ await rm(pfad, { recursive: true, force: true });
114
+ else
115
+ throw new NameBelegt(ziel, name);
116
+ }
117
+ await symlink(path.join(checkout.pfad, skill.pfad), pfad);
118
+ }
119
+ async linkEntfernen(_ziel, verzeichnis, name) {
120
+ const zielPfad = await this.linkZiel(verzeichnis, name);
121
+ if (!zielPfad)
122
+ return;
123
+ if (await this.deuten(zielPfad))
124
+ await unlink(path.join(verzeichnis, name));
125
+ }
126
+ }