kimal3 0.1.5 → 0.2.0

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 CHANGED
@@ -35,7 +35,11 @@ auf und bestätigst den Code dort in deinem eingeloggten Konto — so wie bei `g
35
35
  kimal3 Zeigt, ob dieser Rechner angemeldet ist
36
36
  kimal3 login Diesen Rechner mit deinem Konto verbinden
37
37
  kimal3 skill install Claude beibringen, dieses Werkzeug zu benutzen — und dir die /k3-Befehle geben
38
- kimal3 arbeitsplatz neu Deine KI-Arbeitsplatz-Vorlage als Ordner anlegen
38
+ kimal3 arbeitsplatz neu Den ganzen KI-Arbeitsplatz anlegen — Firmenebene und Werkbank
39
+ kimal3 werkbank neu Nur eine eigene Werkbank in einer bestehenden Firma anlegen
40
+ kimal3 kontext runter Den Firmenkontext holen — CLAUDE.md und die fünf Dateien in kontext/
41
+ kimal3 kontext fragen Zeigen, welche Fragen über deine Firma noch unbeantwortet sind
42
+ kimal3 kontext hoch Deine lokalen Änderungen an den fünf Kontext-Dateien hochschicken
39
43
  kimal3 projekt neu … Ein Projekt ins Erfolgsprofil eintragen
40
44
  kimal3 projekt liste Deine eingetragenen Projekte anzeigen
41
45
  kimal3 firma daten Die Daten deiner Firma holen, damit Claude sie auswerten kann
@@ -68,6 +72,23 @@ Für Skripte und für Claude selbst:
68
72
  Der Unterschied zwischen `3` und `4` ist Absicht: Bei `3` lohnt ein zweiter Versuch,
69
73
  bei `4` nie.
70
74
 
75
+ ## Update-Hinweise
76
+
77
+ Steht eine neuere Fassung bereit, schreibt `kimal3` nach dem Befehl eine eingerückte
78
+ Zeile — höchstens **einmal am Tag**:
79
+
80
+ ```
81
+ → kimal3 0.2.0 ist da. npm i -g kimal3@latest && kimal3 skill install
82
+ ```
83
+
84
+ Sie geht auf **stderr**, nicht auf stdout: `kimal3 firma daten > firma.json` bleibt
85
+ damit sauberes JSON. Sie **ändert den Rückgabewert nicht** — eine `0` bleibt eine `0`.
86
+ Und `kimal3` **installiert nichts von selbst**; der Befehl steht in der Zeile, ausführen
87
+ tust du ihn.
88
+
89
+ Dasselbe gilt für die Skill-Dateien: Sind die in `~/.claude/skills/kimal3/` älter als
90
+ das installierte Werkzeug, sagt `kimal3` es — dafür braucht es kein Netz.
91
+
71
92
  ## Voraussetzungen
72
93
 
73
94
  Node.js 20 oder neuer. Sonst nichts — das Werkzeug bringt **keine fremden Bibliotheken**
package/dist/api.js CHANGED
@@ -1,7 +1,62 @@
1
1
  import { BASIS_URL, leseAnmeldung } from "./anmeldung.js";
2
+ import { hinweisAusAntwort } from "./nachricht.js";
3
+ import { eigeneVersion } from "./version.js";
4
+ import { findeArbeitsplatz, leseMerkzettelDatei } from "./kontext-dateien.js";
5
+ import { bekannterStandAus, istKopfwertSicher } from "./kontext-stand.js";
2
6
  export class NichtAngemeldet extends Error {
3
7
  constructor() { super("nicht angemeldet"); }
4
8
  }
9
+ /**
10
+ * Der Wert fuer den Kopf `X-Kimal3-Kontext-Stand` -- `<firmen-id>:<stand>`
11
+ * aus dem lokalen Merkzettel, oder `null`, wenn er sich nicht bilden laesst
12
+ * (Task 10, Step 1).
13
+ *
14
+ * ⚠️ WIRFT NIE -- dieselbe Regel wie beim Update-Kanal insgesamt: Ein
15
+ * Nebenschauplatz darf keinen Befehl umwerfen. `findeArbeitsplatz` und
16
+ * `leseMerkzettelDatei` werfen zwar selbst schon nicht (s. ihre eigenen
17
+ * Kommentare), das `try` ist trotzdem das Netz fuer den Fall, dass sich das
18
+ * einmal aendert.
19
+ *
20
+ * 🔴 **Fix-Runde 1, Fund 1:** `firma_id` und jeder `stand` kommen aus dem
21
+ * Merkzettel auf der Platte des Kunden -- einer Datei, die ausdruecklich zum
22
+ * Von-Hand-Aufmachen gedacht ist (s. Kopf-Kommentar an `pruefsumme` in
23
+ * `kontext-stand.ts`). `leseMerkzettel` prueft nur `typeof === "string"`, nie
24
+ * den INHALT. Ohne `istKopfwertSicher` wuerde ein einziges Nicht-ASCII-
25
+ * Zeichen darin `headers.set(...)` in `ruf()` unten zum Werfen bringen --
26
+ * und zwar bei JEDEM folgenden Befehl, nicht nur bei `kontext`-Befehlen. Der
27
+ * Kunde saehe "kimal3 ist gerade nicht erreichbar" und suchte im Netz, waehrend
28
+ * der Fehler auf seiner eigenen Platte sitzt.
29
+ *
30
+ * 🔑 **Warum stillschweigend `null`, keine Warnung:** Dieselbe Antwort wie
31
+ * bei den drei anderen Wirklichkeiten, die diese Funktion schon zu `null`
32
+ * zusammenfaltet (kein Arbeitsplatz, keine bekannte `firma_id` in allen drei
33
+ * Lesarten von Task 10a). Eine Warnung liefe bei JEDEM Befehl erneut, bis der
34
+ * Merkzettel neu geschrieben wird (naechstes `kontext runter`/`kontext hoch`
35
+ * heilt das automatisch) -- und ein Nebenschauplatz, der ploetzlich auf jedem
36
+ * Befehl eine unerwartete Zeile ausgibt, ist selbst ein Verstoss gegen die
37
+ * Regel, die ihn ueberhaupt rechtfertigt ("darf keinen Befehl umwerfen" ist
38
+ * eng verwandt mit "darf keinen Befehl verunstalten"). *Wie viele
39
+ * verschiedene Wirklichkeiten enden auf `null` -- und ist jede ein sicherer
40
+ * Ausgang?* Vier, und JEDE fuehrt zum selben, harmlosen Ergebnis: kein Kopf,
41
+ * kein Hinweis, der Befehl laeuft unveraendert weiter.
42
+ */
43
+ function kontextStandKopf() {
44
+ try {
45
+ const ordner = findeArbeitsplatz(process.cwd());
46
+ if (ordner === null)
47
+ return null;
48
+ const merkzettel = leseMerkzettelDatei(ordner);
49
+ if (merkzettel.firma_id === null)
50
+ return null;
51
+ const wert = `${merkzettel.firma_id}:${bekannterStandAus(merkzettel)}`;
52
+ if (!istKopfwertSicher(wert))
53
+ return null;
54
+ return wert;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
5
60
  /**
6
61
  * Ein Aufruf gegen die App. `mitAusweis` haengt den Schluessel an.
7
62
  *
@@ -18,6 +73,50 @@ export async function ruf(pfad, optionen = {}) {
18
73
  throw new NichtAngemeldet();
19
74
  kopf.Authorization = `Bearer ${a.schluessel}`;
20
75
  }
76
+ /*
77
+ 🔴 DIE CLI SAGT, WER SIE IST -- und nur daran haengt der Server den
78
+ Update-Hinweis an (Befund der fuenften Review-Runde).
79
+
80
+ Ohne diesen Kopf bekaeme JEDER Client das neue Antwortfeld, auch die
81
+ veroeffentlichte 0.1.5. Und die gibt bei `firma daten` den GANZEN Rumpf als
82
+ JSON aus: Sobald jemand eine Nachricht einschaltet, stuende ein
83
+ `"hinweis": {…}` mitten in den Firmendaten -- bei genau den Leuten, fuer
84
+ die der Kanal ueberhaupt gebaut wurde, und in einer Ausgabe, die SKILL.md
85
+ als Firmendaten zu lesen anweist. Gemessen am 2026-08-21 mit der echten
86
+ 0.1.5 aus npm.
87
+
88
+ 🔑 Damit ist US-7 ("die alte CLI verhaelt sich unveraendert") keine
89
+ Hoffnung mehr, sondern gebaut: Wer den Kopf nicht schickt, bekommt nichts.
90
+ Und die Nummer ist ASCII -- der ByteString-Fehler, an dem der Hinweis
91
+ selbst scheitern wuerde, trifft sie nicht.
92
+ */
93
+ const version = eigeneVersion();
94
+ if (version !== null)
95
+ kopf["X-Kimal3-Version"] = version;
96
+ /*
97
+ 🔴 DER ZWEITE KOPF (Task 10) -- der lokal bekannte Firmenstand, damit der
98
+ Server einen firmenbezogenen Hinweis anhaengen kann ("deine Firma hat den
99
+ Kontext geaendert"). Der Server weiss nicht, was auf DIESER Platte liegt
100
+ -- deshalb schickt die CLI ihn mit, statt dass der Server raet.
101
+
102
+ ⚠️ ASCII PFLICHT, aus demselben Grund wie der Versions-Kopf oben: die
103
+ Firmen-ID ist eine UUID, der Stand ein ISO-Zeitstempel oder `"leer"" --
104
+ beides ASCII. Der Firmenname geht NIE hier mit, nur die ID; den Namen
105
+ loest der Server fuer den Hinweistext selbst auf (Step 4, US-8).
106
+
107
+ 🔴 GAR KEIN KOPF, wenn sich kein Arbeitsplatz findet ODER der lokale
108
+ Merkzettel keine Firmen-ID kennt -- letzteres kollabiert bewusst DREI
109
+ Wirklichkeiten in einen Wert (altes Merkzettel-Format ohne das Feld,
110
+ kaputtes JSON, ausdruecklich `null`, s. `leseMerkzettel` in
111
+ `kontext-stand.ts`). Diese Funktion unterscheidet sie nicht: Alle drei
112
+ heissen "wir wissen die Firma nicht sicher", und "kein Kopf" ist in
113
+ JEDEM der drei Faelle die richtige, sichere Antwort -- kein Hinweis
114
+ bleibt der Vorgabewert, auch wenn er einen der drei Faelle zu Unrecht
115
+ (aber folgenlos) mit erwischt.
116
+ */
117
+ const kontextKopf = kontextStandKopf();
118
+ if (kontextKopf !== null)
119
+ kopf["X-Kimal3-Kontext-Stand"] = kontextKopf;
21
120
  const methode = optionen.methode ?? "POST";
