hmgr 0.0.4 → 0.0.6
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/README.md +10 -0
- package/dist/adapter/checkout-pfad.js +10 -2
- package/dist/adapter/claude-code.js +251 -23
- package/dist/adapter/composer.js +4 -3
- package/dist/adapter/datei-text.js +18 -0
- package/dist/adapter/dateisystem.js +146 -8
- package/dist/adapter/git.js +129 -17
- package/dist/adapter/hermes.js +235 -10
- package/dist/adapter/marketplace-json.js +2 -1
- package/dist/adapter/mcp-katalog.js +124 -0
- package/dist/adapter/quelle-text.js +10 -1
- package/dist/adapter/skill-md.js +96 -19
- package/dist/adapter/terminal.js +1 -1
- package/dist/adapter/umgebung.js +43 -0
- package/dist/adapter/vergleich.js +20 -0
- package/dist/adapter/zustand-json.js +6 -1
- package/dist/cli/hilfe.js +227 -26
- package/dist/cli/main.js +362 -20
- package/dist/gui/beobachter.js +108 -0
- package/dist/gui/icons.js +114 -0
- package/dist/gui/login-pfad.js +120 -0
- package/dist/gui/main.js +823 -0
- package/dist/gui/mcp-ansicht.js +57 -0
- package/dist/gui/oeffnen.js +33 -0
- package/dist/gui/preload.cjs +70 -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 +581 -0
- package/dist/gui/renderer/renderer.js +3227 -0
- package/dist/gui/renderer/styles.css +701 -0
- package/dist/kern/anwendungsfaelle.js +859 -65
- package/dist/kern/modell.js +297 -0
- package/dist/kern/quelle-ort.js +14 -1
- package/dist/ports/harness.js +16 -0
- package/dist/ports/mcp-katalog.js +1 -0
- package/dist/ports/umgebung.js +1 -0
- package/dist/ports/zustand.js +1 -1
- package/package.json +47 -2
package/README.md
CHANGED
|
@@ -24,6 +24,16 @@ npm run build
|
|
|
24
24
|
|
|
25
25
|
`npm run dev` startet `src/cli/main.ts`, `npm run build` erzeugt `dist/`.
|
|
26
26
|
|
|
27
|
+
## GUI
|
|
28
|
+
|
|
29
|
+
`npm run gui` startet die Electron-Oberfläche im Dev-Modus (`electron dist/gui/main.js`).
|
|
30
|
+
|
|
31
|
+
`npm run gui:app` baut ein macOS-App-Bundle „hmgr.app" mit eigenem Icon und Namen im Dock/Finder (statt „Electron"), über [electron-builder](https://www.electron.build/). Das Ergebnis liegt in `release/mac-arm64/hmgr.app`, dazu ein `.zip` zum Weitergeben. Nur arm64, unsigniert, keine Notarisierung, kein Auto-Update — für den lokalen Gebrauch. Das Icon (`build/icon.icns`) entsteht aus `entwurf/icon/icon-1024.png`.
|
|
32
|
+
|
|
33
|
+
`npm run gui:app:win` baut den Windows-Installer, auch auf dem Mac: NSIS braucht dort kein Wine, electron-builder lädt sich die nötigen Teile selbst. Es entsteht `release/hmgr-Setup-<version>-x64.exe` (rund 107 MiB) samt `.blockmap`, daneben die ungepackte App in `release/win-unpacked/`. Nur x64, installiert wird pro Benutzer nach `%LOCALAPPDATA%\Programs\hmgr` — ohne Administratorrechte, das Zielverzeichnis lässt sich im Installer ändern. Das Windows-Icon (`build/icon.ico`) erzeugt `entwurf/icon/erzeuge-ico.py` aus derselben `icon-1024.png`.
|
|
34
|
+
|
|
35
|
+
Der Installer ist nicht signiert. Windows SmartScreen meldet sich deshalb beim ersten Start mit „Der Computer wurde durch Windows geschützt"; der Weg dahinter führt über „Weitere Informationen" und „Trotzdem ausführen".
|
|
36
|
+
|
|
27
37
|
## Kommandos
|
|
28
38
|
|
|
29
39
|
`hmgr --help` zeigt die Kommandos, `hmgr help <kommando>` Schalter, Beispiele
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pfadschema der Checkouts (E22): <wurzel>/<host>/<besitzer>/<repo>/<ref>/,
|
|
2
2
|
// je Repo ein Basis-Clone unter <wurzel>/<host>/<besitzer>/<repo>/.repo.
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { ortAusSegmenten, urlOrt } from "../kern/quelle-ort.js";
|
|
4
|
+
import { istWindowsLaufwerkspfad, ortAusSegmenten, urlOrt } from "../kern/quelle-ort.js";
|
|
5
5
|
export const BASIS_NAME = '.repo';
|
|
6
6
|
/** Refs mit „/" im Namen werden im Pfad kodiert, damit ein Ref ein Verzeichnis bleibt. */
|
|
7
7
|
export function refKodieren(ref) {
|
|
@@ -15,8 +15,16 @@ export function urlZerlegen(url) {
|
|
|
15
15
|
const ort = urlOrt(url);
|
|
16
16
|
if (ort !== undefined)
|
|
17
17
|
return ort;
|
|
18
|
+
const text = url.trim();
|
|
19
|
+
// Windows-Laufwerkspfad (E65/B12): an beiden Trennern zerlegen und ohne den Laufwerksbuchstaben,
|
|
20
|
+
// denn ein Doppelpunkt ist unter Windows kein erlaubtes Zeichen in einem Verzeichnisnamen —
|
|
21
|
+
// „C:" als Besitzer ergäbe einen Checkout-Pfad, den das Dateisystem nicht anlegen kann. Ohne das
|
|
22
|
+
// Laufwerk ist es derselbe Fall wie ein POSIX-Pfad, dessen erstes Segment leer ist. Bewusst ohne
|
|
23
|
+
// `path.resolve`: die Zerlegung soll auf jeder Plattform dasselbe ergeben.
|
|
24
|
+
if (istWindowsLaufwerkspfad(text))
|
|
25
|
+
return ortAusSegmenten('local', text.slice(2).split(/[\\/]/));
|
|
18
26
|
// Nackter lokaler Pfad: Besitzer aus dem Elternverzeichnis.
|
|
19
|
-
return ortAusSegmenten('local', path.resolve(
|
|
27
|
+
return ortAusSegmenten('local', path.resolve(text).split(path.sep));
|
|
20
28
|
}
|
|
21
29
|
export function quelleZerlegen(quelle) {
|
|
22
30
|
return urlZerlegen(quelle.art === 'git' ? quelle.url : quelle.pfad);
|
|
@@ -1,26 +1,42 @@
|
|
|
1
|
-
// Claude Code: Grundverhalten plus Plugins über die claude-CLI (E16, E24, E27).
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
// Claude Code: Grundverhalten plus Plugins und MCP-Server über die claude-CLI (E16, E24, E27, E61).
|
|
2
|
+
import spawn from 'cross-spawn';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { mkdir, readFile, stat } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { McpAbweichenderEintrag, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, OrtNichtUnterstuetzt, pluginSchluessel, } from "../kern/modell.js";
|
|
7
|
+
import { ohneBom } from "./datei-text.js";
|
|
7
8
|
import { gitignoreEintragen, istGitRepo } from "./gitignore.js";
|
|
9
|
+
import { tiefGleich } from "./vergleich.js";
|
|
8
10
|
const GITIGNORE_ZEILE = '.claude/';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Standard-Ausführer: `claude` vom PATH. Ein Fehlschlag wird zum Code, nicht zur Ausnahme.
|
|
13
|
+
* stdin ist zu, sonst hängt eine CLI, die auf Eingabe wartet, den ganzen Ablauf; der Timeout
|
|
14
|
+
* fängt alles Übrige (beobachtet: 13 Minuten ohne Rückkehr in der GUI). SIGKILL, weil die
|
|
15
|
+
* CLI SIGTERM überleben kann.
|
|
16
|
+
*
|
|
17
|
+
* `spawn` kommt von cross-spawn, nicht aus node:child_process (E65/B7): unter Windows ist die
|
|
18
|
+
* npm-installierte `claude` ein `.cmd`-Shim, und Node startet `.cmd`/`.bat` seit dem Fix zu
|
|
19
|
+
* CVE-2024-27980 ohne `shell: true` nicht mehr (EINVAL). Der error-Handler machte daraus Code 1,
|
|
20
|
+
* `vorhanden()` hielt die CLI für abwesend und alle MCP- und Plugin-Fähigkeiten fielen weg.
|
|
21
|
+
* cross-spawn setzt den Aufruf selbst auf cmd.exe um und quotet dabei richtig — nötig für
|
|
22
|
+
* Argumente wie das JSON von `mcp add-json`. Auf macOS und Linux ändert sich nichts.
|
|
23
|
+
*/
|
|
24
|
+
export const claudeAusfuehrer = (args, cwd) => new Promise((aufloesen) => {
|
|
25
|
+
const kind = spawn('claude', args, {
|
|
26
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
timeout: 120_000,
|
|
29
|
+
killSignal: 'SIGKILL',
|
|
30
|
+
});
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
// `!`, weil cross-spawn nur den allgemeinen ChildProcess-Typ mit nullbaren Strömen kennt;
|
|
34
|
+
// das `stdio` oben verlangt für beide eine Pipe, also sind sie da.
|
|
35
|
+
kind.stdout.on('data', (d) => (stdout += d));
|
|
36
|
+
kind.stderr.on('data', (d) => (stderr += d));
|
|
37
|
+
kind.on('error', (fehler) => aufloesen({ stdout: fehler.message, code: 1 }));
|
|
38
|
+
kind.on('close', (code) => aufloesen({ stdout: code === 0 ? stdout : `${stdout}${stderr}`, code: code ?? 1 }));
|
|
39
|
+
});
|
|
24
40
|
export class PluginListeUnbekannteForm extends Error {
|
|
25
41
|
constructor(text) {
|
|
26
42
|
super(`claude plugin list --json: ${text}`);
|
|
@@ -139,28 +155,240 @@ class ClaudePlugins {
|
|
|
139
155
|
.map((eintrag) => pluginSchluessel(eintrag));
|
|
140
156
|
}
|
|
141
157
|
}
|
|
158
|
+
/** Der JSON-Block für `claude mcp add-json`, aus dem neutralen Katalog-Konfigblock (E61). */
|
|
159
|
+
export function claudeMcpJson(konfig) {
|
|
160
|
+
if ('command' in konfig)
|
|
161
|
+
return JSON.stringify({
|
|
162
|
+
type: 'stdio',
|
|
163
|
+
command: konfig.command,
|
|
164
|
+
...(konfig.args === undefined ? {} : { args: konfig.args }),
|
|
165
|
+
...(konfig.env === undefined ? {} : { env: konfig.env }),
|
|
166
|
+
});
|
|
167
|
+
return JSON.stringify({
|
|
168
|
+
type: 'http',
|
|
169
|
+
url: konfig.url,
|
|
170
|
+
...(konfig.headers === undefined ? {} : { headers: konfig.headers }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function objektFeld(daten, feld) {
|
|
174
|
+
if (typeof daten !== 'object' || daten === null)
|
|
175
|
+
return undefined;
|
|
176
|
+
return daten[feld];
|
|
177
|
+
}
|
|
178
|
+
function mcpServersVon(daten) {
|
|
179
|
+
const mcp = objektFeld(daten, 'mcpServers');
|
|
180
|
+
if (typeof mcp !== 'object' || mcp === null || Array.isArray(mcp))
|
|
181
|
+
return {};
|
|
182
|
+
return mcp;
|
|
183
|
+
}
|
|
184
|
+
/** Der aufgelöste Pfad, oder der Pfad selbst, wenn es ihn nicht (mehr) gibt. */
|
|
185
|
+
function aufgeloest(pfad) {
|
|
186
|
+
try {
|
|
187
|
+
return realpathSync(pfad);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return pfad;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Die MCP-Server der geteilten `.mcp.json` eines Arbeitsverzeichnisses (E61-Nachtrag, Format
|
|
195
|
+
* `{"mcpServers": {<name>: {...}}}`, wie `~/.claude.json`). Nur gelesen, nie geschrieben: fehlende
|
|
196
|
+
* Datei heißt nichts, kaputtes JSON oder fremde Struktur ebenso, derselbe tolerante Umgang wie beim
|
|
197
|
+
* Lesen von `~/.claude.json`. Eine BOM zählt nicht als kaputt (E65/B11), sonst wirkt eine unter
|
|
198
|
+
* Windows angelegte Datei leer.
|
|
199
|
+
*/
|
|
200
|
+
async function projektMcpJsonLesen(verzeichnis) {
|
|
201
|
+
try {
|
|
202
|
+
const text = await readFile(path.join(verzeichnis, '.mcp.json'), 'utf8');
|
|
203
|
+
return mcpServersVon(JSON.parse(ohneBom(text)));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* MCP-Server über `claude mcp …` (E61). Schreiben ausschließlich über die CLI, dieselbe
|
|
211
|
+
* Ausführer-Infrastruktur wie bei Plugins. Für die Übersicht wird NUR `~/.claude.json` gelesen, nie
|
|
212
|
+
* geschrieben: user-Scope unter `mcpServers`, local-Scope je Projekt unter
|
|
213
|
+
* `projects.<pfad>.mcpServers`. Die geteilte `.mcp.json` in der Projektwurzel bleibt beim Ein- und
|
|
214
|
+
* Aushängen unangetastet (E57), `geteilteFundorte` liest sie für die Übersicht mit (E61-Nachtrag);
|
|
215
|
+
* geschrieben wird sie einzig von `geteiltenUebernehmen` auf ausdrücklichen Auftrag (E64). Format
|
|
216
|
+
* und Verhalten der CLI stehen in HARNESS-FAKTEN.md, geprüft an Claude Code 2.1.261 gegen ein
|
|
217
|
+
* eigenes CLAUDE_CONFIG_DIR, nie an einer echten Nutzerdatei.
|
|
218
|
+
*/
|
|
219
|
+
class ClaudeMcp {
|
|
220
|
+
harness;
|
|
221
|
+
ausfuehrer;
|
|
222
|
+
home;
|
|
223
|
+
constructor(harness, ausfuehrer, home) {
|
|
224
|
+
this.harness = harness;
|
|
225
|
+
this.ausfuehrer = ausfuehrer;
|
|
226
|
+
this.home = home;
|
|
227
|
+
}
|
|
228
|
+
ebenen() {
|
|
229
|
+
return ['home', 'arbeitsverzeichnis'];
|
|
230
|
+
}
|
|
231
|
+
hinweisNachAenderung() {
|
|
232
|
+
return 'Claude Code übernimmt die Änderung mit der nächsten Sitzung.';
|
|
233
|
+
}
|
|
234
|
+
scope(ort) {
|
|
235
|
+
if (ort.art === 'home')
|
|
236
|
+
return { scope: 'user' };
|
|
237
|
+
if (ort.art === 'arbeitsverzeichnis')
|
|
238
|
+
return { scope: 'local', cwd: ort.pfad };
|
|
239
|
+
throw new OrtNichtUnterstuetzt(this.harness.name, ort.art);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Die CLI legt keinen Eintrag über einen bestehenden: sie bricht mit Code 1 und „already exists"
|
|
243
|
+
* ab (belegt an 2.1.261). Deshalb wird danach in `~/.claude.json` nachgesehen: derselbe Block
|
|
244
|
+
* heißt „schon eingehängt" und ist idempotent wie bei Hermes, ein abweichender Block ist ein
|
|
245
|
+
* eigener Fehler statt einer englischen CLI-Zeile. hmgr überschreibt ihn nicht (E61).
|
|
246
|
+
*/
|
|
247
|
+
async einhaengen(name, konfig, ort) {
|
|
248
|
+
const { scope, cwd } = this.scope(ort);
|
|
249
|
+
const json = claudeMcpJson(konfig);
|
|
250
|
+
const { stdout, code } = await this.ausfuehrer(['mcp', 'add-json', name, json, '--scope', scope], cwd);
|
|
251
|
+
if (code === 0)
|
|
252
|
+
return;
|
|
253
|
+
const bestehend = await this.eintrag(name, ort);
|
|
254
|
+
if (bestehend !== undefined) {
|
|
255
|
+
if (tiefGleich(bestehend, JSON.parse(json)))
|
|
256
|
+
return;
|
|
257
|
+
throw new McpAbweichenderEintrag({ harness: this.harness.name, ort }, name);
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`claude mcp add-json ${name} scheiterte (Code ${code}): ${stdout.trim()}`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Ist der Name am Ort gar nicht eingehängt, bricht die CLI mit Code 1 ab („No MCP server named",
|
|
263
|
+
* belegt an 2.1.261). Das ist kein Fehler, sondern das gewünschte Ergebnis: aushängen ist
|
|
264
|
+
* idempotent wie bei Hermes. Ausnahme (E61-Nachtrag): steht der Name nur in der geteilten
|
|
265
|
+
* `.mcp.json` der Projektwurzel, bleibt dort tatsächlich ein Eintrag liegen — das darf nicht als
|
|
266
|
+
* Erfolg durchgehen, sondern bekommt eine eigene Meldung.
|
|
267
|
+
*/
|
|
268
|
+
async aushaengen(name, ort) {
|
|
269
|
+
const { scope, cwd } = this.scope(ort);
|
|
270
|
+
const { stdout, code } = await this.ausfuehrer(['mcp', 'remove', name, '--scope', scope], cwd);
|
|
271
|
+
if (code === 0)
|
|
272
|
+
return;
|
|
273
|
+
if ((await this.eintrag(name, ort)) !== undefined)
|
|
274
|
+
throw new Error(`claude mcp remove ${name} scheiterte (Code ${code}): ${stdout.trim()}`);
|
|
275
|
+
if (ort.art === 'arbeitsverzeichnis') {
|
|
276
|
+
const geteilt = await projektMcpJsonLesen(ort.pfad);
|
|
277
|
+
if (name in geteilt)
|
|
278
|
+
throw new McpNurGeteilterFundort({ harness: this.harness.name, ort }, name, path.join(ort.pfad, '.mcp.json'));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async eingehaengte(ort) {
|
|
282
|
+
return Object.keys(await this.mcpServersAm(ort));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Die geteilte `.mcp.json` eines Arbeitsverzeichnisses (E61-Nachtrag): im Alltag nur gelesen,
|
|
286
|
+
* weder beim Ein- noch beim Aushängen angefasst. Home kennt keine solche Datei.
|
|
287
|
+
*/
|
|
288
|
+
async geteilteFundorte(ort) {
|
|
289
|
+
if (ort.art !== 'arbeitsverzeichnis')
|
|
290
|
+
return [];
|
|
291
|
+
const datei = path.join(ort.pfad, '.mcp.json');
|
|
292
|
+
const server = await projektMcpJsonLesen(ort.pfad);
|
|
293
|
+
return Object.keys(server).map((name) => ({ name, datei, scope: 'project' }));
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Übernehmen (E64): den Block aus der geteilten `.mcp.json` der Projektwurzel 1:1 in den privaten
|
|
297
|
+
* local-Scope schreiben und ihn dort entfernen. Geschrieben wird ausschließlich über die CLI
|
|
298
|
+
* (E61), auch die geteilte Datei — `claude mcp remove <name> --scope project` mit der
|
|
299
|
+
* Projektwurzel als cwd kann das, ein eigener JSON-Schreibweg wäre eine zweite Wahrheit über das
|
|
300
|
+
* Dateiformat. Preis dafür: die CLI schreibt die Datei komplett neu und behält nur `mcpServers`,
|
|
301
|
+
* zusätzliche Top-Level-Schlüssel gehen verloren (belegt an 2.1.261, siehe HARNESS-FAKTEN.md) —
|
|
302
|
+
* dasselbe passiert dem Nutzer, wenn er die CLI selbst aufruft.
|
|
303
|
+
*
|
|
304
|
+
* Die Reihenfolge ist fail-safe: erst eintragen, dann entfernen. Scheitert der erste Schritt, ist
|
|
305
|
+
* nichts geschehen. Scheitert der zweite, steht der Eintrag doppelt statt nirgends — und weil
|
|
306
|
+
* local Vorrang vor project hat (belegt an 2.1.261), wirkt schon der private. Der Block kommt aus
|
|
307
|
+
* der Datei, nicht aus dem Katalog: auch ein Eintrag, den der Katalog nicht kennt, lässt sich so
|
|
308
|
+
* übernehmen.
|
|
309
|
+
*/
|
|
310
|
+
async geteiltenUebernehmen(name, ort) {
|
|
311
|
+
const ziel = { harness: this.harness.name, ort };
|
|
312
|
+
if (ort.art !== 'arbeitsverzeichnis')
|
|
313
|
+
throw new McpUebernahmeNichtMoeglich(ziel);
|
|
314
|
+
const datei = path.join(ort.pfad, '.mcp.json');
|
|
315
|
+
const block = (await projektMcpJsonLesen(ort.pfad))[name];
|
|
316
|
+
if (block === undefined)
|
|
317
|
+
throw new McpNichtInGeteilterDatei(ziel, name, datei);
|
|
318
|
+
const hin = await this.ausfuehrer(['mcp', 'add-json', name, JSON.stringify(block), '--scope', 'local'], ort.pfad);
|
|
319
|
+
if (hin.code !== 0) {
|
|
320
|
+
// Der einzige harmlose Fehlschlag: derselbe Block liegt schon im privaten Scope, etwa weil ein
|
|
321
|
+
// früherer Lauf zwischen den beiden Schritten abgebrochen ist. Ein abweichender Block wird
|
|
322
|
+
// nicht überschrieben (E61), und die geteilte Datei bleibt dann unangetastet.
|
|
323
|
+
const bestehend = await this.eintrag(name, ort);
|
|
324
|
+
if (bestehend === undefined)
|
|
325
|
+
throw new Error(`claude mcp add-json ${name} scheiterte (Code ${hin.code}): ${hin.stdout.trim()}`);
|
|
326
|
+
if (!tiefGleich(bestehend, block))
|
|
327
|
+
throw new McpAbweichenderEintrag(ziel, name);
|
|
328
|
+
}
|
|
329
|
+
const weg = await this.ausfuehrer(['mcp', 'remove', name, '--scope', 'project'], ort.pfad);
|
|
330
|
+
// Nicht der Code entscheidet, sondern die Datei danach: steht der Name nicht mehr drin, ist die
|
|
331
|
+
// Übernahme fertig, egal wie die CLI den Fall genannt hat.
|
|
332
|
+
if (weg.code !== 0 && name in (await projektMcpJsonLesen(ort.pfad)))
|
|
333
|
+
throw new McpUebernahmeUnvollstaendig(ziel, name, datei, weg.stdout.trim());
|
|
334
|
+
return { datei };
|
|
335
|
+
}
|
|
336
|
+
/** Der eingehängte Block unter diesem Namen, oder undefined. */
|
|
337
|
+
async eintrag(name, ort) {
|
|
338
|
+
return (await this.mcpServersAm(ort))[name];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Die MCP-Server, die `~/.claude.json` für diesen Ort führt. Ein Arbeitsverzeichnis steht dort
|
|
342
|
+
* unter dem aufgelösten Pfad, weil Claude Code den cwd auflöst (belegt an 2.1.261); hmgr merkt
|
|
343
|
+
* sich Arbeitsverzeichnisse aber so, wie der Nutzer sie genannt hat, also zählen beide.
|
|
344
|
+
*/
|
|
345
|
+
async mcpServersAm(ort) {
|
|
346
|
+
const daten = await this.claudeJsonLesen();
|
|
347
|
+
if (ort.art === 'home')
|
|
348
|
+
return mcpServersVon(daten);
|
|
349
|
+
if (ort.art === 'arbeitsverzeichnis') {
|
|
350
|
+
const projects = objektFeld(daten, 'projects');
|
|
351
|
+
const projekt = objektFeld(projects, ort.pfad) ?? objektFeld(projects, aufgeloest(ort.pfad));
|
|
352
|
+
return mcpServersVon(projekt);
|
|
353
|
+
}
|
|
354
|
+
throw new OrtNichtUnterstuetzt(this.harness.name, ort.art);
|
|
355
|
+
}
|
|
356
|
+
async claudeJsonLesen() {
|
|
357
|
+
try {
|
|
358
|
+
const text = await readFile(path.join(this.home, '.claude.json'), 'utf8');
|
|
359
|
+
return JSON.parse(ohneBom(text));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
142
366
|
export class ClaudeCode {
|
|
143
367
|
name = 'claude-code';
|
|
144
368
|
home;
|
|
145
369
|
ausfuehrer;
|
|
146
370
|
/** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt. */
|
|
147
371
|
plugins;
|
|
372
|
+
/** Wird von `vorhanden()` gesetzt, sobald die claude-CLI auf dem PATH liegt (E61). */
|
|
373
|
+
mcp;
|
|
148
374
|
geprueft = false;
|
|
149
375
|
constructor(home, ausfuehrer = claudeAusfuehrer) {
|
|
150
376
|
this.home = home;
|
|
151
377
|
this.ausfuehrer = ausfuehrer;
|
|
152
378
|
}
|
|
153
379
|
/**
|
|
154
|
-
* Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin-
|
|
155
|
-
*
|
|
380
|
+
* Claude Code liegt auf dem Rechner, wenn es sein Home angelegt hat. Die Plugin- und
|
|
381
|
+
* MCP-Fähigkeit kommen dazu, sobald `claude --version` durchläuft: ohne CLI gibt es beides nicht.
|
|
156
382
|
*/
|
|
157
383
|
async vorhanden() {
|
|
158
384
|
const da = (await stat(path.join(this.home, '.claude')).catch(() => undefined)) !== undefined;
|
|
159
385
|
if (!this.geprueft) {
|
|
160
386
|
this.geprueft = true;
|
|
161
387
|
const { code } = await this.ausfuehrer(['--version']).catch(() => ({ code: 1 }));
|
|
162
|
-
if (code === 0)
|
|
388
|
+
if (code === 0) {
|
|
163
389
|
this.plugins = new ClaudePlugins(this, this.ausfuehrer);
|
|
390
|
+
this.mcp = new ClaudeMcp(this, this.ausfuehrer, this.home);
|
|
391
|
+
}
|
|
164
392
|
}
|
|
165
393
|
return da;
|
|
166
394
|
}
|
package/dist/adapter/composer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Zeiger in der composer.json des Repos, unter extra.hmgr (B8, E25, E26).
|
|
2
2
|
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ohneBom } from "./datei-text.js";
|
|
4
5
|
import { quelleAusText } from "./quelle-text.js";
|
|
5
6
|
const DATEI = 'composer.json';
|
|
6
7
|
/** Ruft hmgr nur auf, wenn es vorhanden ist, und lässt composer nie scheitern (E20). */
|
|
@@ -40,7 +41,7 @@ export class ComposerZeiger {
|
|
|
40
41
|
return { ergebnis: 'mehrdeutig', kandidaten };
|
|
41
42
|
}
|
|
42
43
|
async lesen(pfad) {
|
|
43
|
-
const daten = JSON.parse(await readFile(pfad, 'utf8'));
|
|
44
|
+
const daten = JSON.parse(ohneBom(await readFile(pfad, 'utf8')));
|
|
44
45
|
const eintrag = daten.extra?.hmgr ?? {};
|
|
45
46
|
const verzeichnis = path.dirname(pfad);
|
|
46
47
|
return {
|
|
@@ -50,7 +51,7 @@ export class ComposerZeiger {
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
async schreiben(pfad, zeiger) {
|
|
53
|
-
const daten = JSON.parse(await readFile(pfad, 'utf8'));
|
|
54
|
+
const daten = JSON.parse(ohneBom(await readFile(pfad, 'utf8')));
|
|
54
55
|
const eintrag = {
|
|
55
56
|
sources: zeiger.quellen.map(quelleAlsEintrag),
|
|
56
57
|
skills: Object.fromEntries(zeiger.skills),
|
|
@@ -64,7 +65,7 @@ export class ComposerZeiger {
|
|
|
64
65
|
* und ihre Reihenfolge bleiben, der Rest der Datei bleibt unberührt. Idempotent.
|
|
65
66
|
*/
|
|
66
67
|
async scriptEintragen(pfad) {
|
|
67
|
-
const daten = JSON.parse(await readFile(pfad, 'utf8'));
|
|
68
|
+
const daten = JSON.parse(ohneBom(await readFile(pfad, 'utf8')));
|
|
68
69
|
const scripts = { ...(daten.scripts ?? {}) };
|
|
69
70
|
let geaendert = false;
|
|
70
71
|
for (const schluessel of SCRIPT_SCHLUESSEL) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Gemeinsames Modul der Adapter: Text aus Dateien, die von Hand geschrieben sein können.
|
|
2
|
+
/**
|
|
3
|
+
* Streift eine führende UTF-8-BOM ab (E65/B11). Windows-Editoren und PowerShell 5.1
|
|
4
|
+
* (`Set-Content -Encoding utf8`) schreiben sie an den Dateianfang. `JSON.parse` bricht daran ab
|
|
5
|
+
* („Unexpected token"), und wo dieser Fehler in einem `catch` landet, wirkt die Datei leer statt
|
|
6
|
+
* kaputt: `hmgr mcp migrate` meldete deshalb „steht nicht in der Datei", obwohl der Eintrag in der
|
|
7
|
+
* `.mcp.json` stand.
|
|
8
|
+
*
|
|
9
|
+
* Nur JSON braucht das. Der YAML-Parser kennt die BOM, und `String.trim()` streift sie mit ab —
|
|
10
|
+
* beides geprüft, deshalb bleiben config.yaml, mcp.yaml, `.gitignore` und `.env` unberührt.
|
|
11
|
+
*
|
|
12
|
+
* Beim Zurückschreiben geht die BOM verloren: hmgr schreibt jede JSON-Datei, die es ändert,
|
|
13
|
+
* vollständig neu (`JSON.stringify`). Das ist gewollt — die Datei bleibt gültiges UTF-8 und ist
|
|
14
|
+
* danach auch für Werkzeuge lesbar, die über die BOM stolpern.
|
|
15
|
+
*/
|
|
16
|
+
export function ohneBom(text) {
|
|
17
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
18
|
+
}
|
|
@@ -1,16 +1,59 @@
|
|
|
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
|
-
import { NameBelegt } from "../kern/modell.js";
|
|
5
|
+
import { LinkAnlegenFehlgeschlagen, NameBelegt, } from "../kern/modell.js";
|
|
5
6
|
import { checkoutZerlegen, quelleZerlegen } from "./checkout-pfad.js";
|
|
7
|
+
import { ohneBom } from "./datei-text.js";
|
|
6
8
|
import { LOKAL_REF } from "./git.js";
|
|
7
|
-
import { SKILL_DATEI,
|
|
9
|
+
import { SKILL_DATEI, skillAusPfad } from "./skill-md.js";
|
|
10
|
+
/** Dateien eines Ordners rekursiv, `.git` und `node_modules` ausgenommen, Symlinks nicht gefolgt. */
|
|
11
|
+
async function ordnerDateien(wurzel, unter, ergebnis) {
|
|
12
|
+
const eintraege = await readdir(path.join(wurzel, unter), { withFileTypes: true }).catch(() => []);
|
|
13
|
+
for (const eintrag of eintraege) {
|
|
14
|
+
if (eintrag.name === '.git' || eintrag.name === 'node_modules')
|
|
15
|
+
continue;
|
|
16
|
+
const rel = unter ? `${unter}/${eintrag.name}` : eintrag.name;
|
|
17
|
+
if (eintrag.isDirectory())
|
|
18
|
+
await ordnerDateien(wurzel, rel, ergebnis);
|
|
19
|
+
else if (eintrag.isFile())
|
|
20
|
+
ergebnis.push({ relativerPfad: rel, inhalt: await readFile(path.join(wurzel, rel)) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Typ-Argument für `fs.symlink` (E66). Unter Windows eine Junction: einen Verzeichnis-Symlink
|
|
25
|
+
* verweigert das System dem unprivilegierten Nutzer, eine Junction legt es ohne Sonderrechte an
|
|
26
|
+
* (HARNESS-FAKTEN „Windows"). Sonst `undefined` — das ist für Node dasselbe wie kein drittes
|
|
27
|
+
* Argument, geprüft an Node 22: beide Aufrufe legen denselben Link.
|
|
28
|
+
*/
|
|
29
|
+
export function symlinkTyp(plattform = process.platform) {
|
|
30
|
+
return plattform === 'win32' ? 'junction' : undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Streift das Windows-Präfix `\\?\` von einem Pfad ab (E66). `fs.readlink` gibt das Ziel einer
|
|
34
|
+
* Junction mitsamt diesem Präfix zurück; unverkürzt verglichen sähe derselbe Link auf einen
|
|
35
|
+
* anderen Pfad aus, und `deuten` hielte einen eigenen Link für fremd. Gekürzt wird nur die
|
|
36
|
+
* Laufwerksform: bei `\\?\UNC\server\share` gehört das Präfix zum Pfad. Auf POSIX ändert die
|
|
37
|
+
* Funktion nichts, dort kommt das Präfix nicht vor.
|
|
38
|
+
*/
|
|
39
|
+
export function ohneWindowsPraefix(pfad) {
|
|
40
|
+
return /^\\\\\?\\[A-Za-z]:/.test(pfad) ? pfad.slice(4) : pfad;
|
|
41
|
+
}
|
|
42
|
+
/** Was der Nutzer tun kann, wenn das Anlegen eines Links gescheitert ist (E66). */
|
|
43
|
+
export function linkFehlerHinweis(plattform = process.platform) {
|
|
44
|
+
return plattform === 'win32'
|
|
45
|
+
? 'Aktiviere den Windows-Entwicklermodus (Einstellungen → System → Für Entwickler) oder starte hmgr mit Administratorrechten.'
|
|
46
|
+
: 'Prüfe die Schreibrechte auf das Zielverzeichnis und ob das Linkziel vorhanden ist.';
|
|
47
|
+
}
|
|
8
48
|
export class DateisystemZiele {
|
|
9
49
|
wurzel;
|
|
10
50
|
zustand;
|
|
11
|
-
|
|
51
|
+
/** $XDG_STATE_HOME, falls gesetzt und nicht leer, für den vorrangigen Lockfile-Ort (E50). */
|
|
52
|
+
xdgStateHome;
|
|
53
|
+
constructor(wurzel, zustand, xdgStateHome) {
|
|
12
54
|
this.wurzel = wurzel;
|
|
13
55
|
this.zustand = zustand;
|
|
56
|
+
this.xdgStateHome = xdgStateHome;
|
|
14
57
|
}
|
|
15
58
|
async quellen() {
|
|
16
59
|
return (await this.zustand.lesen()).quellen;
|
|
@@ -18,12 +61,14 @@ export class DateisystemZiele {
|
|
|
18
61
|
/**
|
|
19
62
|
* Der Skill hinter einem Linkziel. Der Name kommt aus der SKILL.md am Linkziel, nicht aus dem
|
|
20
63
|
* Namen des Eintrags im Ziel: derselbe Skill soll überall gleich heißen, auch wenn ein Eintrag
|
|
21
|
-
* von Hand umbenannt wurde (E32).
|
|
64
|
+
* von Hand umbenannt wurde (E32). Über `skillAusPfad` kommt zugleich die Beschreibung (E39) und
|
|
65
|
+
* die übrigen Frontmatter-Felder (E55) mit, die die Ziel-Tabellen der GUI zeigen (E60) — vorher
|
|
66
|
+
* fehlten sie hier, weil nur der Name gelesen wurde. Ein Aufruf, ein Lesen, ein Parsen des
|
|
67
|
+
* Frontmatters: bei vielen Links summiert sich jedes zweite Parsen derselben Datei.
|
|
22
68
|
*/
|
|
23
69
|
async skill(zielPfad, skillPfad, ersatz) {
|
|
24
|
-
const verzeichnisname = skillPfad.split('/').filter((t) => t.length > 0).at(-1) ?? ersatz;
|
|
25
70
|
const inhalt = await readFile(path.join(zielPfad, SKILL_DATEI), 'utf8').catch(() => undefined);
|
|
26
|
-
return
|
|
71
|
+
return skillAusPfad(skillPfad, inhalt, ersatz);
|
|
27
72
|
}
|
|
28
73
|
/** Liest aus dem Linkziel Quelle, Ref und Skill zurück. Undefined heißt: kein eigener Link. */
|
|
29
74
|
async deuten(zielPfad) {
|
|
@@ -65,7 +110,9 @@ export class DateisystemZiele {
|
|
|
65
110
|
const info = await lstat(pfad).catch(() => undefined);
|
|
66
111
|
if (!info || !info.isSymbolicLink())
|
|
67
112
|
return undefined;
|
|
68
|
-
|
|
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)));
|
|
69
116
|
}
|
|
70
117
|
/**
|
|
71
118
|
* Ein Skill ist ein Verzeichnis. Dateien wie .DS_Store zählen nicht als fremder Eintrag,
|
|
@@ -101,6 +148,10 @@ export class DateisystemZiele {
|
|
|
101
148
|
return ergebnis;
|
|
102
149
|
}
|
|
103
150
|
async linkLegen(ziel, verzeichnis, name, checkout, skill, ersetzen) {
|
|
151
|
+
// 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))
|
|
154
|
+
throw new Error(`Ungültiger Name für einen Link: „${name}"`);
|
|
104
155
|
await mkdir(verzeichnis, { recursive: true });
|
|
105
156
|
const pfad = path.join(verzeichnis, name);
|
|
106
157
|
const info = await lstat(pfad).catch(() => undefined);
|
|
@@ -114,7 +165,17 @@ export class DateisystemZiele {
|
|
|
114
165
|
else
|
|
115
166
|
throw new NameBelegt(ziel, name);
|
|
116
167
|
}
|
|
117
|
-
|
|
168
|
+
// Eine Junction verlangt ein absolutes Ziel (E66). `checkout.pfad` ist es in der Praxis immer
|
|
169
|
+
// — die Wurzel unter dem hmgr-Home, eine lokale Quelle über `path.resolve` in `quelle-text` —,
|
|
170
|
+
// nur garantiert das nichts: aus einer von Hand geänderten zustand.json käme auch ein
|
|
171
|
+
// relativer Pfad. `path.resolve` kostet nichts und macht die Zusage hier fest.
|
|
172
|
+
const checkoutZiel = path.resolve(checkout.pfad, skill.pfad);
|
|
173
|
+
try {
|
|
174
|
+
await symlink(checkoutZiel, pfad, symlinkTyp());
|
|
175
|
+
}
|
|
176
|
+
catch (ursache) {
|
|
177
|
+
throw new LinkAnlegenFehlgeschlagen(pfad, checkoutZiel, ursache instanceof Error ? ursache.message : String(ursache), linkFehlerHinweis());
|
|
178
|
+
}
|
|
118
179
|
}
|
|
119
180
|
async linkEntfernen(_ziel, verzeichnis, name) {
|
|
120
181
|
const zielPfad = await this.linkZiel(verzeichnis, name);
|
|
@@ -123,4 +184,81 @@ export class DateisystemZiele {
|
|
|
123
184
|
if (await this.deuten(zielPfad))
|
|
124
185
|
await unlink(path.join(verzeichnis, name));
|
|
125
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Löscht einen fremden Eintrag von der Platte (E46): ein Symlink wird als Link entfernt, ohne
|
|
189
|
+
* ihm zu folgen, die Arbeitskopie dahinter bleibt unangetastet; ein Verzeichnis wird rekursiv
|
|
190
|
+
* gelöscht. Ob der Eintrag tatsächlich fremd ist, hat der Kern schon geprüft.
|
|
191
|
+
*/
|
|
192
|
+
async eintragLoeschen(_ziel, verzeichnis, name) {
|
|
193
|
+
const pfad = path.join(verzeichnis, name);
|
|
194
|
+
const info = await lstat(pfad).catch(() => undefined);
|
|
195
|
+
if (info === undefined)
|
|
196
|
+
return;
|
|
197
|
+
if (info.isSymbolicLink())
|
|
198
|
+
await unlink(pfad);
|
|
199
|
+
else
|
|
200
|
+
await rm(pfad, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Lockfile-Kandidaten in Suchreihenfolge (E50): zuerst $XDG_STATE_HOME/skills/.skill-lock.json,
|
|
204
|
+
* nur wenn die Variable gesetzt und nicht leer ist, sonst wie bisher neben `skillsVerzeichnis`.
|
|
205
|
+
*/
|
|
206
|
+
lockPfadKandidaten(skillsVerzeichnis) {
|
|
207
|
+
const kandidaten = [];
|
|
208
|
+
if (this.xdgStateHome !== undefined && this.xdgStateHome.length > 0)
|
|
209
|
+
kandidaten.push(path.join(this.xdgStateHome, 'skills', '.skill-lock.json'));
|
|
210
|
+
kandidaten.push(path.join(path.dirname(skillsVerzeichnis), '.skill-lock.json'));
|
|
211
|
+
return kandidaten;
|
|
212
|
+
}
|
|
213
|
+
/** Eintrag zu `name` aus dem Lockfile-Inhalt. Kaputtes JSON oder fehlende Felder: undefined. */
|
|
214
|
+
lockEintragAusInhalt(roh, name) {
|
|
215
|
+
try {
|
|
216
|
+
const geparst = JSON.parse(ohneBom(roh));
|
|
217
|
+
const eintrag = geparst.skills?.[name];
|
|
218
|
+
if (eintrag === undefined)
|
|
219
|
+
return undefined;
|
|
220
|
+
const sourceUrl = eintrag.sourceUrl;
|
|
221
|
+
const skillFolderHash = eintrag.skillFolderHash;
|
|
222
|
+
if (typeof sourceUrl !== 'string' || typeof skillFolderHash !== 'string')
|
|
223
|
+
return undefined;
|
|
224
|
+
const ref = eintrag.ref;
|
|
225
|
+
const skillPath = eintrag.skillPath;
|
|
226
|
+
return {
|
|
227
|
+
sourceUrl,
|
|
228
|
+
skillFolderHash,
|
|
229
|
+
...(typeof ref === 'string' ? { ref } : {}),
|
|
230
|
+
...(typeof skillPath === 'string' ? { skillPath } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Lockfile der skills-CLI (E48 b, E50, HARNESS-FAKTEN). Fehlt die Datei am ersten Kandidaten,
|
|
239
|
+
* wird der nächste versucht; ist sie da, aber kein gültiges JSON oder trägt der Eintrag nicht die
|
|
240
|
+
* nötigen Felder, gibt es undefined zurück statt zu werfen: ein kaputtes Lockfile bricht die
|
|
241
|
+
* Erhebung nicht ab.
|
|
242
|
+
*/
|
|
243
|
+
async skillLockEintrag(skillsVerzeichnis, name) {
|
|
244
|
+
for (const lockPfad of this.lockPfadKandidaten(skillsVerzeichnis)) {
|
|
245
|
+
const roh = await readFile(lockPfad, 'utf8').catch(() => undefined);
|
|
246
|
+
if (roh === undefined)
|
|
247
|
+
continue;
|
|
248
|
+
return this.lockEintragAusInhalt(roh, name);
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
/** SHA-256 über relativen Pfad und Inhalt aller Dateien, wie `computeSkillFolderHash` (E48 b). */
|
|
253
|
+
async ordnerHash(verzeichnis, name) {
|
|
254
|
+
const dateien = [];
|
|
255
|
+
await ordnerDateien(path.join(verzeichnis, name), '', dateien);
|
|
256
|
+
dateien.sort((a, b) => a.relativerPfad.localeCompare(b.relativerPfad));
|
|
257
|
+
const hash = createHash('sha256');
|
|
258
|
+
for (const datei of dateien) {
|
|
259
|
+
hash.update(datei.relativerPfad);
|
|
260
|
+
hash.update(datei.inhalt);
|
|
261
|
+
}
|
|
262
|
+
return hash.digest('hex');
|
|
263
|
+
}
|
|
126
264
|
}
|