22
121
  // Content-Type nur, wenn wirklich etwas im Rumpf steht. Ein GET mit
23
122
  // "Content-Type: application/json" und ohne Rumpf ist eine Luege ueber die
@@ -29,7 +128,45 @@ export async function ruf(pfad, optionen = {}) {
29
128
  headers: kopf,
30
129
  body: methode === "POST" ? JSON.stringify(optionen.koerper ?? {}) : undefined,
31
130
  });
32
- return { status: antwort.status, koerper: await antwort.json().catch(() => null) };
131
+ const koerper = await antwort.json().catch(() => null);
132
+ merkeHinweis(koerper);
133
+ return { status: antwort.status, koerper };
134
+ }
135
+ /**
136
+ * Der zuletzt mitgereiste Update-Hinweis.
137
+ *
138
+ * 🔑 WARUM EINE MODULVARIABLE UND KEINE AUSGABE AN DIESER STELLE: `ruf()`
139
+ * laeuft MITTEN in einem Befehl, teils mehrfach. Wer hier schreibt, schneidet
140
+ * die Projektliste entzwei -- die Spec verlangt ausdruecklich, dass die Zeile
141
+ * NACH der Ausgabe steht, nie mittendrin (US-1). Ausgegeben wird genau einmal,
142
+ * ganz am Ende, in kimal3.ts.
143
+ */
144
+ let letzterHinweis = null;
145
+ function merkeHinweis(koerper) {
146
+ const h = hinweisAusAntwort(koerper);
147
+ if (h)
148
+ letzterHinweis = h;
149
+ /*
150
+ 🔴 UND DANN RAUS AUS DEM RUMPF. Gemessen am 2026-08-21: `firma daten` gibt
151
+ den GANZEN Rumpf als JSON aus (`console.log(JSON.stringify(koerper))`) --
152
+ der Hinweis stuende dort mitten in den Firmendaten UND darunter noch einmal
153
+ als →-Zeile. Zweimal dasselbe, und einmal davon an einer Stelle, an der
154
+ Claude Daten erwartet.
155
+
156
+ 🔑 Warum HIER und nicht in `firma-daten.ts`: Dort waere es eine Kopie, die
157
+ beim naechsten Befehl fehlt, der einen Rumpf durchreicht. Wer den Hinweis
158
+ ENTGEGENNIMMT, nimmt ihn auch heraus -- dann kann kein Befehl ihn
159
+ versehentlich mit ausgeben, auch keiner, den es noch nicht gibt.
160
+
161
+ Das `delete` trifft ein frisch aus `json()` erzeugtes Objekt, das sonst
162
+ niemand haelt.
163
+ */
164
+ if (koerper && typeof koerper === "object") {
165
+ delete koerper.hinweis;
166
+ }
167
+ }
168
+ export function gemerkterHinweis() {
169
+ return letzterHinweis;
33
170
  }
34
171
  /** Holt ein Textfeld aus einer Antwort, ohne `any`. */
35
172
  export function feld(koerper, name) {
@@ -0,0 +1,268 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { feld, ruf } from "./api.js";
4
+ import { schlussFehler } from "./fehler.js";
5
+ /*
6
+ 🔁 Die Ableitung „welcher Unterordner ist die Werkbank" wohnt seit dem
7
+ 2026-08-20 in `werkbank-name.ts` -- gemeinsam mit der App, die sie fuer `/pro`
8
+ genauso braucht. Sie stand vorher hier UND in `src/lib/arbeitsplatz/ausliefern.ts`,
9
+ wortgleich. *Zwei Kopien einer Ableitung sind dieselbe zweite Wahrheit wie zwei
10
+ Kopien des Namens, nur besser getarnt.*
11
+ */
12
+ export { werkbankAusDateien as werkbankOrdner } from "./werkbank-name.js";
13
+ /**
14
+ * Holt die Vorlage von der App — **eine** Route für beide Befehle.
15
+ *
16
+ * 🔑 `werkbank neu` bekommt bewusst KEINE eigene Route: Es sind dieselben
17
+ * Dateien, dieselbe Pro-Prüfung, derselbe Ausweis. Eine zweite Route wäre eine
18
+ * zweite Tür, die man einzeln absichern und einzeln nachmessen müsste — für
19
+ * exakt denselben Inhalt. Der Ausschnitt entsteht hier auf dem Rechner.
20
+ */
21
+ export async function vorlageHolen() {
22
+ const antwort = await ruf("/api/cli/arbeitsplatz", { methode: "GET", mitAusweis: true });
23
+ if (antwort.status === 200) {
24
+ const daten = alsVorlageAntwort(antwort.koerper);
25
+ if (daten === null) {
26
+ console.error("Die Antwort war nicht lesbar. Bitte gleich noch einmal.");
27
+ return { ok: false, wert: 3 };
28
+ }
29
+ return { ok: true, daten };
30
+ }
31
+ const meldung = feld(antwort.koerper, "meldung") ?? "Das hat nicht geklappt.";
32
+ // 402 VOR 401: Beide heissen "kommst hier nicht rein", verlangen aber
33
+ // Gegenteiliges. Wer sie zusammenwirft, raet `kimal3 login` -- und der
34
+ // meldet sich dann erfolgreich an, ohne dass es etwas aendert.
35
+ if (antwort.status === 402) {
36
+ console.error(meldung);
37
+ return { ok: false, wert: 4 };
38
+ }
39
+ if (antwort.status === 401) {
40
+ console.error(`${meldung} Bitte \`kimal3 login\` ausführen.`);
41
+ return { ok: false, wert: 2 };
42
+ }
43
+ // 🔑 Den Rest entscheidet `schlussFehler` fuer alle Befehle gemeinsam --
44
+ // insbesondere die `404` (die App kennt die Route noch nicht) und `429`/
45
+ // `408` (ueberlastet, wiederholbar).
46
+ const schluss = schlussFehler(antwort.status, meldung);
47
+ console.error(schluss.text);
48
+ return { ok: false, wert: schluss.wert };
49
+ }
50
+ /** Prueft die Antwort-Form, ohne `any` -- eine falsche Form ist unlesbar, keine Ausnahme. */
51
+ function alsVorlageAntwort(koerper) {
52
+ if (!koerper || typeof koerper !== "object")
53
+ return null;
54
+ const wurzel = koerper.wurzel;
55
+ const dateienRoh = koerper.dateien;
56
+ if (typeof wurzel !== "string" || wurzel.trim() === "")
57
+ return null;
58
+ if (!Array.isArray(dateienRoh))
59
+ return null;
60
+ const dateien = [];
61
+ for (const eintrag of dateienRoh) {
62
+ if (!eintrag || typeof eintrag !== "object")
63
+ return null;
64
+ const pfad = eintrag.pfad;
65
+ const inhalt = eintrag.inhalt;
66
+ if (typeof pfad !== "string" || pfad.trim() === "")
67
+ return null;
68
+ if (typeof inhalt !== "string")
69
+ return null;
70
+ dateien.push({ pfad, inhalt });
71
+ }
72
+ return { wurzel, dateien };
73
+ }
74
+ /**
75
+ * 🔴 **Nie ueberschreiben** -- die wichtigste Eigenschaft dieser Befehle.
76
+ * Angelegt wird nur, wenn am Zielort NICHTS liegt oder ein LEERER Ordner.
77
+ *
78
+ * Genauer, weil hier zwei verschiedene Absagen stecken:
79
+ * - Zielort ist eine **Datei** (auch eine leere): Abbruch. An einer Datei kann
80
+ * kein Ordner entstehen, ohne sie zu loeschen -- und geloescht wird nichts.
81
+ * - Zielort ist ein **Ordner mit Inhalt**: Abbruch.
82
+ * - Zielort ist ein **leerer Ordner** oder gibt es nicht: los geht's.
83
+ *
84
+ * ⚠️ **Hier stand "eine leere Datei ... ist kein Hindernis"** (Review-Runde 4 zu
85
+ * PR #24) -- das war nie wahr. Wer `touch Mein-KI-Arbeitsplatz` gemacht hat oder
86
+ * wessen Editor eine leere Datei dieses Namens liegen liess, bekam den Abbruch,
87
+ * waehrend der Kommentar Durchlass versprach.
88
+ * *Die Prueffrage aus CLAUDE.md: Beschreibt das ein Verhalten, das jemand WILL --
89
+ * oder nur eines, das PASSIERT?* Hier wollte niemand das Beschriebene; der Code
90
+ * war richtig und der Satz darueber falsch.
91
+ *
92
+ * 🆕 **Neu am 2026-08-20:** `hinweis` — was die Absage **im Zweig „Ordner
93
+ * existiert und ist nicht leer"** zum Weitermachen raet. Nur dort: Seit
94
+ * Review-Runde 1 traegt er ein `cd` in den Zielordner, und in eine Datei oder
95
+ * einen unlesbaren Ordner kann man nicht hineinwechseln (Runde 2). Die beiden
96
+ * anderen Zweige haben ihren eigenen Satz.
97
+ * Der Rat stand dreimal fest getippt als `kimal3 arbeitsplatz neu <anderer-name>`
98
+ * im Text. Für `werkbank neu` wäre er falsch — und ein Rat, der in den falschen
99
+ * Befehl schickt, ist schlimmer als keiner.
100
+ *
101
+ * 🔴 **Und er trägt den GANZEN Rat, nicht nur den Befehl** (Messung am selben
102
+ * Tag, auf der Kundenstrecke). In der ersten Fassung war `hinweis` ein Befehl,
103
+ * und der Rahmensatz `Gib einen anderen Namen an:` gehörte dieser Datei. Nach
104
+ * der Umbenennung las sich das so:
105
+ *
106
+ * Gib einen anderen Namen an: kimal3 werkbank neu <dein-vorname>
107
+ *
108
+ * Einen anderen Namen kann man für den obersten Ordner seit der Umbenennung
109
+ * gerade **nicht** mehr geben — genau deshalb wurde der Rat ausgetauscht. *Wer
110
+ * nur das Ziel eines Satzes austauschbar macht, aber nicht seine Aussage, hat
111
+ * die halbe Wahrheit festgenagelt.*
112
+ */
113
+ export function dateienSchreiben(dateien, ordnerName, hinweis) {
114
+ // 🔴 B-7: eine leere Lieferung ist ein FEHLERFALL, kein Erfolg mit null
115
+ // Dateien -- sonst meldet der Befehl "✓ 0 Dateien angelegt." und legt dabei
116
+ // nicht einmal einen Ordner an. Derselbe Rueckgabewert wie bei anderen
117
+ // unlesbaren Antworten (3): der Nutzer hat hier nichts falsch gemacht.
118
+ if (dateien.length === 0) {
119
+ console.error("Die Antwort enthielt keine einzige Datei -- das ist kein gueltiges Ergebnis.");
120
+ console.error("Bitte gleich noch einmal versuchen.");
121
+ return 3;
122
+ }
123
+ const ziel = resolve(ordnerName);
124
+ /*
125
+ 🔴 Diese drei Aufrufe brauchen ein eigenes Netz (Review-Runde 3). Sie lagen
126
+ ausserhalb jedes `try`: Ist der Zielordner vorhanden, aber nicht lesbar
127
+ (fremder Besitzer, gesperrte Netzfreigabe -- EACCES), flog `readdirSync`
128
+ bis in den Auffang-Block von `kimal3.ts` durch. Der druckt dann
129
+ „kimal3 ist gerade nicht erreichbar." und gibt 3 zurueck.
130
+
131
+ Das ist gleich doppelt falsch: Der Mensch sucht den Fehler beim Server,
132
+ obwohl er auf seiner eigenen Platte sitzt -- und 3 heisst fuer Claude laut
133
+ SKILL.md „gleich noch einmal versuchen", was hier nie helfen kann.
134
+ *Ein Fehlerfall darf nie denselben Namen tragen wie ein anderer.*
135
+ */
136
+ try {
137
+ if (existsSync(ziel)) {
138
+ if (!statSync(ziel).isDirectory()) {
139
+ /*
140
+ 🔴 **Hier stand der `hinweis`** (Review-Runde 2 zu PR #25). Seit Runde 1
141
+ enthaelt er ein `cd` in den Zielordner -- und in eine DATEI kann man
142
+ nicht hineinwechseln. Der Rat war in diesem Zweig unausfuehrbar.
143
+ *Ein Parameter, der zu einem Zweig passt, gehoert nicht in alle drei.*
144
+ */
145
+ console.error(`„${ordnerName}" gibt es schon -- als DATEI, nicht als Ordner.`);
146
+ console.error("Benenn die Datei um oder geh in einen anderen Ordner.");
147
+ return 1;
148
+ }
149
+ /*
150
+ ⚠️ **Betriebssystem-Krimskrams zaehlt nicht als Inhalt** (Review-Runde 5).
151
+ `readdirSync` zaehlte vorher ALLES. Wer den Ordner angelegt und ihn nur
152
+ einmal im Finder (`.DS_Store`) oder Explorer (`desktop.ini`) geoeffnet
153
+ hat, bekam "gibt es schon und ist nicht leer" fuer einen Ordner, der fuer
154
+ ihn leer AUSSIEHT -- und keine Chance zu verstehen, warum. *Ein Grund, den
155
+ der Mensch nicht sehen kann, ist keine Begruendung, sondern eine Absage.*
156
+
157
+ Bewusst eine geschlossene Liste und kein "alles, was mit Punkt anfaengt":
158
+ Eine `.env` oder ein `.git` IST Inhalt, und den fassen wir nicht an.
159
+ */
160
+ const OS_KRIMSKRAMS = [".DS_Store", "desktop.ini", "Thumbs.db"];
161
+ const echterInhalt = readdirSync(ziel).filter((n) => !OS_KRIMSKRAMS.includes(n));
162
+ if (echterInhalt.length > 0) {
163
+ console.error(`Der Ordner „${ordnerName}" gibt es schon und ist nicht leer.`);
164
+ console.error("Ich fasse ihn nicht an -- so geht nie versehentlich eigene Arbeit verloren.");
165
+ console.error(hinweis);
166
+ return 1;
167
+ }
168
+ }
169
+ }
170
+ catch (fehler) {
171
+ console.error(`Ich komme an den Ordner „${ordnerName}" nicht heran.`);
172
+ console.error(`Grund: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
173
+ // ⚠️ Ebenfalls OHNE den `hinweis` (Runde 2): Wer den Ordner nicht lesen darf,
174
+ // kann auch nicht hineinwechseln. Hier hilft nur, was schon dasteht.
175
+ console.error("Das liegt an diesem Rechner, nicht am Server -- Rechte pruefen.");
176
+ return 1;
177
+ }
178
+ // 🔴 B-6: ALLE Pfade pruefen, BEVOR die erste Datei geschrieben wird. Wuerde
179
+ // erst waehrend des Schreibens geprueft, koennte ein unsicherer Pfad mitten
180
+ // in der Liste einen halb beschriebenen Ordner hinterlassen -- der naechste
181
+ // Versuch liefe dann in die "nicht leer"-Absage oben, ohne dass der Nutzer
182
+ // je erfaehrt, dass der Ordner unvollstaendig ist.
183
+ for (const datei of dateien) {
184
+ if (istUnsichererPfad(datei.pfad)) {
185
+ console.error(`Die Antwort enthaelt einen unsicheren Pfad: „${datei.pfad}". Bitte melden.`);
186
+ return 3;
187
+ }
188
+ }
189
+ try {
190
+ for (const datei of dateien) {
191
+ const ort = join(ziel, ...datei.pfad.split("/"));
192
+ mkdirSync(dirname(ort), { recursive: true });
193
+ // 🔑 B-2-Folge: Platzhalter wie [DATUM] fuellt seit PR #24 der SERVER
194
+ // (`src/lib/arbeitsplatz/ausliefern.ts`), fuer beide Auslieferungswege
195
+ // gleichzeitig -- Download auf `/pro` UND diese CLI. Wuerde die CLI
196
+ // zusaetzlich selbst ersetzen, gaebe es wieder zwei Wahrheiten ueber
197
+ // denselben Inhalt (genau das war der urspruengliche Befund). Die CLI
198
+ // schreibt den Inhalt deshalb hier UNVERAENDERT auf die Platte.
199
+ writeFileSync(ort, datei.inhalt, "utf8");
200
+ }
201
+ }
202
+ catch (fehler) {
203
+ /*
204
+ 🔴 **Hier stand `return 3` (Review-Runde 5 zu PR #24).** `3` heisst laut
205
+ `cli/skill/SKILL.md` "voruebergehende Stoerung -- gleich noch einmal
206
+ versuchen". Ein voller Datentraeger oder ein EACCES ist aber keine
207
+ Server-Stoerung, und der zweite Versuch kann gar nicht mehr gutgehen:
208
+ Der Zielordner EXISTIERT jetzt und ist nicht leer, also faellt der Lauf
209
+ oben in die Nicht-leer-Wache und endet mit `1` = "Eingabe passt nicht".
210
+ Claude sagt dem Menschen dann, sein Ordnername sei falsch -- und der
211
+ wahre Grund (Platte voll) kommt nie an.
212
+
213
+ Dieselbe Begruendung steht 40 Zeilen weiter oben schon einmal, beim
214
+ `readdirSync`-catch. *Ein Fehler, der einmal verstanden und
215
+ aufgeschrieben wurde, ist im Nachbar-catch trotzdem wieder neu.*
216
+ */
217
+ console.error(`Schreiben fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
218
+ console.error(`Der Ordner „${ordnerName}" ist dadurch UNVOLLSTAENDIG -- er darf geloescht werden.`);
219
+ console.error("Das liegt an diesem Rechner, nicht am Server -- Platz und Rechte pruefen.");
220
+ return 1;
221
+ }
222
+ return 0;
223
+ }
224
+ /**
225
+ * Ein `..`-Segment oder ein absoluter Pfad duerften das Ziel verlassen.
226
+ *
227
+ * 🔴 B-8: Zerlegt an `/` UND `\` -- eine fehlerhafte oder manipulierte
228
+ * Server-Antwort koennte sonst mit `..\..\boese.md` durchkommen. `join()`
229
+ * trennt unter Windows auch am Backslash, dieser Schutz muss also beide
230
+ * Trenner kennen wie `join()` selbst.
231
+ */
232
+ /**
233
+ * Taugt das als **Ordnername**? — schaerfer als `istUnsichererPfad`.
234
+ *
235
+ * 🔴 **Review-Runde 2 zu PR #25, und der Kommentar daneben war der Beweis.**
236
+ * `istUnsichererPfad` prueft einen PFAD: Es faengt `..` und absolute Pfade. Ein
237
+ * Ordner_name_ ist aber etwas Engeres — er ist **ein** Pfadteil. Und genau die
238
+ * beiden Werte, die als Pfadteil sinnlos sind, kommen durch:
239
+ *
240
+ * "" aus einer Antwort mit `/CLAUDE.md` -> resolve("") = das aktuelle Verzeichnis
241
+ * "." aus einer Antwort mit `./CLAUDE.md` -> resolve(".") = das aktuelle Verzeichnis
242
+ *
243
+ * In einem LEEREN Ordner haette `werkbank neu` die Dateien damit einfach dorthin
244
+ * geschrieben, wo der Mensch steht — und die Firmen-Wache daneben wird dabei
245
+ * still gruen, weil `firmenEbeneAus` genau diese Pfade ueberspringt.
246
+ *
247
+ * ⚠️ **In der Fassung von Runde 1 stand als Begruendung, `""` sei ungefaehrlich,
248
+ * „weil sonst die Wache oben schon rot waere".** Diese Wache ist in genau
249
+ * diesem Fall gruen. *Ein Kommentar, der eine Luecke mit einer anderen Wache
250
+ * begruendet, muss diese Wache im FEHLERFALL gelesen haben, nicht im Normalfall.*
251
+ *
252
+ * 🔑 Positiv formuliert statt negativ: Ein Ordnername ist EIN Teil, und dieser
253
+ * Teil ist weder leer noch `.` noch `..`. Eine Liste dessen, was verboten ist,
254
+ * waere beim naechsten Sonderfall wieder unvollstaendig.
255
+ */
256
+ export function istUnsichererOrdnername(name) {
257
+ if (istUnsichererPfad(name))
258
+ return true;
259
+ const teile = name.split(/[\\/]/);
260
+ if (teile.length !== 1)
261
+ return true;
262
+ return teile[0] === "" || teile[0] === ".";
263
+ }
264
+ export function istUnsichererPfad(pfad) {
265
+ if (pfad.startsWith("/") || /^[a-zA-Z]:/.test(pfad))
266
+ return true;
267
+ return pfad.split(/[\\/]/).some((teil) => teil === "..");
268
+ }