kimal3 0.4.0-durchstich.3 → 0.4.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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Die reinen Rechnungen zum Finden eines vorhandenen Arbeitsplatzes (Spec
3
+ * 2026-10-16-vorhandener-arbeitsplatz-lagen.md) -- ohne Dateisystem. Das Suchen auf der
4
+ * Platte kommt in eine eigene Datei; hier steht nur, was aus dem Gefundenen folgt.
5
+ *
6
+ * 🔑 **Warum die Werkbank am ORDNERNAMEN erkannt wird** (Spec, Bauform 5): Dennis' eigene
7
+ * Werkbank hat keine `CLAUDE.md`. Eine Erkennung "Unterordner mit eigener `CLAUDE.md`"
8
+ * (wie `werkbankAusDateien`) fande bei ihm gar keine -- ausgerechnet beim ersten Menschen,
9
+ * der den Befehl benutzt.
10
+ *
11
+ * 🔑 **Warum `bestaetigen` statt still zu waehlen:** Ein Name, der nur ueber seinen Anfang
12
+ * passt (`werkbank-sabine` zu sabine.mueller@...), passt genauso zu Sabine Schmidt. Zwei
13
+ * Sabines in einer Firma waren der Befund 10 der Pruefrunde 2. Deshalb fragt `einrichten`
14
+ * einmal und merkt sich die Antwort zum Konto; nur der volle Name gilt ohne Frage.
15
+ *
16
+ * ⚠️ Die Vordrucke aus der Vorlage (`werkbank-dein-name`) gehoeren niemandem: Sie sind nie
17
+ * waehlbar und machen aus einem Ordner keinen Arbeitsplatz (V-15, Repo-Klon in Suchtiefe).
18
+ */
19
+ import { arbeitsplatzAus } from "./kontext-stand.js";
20
+ import { WERKBANK_PRAEFIXE, werkbankOrdnerAus, werkbankSpracheAus } from "./werkbank-name.js";
21
+ /** Die Werkbank-Namen, die die Vorlage selbst mitbringt. */
22
+ export const WERKBANK_VORDRUCKE = ["werkbank-dein-name", "workbench-your-name"];
23
+ const istVordruck = (name) => WERKBANK_VORDRUCKE.includes(name);
24
+ /** Eine Schreibweise fuer den Vergleich: Windows unterscheidet Gross/klein nicht, `\` und `/` sind dasselbe. */
25
+ const pfadSchluessel = (pfad) => pfad.replaceAll("\\", "/").toLowerCase();
26
+ /**
27
+ * Welche der Kandidaten Arbeitsplätze sind.
28
+ *
29
+ * 🔴 **`mitWerkbank` ist Pflicht, kein optionaler Schalter** (Ruling 8): Die Regel von `kontext runter` allein
30
+ * (eine `CLAUDE.md` und `context/company.md`) ist die übliche Form JEDES Claude-Projekts. Ohne Werkbank hielte die
31
+ * SUCHE das eigene Repo des Kunden für einen Arbeitsplatz, und V-12c ließe Claude dort eine Werkbank anlegen.
32
+ * Deshalb zählt beim Suchen (`true`) nur ein Ordner mit mindestens einer echten Werkbank. Hat der Mensch den
33
+ * Ordner dagegen SELBST genannt (`--arbeitsplatz`, der gemerkte Ort) oder geht es darum, ob ein Ordner fremd ist
34
+ * (`false`), zählt auch einer ohne Werkbank -- sonst wäre V-12c unerreichbar, und ein eigener Arbeitsplatz mit
35
+ * gelöschter Werkbank hieße „fremd“.
36
+ */
37
+ export function fundAus(kandidaten, o) {
38
+ const pfade = [];
39
+ const gesehen = new Set();
40
+ for (const k of kandidaten) {
41
+ if (arbeitsplatzAus([k]).art === "keiner")
42
+ continue;
43
+ // Nur Vordrucke = die Vorlage aus dem Repo (V-15) -- in keinem Fall ein Arbeitsplatz.
44
+ const echte = k.werkbaenke.filter((n) => !istVordruck(n));
45
+ if (k.werkbaenke.length > 0 && echte.length === 0)
46
+ continue;
47
+ if (o.mitWerkbank && echte.length === 0)
48
+ continue;
49
+ const schluessel = pfadSchluessel(k.pfad);
50
+ if (gesehen.has(schluessel))
51
+ continue;
52
+ gesehen.add(schluessel);
53
+ pfade.push(k.pfad);
54
+ }
55
+ if (pfade.length === 0)
56
+ return { art: "keiner" };
57
+ if (pfade.length === 1)
58
+ return { art: "einer", pfad: pfade[0] };
59
+ return { art: "mehrere", pfade };
60
+ }
61
+ /** Der Name ohne sein Praefix (`werkbank-sabine` -> `sabine`); `null`, wenn es keine Werkbank ist. */
62
+ function kernAus(name) {
63
+ const sprache = werkbankSpracheAus(name);
64
+ return sprache === null ? null : name.slice(WERKBANK_PRAEFIXE[sprache].length);
65
+ }
66
+ export function werkbankWahl(namen, email, gewaehlt) {
67
+ const echte = namen.filter((n) => !istVordruck(n) && kernAus(n) !== null);
68
+ if (echte.length === 0)
69
+ return { art: "keine" };
70
+ if (gewaehlt !== null && echte.includes(gewaehlt))
71
+ return { art: "genau", name: gewaehlt };
72
+ // Der erwartete Kern ist in beiden Sprachen derselbe; das Praefix ist beim Vergleich egal (V-13).
73
+ const erwartet = werkbankOrdnerAus(email, "de");
74
+ const erwarteterKern = erwartet === null ? null : kernAus(erwartet);
75
+ if (erwarteterKern === null)
76
+ return { art: "keine-passende", namen: echte };
77
+ const voll = echte.find((n) => kernAus(n) === erwarteterKern);
78
+ if (voll !== undefined)
79
+ return { art: "genau", name: voll };
80
+ const anfang = echte.filter((n) => erwarteterKern.startsWith(`${kernAus(n)}-`));
81
+ if (anfang.length === 1)
82
+ return { art: "bestaetigen", name: anfang[0] };
83
+ if (anfang.length > 1)
84
+ return { art: "mehrere", namen: anfang };
85
+ return { art: "keine-passende", namen: echte };
86
+ }
87
+ /**
88
+ * Steht die Anleitung fuer die erste Sitzung noch in der Werkbank-`CLAUDE.md`? Sie geht erst,
89
+ * wenn der Mensch sie ausgefuellt hat (Marke weg, kein ✏️ mehr). Ohne Datei: nein.
90
+ *
91
+ * Eine Zitatzeile (erstes Zeichen `>`) zaehlt nicht: Die alte Vorlage behaelt dort fuer immer den
92
+ * Hinweis „✏️ Muss nicht von Hand ausgefuellt werden …“, auch bei ausgefuelltem Profil -- sonst
93
+ * schickte `kimal3 einrichten` jeden Bestandskunden wieder in „Lass uns einrichten“.
94
+ */
95
+ export function ersteSitzungOffen(werkbankClaudeMd) {
96
+ if (werkbankClaudeMd === null)
97
+ return false;
98
+ const ohneZitate = werkbankClaudeMd.split(/\r?\n/).filter((z) => !z.trimStart().startsWith(">")).join("\n");
99
+ return ohneZitate.includes("<!-- kimal3:erste-sitzung -->") || ohneZitate.includes("✏️"); // kein-anzeigetext: Marke in der Datei, kein Satz fuer Menschen
100
+ }
101
+ /** Der gemerkte Ort -- nur wenn die Datei die richtige Form hat UND zu diesem Konto gehoert (V-12b). */
102
+ export function gemerktFuer(roh, email) {
103
+ if (typeof roh !== "object" || roh === null)
104
+ return null;
105
+ const o = roh;
106
+ if (typeof o.email !== "string" || typeof o.pfad !== "string")
107
+ return null;
108
+ if (o.werkbank !== null && typeof o.werkbank !== "string")
109
+ return null;
110
+ if (o.zustand !== "angefangen" && o.zustand !== "fertig")
111
+ return null;
112
+ if (o.email.toLowerCase() !== email.toLowerCase())
113
+ return null;
114
+ return { email: o.email, pfad: o.pfad, werkbank: o.werkbank, zustand: o.zustand };
115
+ }
@@ -0,0 +1,176 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir, platform } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { gemerktFuer, fundAus } from "./arbeitsplatz-finden.js";
6
+ import { werkbankSpracheAus } from "./werkbank-name.js";
7
+ /**
8
+ * Das Suchen auf der Platte (Spec 2026-10-16-vorhandener-arbeitsplatz-lagen.md, Bauform 2 und 3).
9
+ * Was aus dem Gefundenen folgt, rechnet `arbeitsplatz-finden.ts` -- ohne Dateisystem.
10
+ *
11
+ * 🔬 **Die Messung vom 16.10.** (auf Dennis' Rechner): Bis Ebene 3 unter dem Benutzerordner sind es
12
+ * 110 Ordner, gelesen in 0,2 s -- die Suche ist billig, solange sie Verknuepfungen meidet. Im
13
+ * Benutzerordner liegen elf davon (`OneDrive`, `Eigene Dateien`, `Anwendungsdaten` ...); Node
14
+ * meldet sie als Verknuepfung (`isSymbolicLink`), und wer sie betritt, laeuft im Kreis oder in
15
+ * fremde Konten. "Versteckt" ist unter Windows ein Dateiattribut, das Node nicht liefert --
16
+ * deshalb zaehlt nur der Punkt am Namensanfang.
17
+ *
18
+ * ⚠️ Die Zeitgrenze macht aus einer langsamen Platte ein "nicht vollstaendig", nie einen Abbruch:
19
+ * wer sicher sein will, dass nichts uebersehen wurde, fragt `vollstaendig`.
20
+ */
21
+ /** Ordner, in denen nie ein Arbeitsplatz liegt, aber viele Dateien (kleingeschrieben verglichen). */
22
+ const AUSLASSEN = ["appdata", "library", "node_modules"]; // kein-anzeigetext: Ordnernamen
23
+ /** Nur auf macOS und nur direkt im Benutzerordner: grosse Ablagen ohne Arbeitsplatz. */
24
+ const AUSLASSEN_MAC_EBENE_1 = ["desktop", "downloads"]; // kein-anzeigetext: Ordnernamen
25
+ const TIEFE_HEIM = 3;
26
+ const TIEFE_DOKUMENTE = 2;
27
+ function namenIn(pfad, nurDateien) {
28
+ try {
29
+ return readdirSync(pfad, { withFileTypes: true }).filter((e) => !nurDateien || !e.isDirectory()).map((e) => e.name);
30
+ }
31
+ catch {
32
+ return []; // ein Ordner, in den ich nicht hineinsehe, zaehlt wie einer ohne unsere Dateien
33
+ }
34
+ }
35
+ function istDatei(pfad) {
36
+ try {
37
+ return statSync(pfad).isFile();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /** Was dieser Ordner ueber sich verraet: `kontext/`, `context/`, `CLAUDE.md` und die Werkbank-Unterordner. */
44
+ export function kandidatAus(pfad) {
45
+ const eintraege = (() => {
46
+ try {
47
+ return readdirSync(pfad, { withFileTypes: true });
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ })();
53
+ return {
54
+ pfad,
55
+ kontextNamen: namenIn(join(pfad, "kontext"), true), // kein-anzeigetext: Ordnername
56
+ contextNamen: namenIn(join(pfad, "context"), true), // kein-anzeigetext: Ordnername
57
+ hatClaude: istDatei(join(pfad, "CLAUDE.md")), // kein-anzeigetext: Dateiname
58
+ werkbaenke: eintraege.filter((e) => e.isDirectory() && werkbankSpracheAus(e.name) !== null).map((e) => e.name),
59
+ };
60
+ }
61
+ /**
62
+ * Macht dieser Lesefehler die Suche unvollstaendig? Nur unter macOS, nur `EPERM`, nur auf `~/Documents`
63
+ * (V-6, V-8). Der Ordner wird ueber seinen Pfad erkannt -- er liegt auf Ebene 2 des Benutzerordners.
64
+ */
65
+ export function macVerweigert(plattform, heim, ordner, code) {
66
+ return plattform === "darwin" && code === "EPERM" && ordner === join(heim, "Documents"); // kein-anzeigetext: Ordnername
67
+ }
68
+ export function sucheArbeitsplatz(a) {
69
+ const ende = Date.now() + a.fristMs;
70
+ const treffer = [];
71
+ let vollstaendig = true;
72
+ const pruefe = (pfad) => {
73
+ const k = kandidatAus(pfad);
74
+ // Nur mit echter Werkbank (Ruling 8): Ein Claude-Projekt des Kunden ist kein Treffer und wird weiter durchsucht.
75
+ if (fundAus([k], { mitWerkbank: true }).art === "keiner")
76
+ return false;
77
+ treffer.push(k);
78
+ return true;
79
+ };
80
+ // (a) Vom aktuellen Ordner aufwaerts -- billig und ohne Zeitgrenze. 200 wie in `findeArbeitsplatz`.
81
+ let oben = resolve(a.start);
82
+ for (let i = 0; i < 200; i++) {
83
+ pruefe(oben);
84
+ const eltern = dirname(oben);
85
+ if (eltern === oben)
86
+ break;
87
+ oben = eltern;
88
+ }
89
+ // (b) und (c): Die Tiefe zaehlt ab der Wurzel (Kinder = Ebene 1). Ein Treffer wird nicht weiter durchstiegen.
90
+ const durchsuche = (ordner, ebene, maxTiefe) => {
91
+ if (Date.now() >= ende) {
92
+ vollstaendig = false;
93
+ return;
94
+ }
95
+ let eintraege;
96
+ try {
97
+ eintraege = readdirSync(ordner, { withFileTypes: true });
98
+ }
99
+ catch (e) {
100
+ // Unter macOS fragt das System beim ersten Zugriff auf Dokumente nach; ein Nein ist ein `EPERM`
101
+ // genau dort -- das ist eine Luecke, die die Suche melden muss (V-6, V-8). Sonst: still weiter.
102
+ const code = e.code;
103
+ if (macVerweigert(a.plattform, a.heim, ordner, code))
104
+ vollstaendig = false;
105
+ return;
106
+ }
107
+ for (const eintrag of eintraege) {
108
+ // Verknuepfungen nie betreten: `isDirectory()` ist fuer sie falsch, die Zeile sagt es trotzdem laut.
109
+ if (eintrag.isSymbolicLink() || !eintrag.isDirectory())
110
+ continue;
111
+ const name = eintrag.name.toLowerCase();
112
+ if (name.startsWith(".") || AUSLASSEN.includes(name))
113
+ continue;
114
+ if (a.plattform === "darwin" && ordner === a.heim && AUSLASSEN_MAC_EBENE_1.includes(name))
115
+ continue;
116
+ const kind = join(ordner, eintrag.name);
117
+ if (pruefe(kind))
118
+ continue;
119
+ if (ebene < maxTiefe)
120
+ durchsuche(kind, ebene + 1, maxTiefe);
121
+ }
122
+ };
123
+ durchsuche(a.heim, 1, TIEFE_HEIM);
124
+ if (a.dokumente !== null)
125
+ durchsuche(a.dokumente, 1, TIEFE_DOKUMENTE);
126
+ return { fund: fundAus(treffer, { mitWerkbank: true }), vollstaendig };
127
+ }
128
+ /** Aus der Zeile `Personal REG_EXPAND_SZ %USERPROFILE%\OneDrive\Dokumente` den Pfad; `%NAME%` kommt aus `env`. */
129
+ export function dokumenteAusRegistry(ausgabe, env) {
130
+ const wert = /Personal\s+REG_\w+\s+(.+?)\s*$/m.exec(ausgabe)?.[1]; // kein-anzeigetext: Registry-Wert
131
+ if (wert === undefined)
132
+ return null;
133
+ // Unbekannte Namen bleiben stehen -- dann gibt es den Ordner nicht, und die Suche ueberspringt ihn.
134
+ return wert.replace(/%([^%]+)%/g, (rest, name) => env[name] ?? rest);
135
+ }
136
+ /**
137
+ * Der Ordner "Dokumente" unter Windows -- bei OneDrive-Konten liegt er NICHT unter dem Benutzerordner.
138
+ * `KIMAL3_DOKUMENTE` hat Vorrang (leer = keiner), damit Messungen nie den echten Ordner lesen.
139
+ */
140
+ export function windowsDokumente() {
141
+ const vorgabe = process.env.KIMAL3_DOKUMENTE; // kein-anzeigetext: Umgebungsvariable
142
+ if (vorgabe !== undefined)
143
+ return vorgabe === "" ? null : vorgabe;
144
+ if (platform() !== "win32")
145
+ return null;
146
+ const r = spawnSync("reg", // kein-anzeigetext: Windows-Programm
147
+ ["query", String.raw `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders`, "/v", "Personal"], // kein-anzeigetext: Registry
148
+ { encoding: "utf8", windowsHide: true });
149
+ if (r.status !== 0)
150
+ return null;
151
+ return dokumenteAusRegistry(r.stdout, process.env);
152
+ }
153
+ const ORDNER = join(homedir(), ".kimal3");
154
+ const DATEI = join(ORDNER, "arbeitsplatz.json"); // kein-anzeigetext: Dateiname
155
+ /** Der gemerkte Ort dieses Kontos; kaputte oder fremde Datei -> `null`. Wirft nie. */
156
+ export function leseGemerktenOrt(email) {
157
+ try {
158
+ return gemerktFuer(JSON.parse(readFileSync(DATEI, "utf8")), email);
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ }
164
+ /**
165
+ * Den Ort merken. 🔴 Wirft nie: Ein nicht schreibbarer Merkzettel darf `einrichten` nicht scheitern
166
+ * lassen -- schlimmstenfalls wird einmal mehr gesucht. `mode: 0o700` wie in `gesehen.ts`.
167
+ */
168
+ export function merkeOrt(o) {
169
+ try {
170
+ mkdirSync(ORDNER, { recursive: true, mode: 0o700 });
171
+ writeFileSync(DATEI, JSON.stringify(o, null, 2), "utf8");
172
+ }
173
+ catch {
174
+ /* nicht schreibbar -- der Befehl laeuft trotzdem durch */
175
+ }
176
+ }
@@ -1,16 +1,23 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { leseAnmeldung } from "../anmeldung.js";
6
+ import { alterKursCoachAblage, alterKursCoachPfad, coachDateiLiegt, legeCoachAb, meldeCoach } from "../coach-ablegen.js";
7
+ import { istAlterKursCoach } from "../coach-rechnung.js";
6
8
  import { tragePluginsEin } from "../einstellungen-schreiben.js";
7
9
  import { logoAblegen } from "../logo-ablegen.js";
8
10
  import { pluginStand } from "../plugins-mischen.js";
9
11
  import { ruf } from "../api.js";
10
12
  import { FIRMEN_OPTIONEN, fehlendeDateienSchreiben, istUnsichererOrdnername, vorlageHolen, werkbankOrdner, } from "../arbeitsplatz-dateien.js";
11
- import { zerlege } from "../argumente.js";
13
+ import { WERKBANK_VORDRUCKE, ersteSitzungOffen, fundAus, werkbankWahl } from "../arbeitsplatz-finden.js";
14
+ import { kandidatAus, leseGemerktenOrt, merkeOrt, sucheArbeitsplatz, windowsDokumente } from "../arbeitsplatz-suche.js";
15
+ import { alsWort, zerlege } from "../argumente.js";
16
+ import { befehlIn, schalterIn } from "../befehlswoerter.js";
17
+ import { ORDNER_NAMEN } from "../kontext-stand.js";
12
18
  import { sprache } from "../sprache.js";
13
19
  import { ALLGEMEIN_TEXTE } from "../texte/allgemein.js";
20
+ import { COACH_TEXTE } from "../texte/coach.js";
14
21
  import { EINRICHTEN_TEXTE } from "../texte/einrichten.js";
15
22
  import { anmeldenMitBrowser } from "./login.js";
16
23
  import { SKILL_TEXTE } from "../texte/skill.js";
@@ -21,14 +28,22 @@ import { skillInstall, ursacheDes } from "./skill-install.js";
21
28
  * Anmeldung (mit Browser), die Vorlage von `/api/cli/arbeitsplatz`, `skill install`, und setzt
22
29
  * die Kurs-Haken.
23
30
  *
24
- * 🔑 Der Arbeitsplatz liegt IMMER im Benutzerordner (N-8), nie im Ordner, in dem Claude gerade
25
- * steht -- die Sitzung ohne Ordner ist ein Wegwerf-Ordner der App (M-L2).
26
- * 🔑 Nichts wird überschrieben: Ein zweiter Lauf ergänzt nur Fehlendes (`fehlendeDateienSchreiben`).
31
+ * 🔑 Einen VORHANDENEN Arbeitsplatz benutzt er, wie er ist (Spec 2026-10-16, Bauform 1): gemerkt in
32
+ * `~/.kimal3/arbeitsplatz.json`, sonst gesucht. Neu angelegt wird er im Benutzerordner (N-8), nie im
33
+ * Ordner, in dem Claude gerade steht -- die Sitzung ohne Ordner ist ein Wegwerf-Ordner der App (M-L2).
34
+ * 🔑 Nichts wird überschrieben: Nachgeliefert wird nur in einen eigenen, abgebrochenen Lauf (gemerkt
35
+ * `angefangen`, V-17). Ein fertiger Arbeitsplatz bekommt keine Datei zurück, die der Mensch gelöscht hat (V-16).
36
+ * 🔑 Jede Rückfrage (welcher Arbeitsplatz, welche Werkbank, fremder Ordner) endet mit `1` und fertigen
37
+ * Befehlen, BEVOR Plugins, Befehle oder Haken eingetragen werden -- Claude fragt den Menschen und startet
38
+ * den Befehl, der zu seiner Antwort passt.
27
39
  *
28
40
  * 🔑 superpowers und das Logo trägt er seit Bauplan 2026-10-15 selbst in Claudes Einstellungen ein
29
41
  * (`pluginsEinrichten`) -- Claude installiert dafür nichts mehr per Befehlszeile oder Menü.
30
42
  *
31
- * 📌 Noch ohne Coach und ohne Suche nach einem vorhandenen Arbeitsplatz an anderer Stelle (S-8).
43
+ * 🔑 Den Coach legt er in Claudes Regel-Ordner (`~/.claude/rules/kimal3-coach.md`, Spec 2026-10-16, zweiter Teil von
44
+ * S-8) -- vorher fragt er den Server einmal nach dem Kursstand: Das frischt die Kontosprache auf und prüft die
45
+ * Anmeldung, damit Plugins, Befehle und Coach in der richtigen Sprache entstehen (Bauform 5). `~/.claude/CLAUDE.md`
46
+ * liest er nur: Steht dort der Coach aus dem alten Mini-Kurs, sagt eine Zeile es, und Claude fragt den Menschen (S-14).
32
47
  */
33
48
  export async function einrichten(argv) {
34
49
  const s = sprache();
@@ -36,25 +51,227 @@ export async function einrichten(argv) {
36
51
  console.log(EINRICHTEN_TEXTE[s].help);
37
52
  return 0;
38
53
  }
39
- const zerlegt = zerlege(argv, FIRMEN_OPTIONEN, s);
54
+ const zerlegt = zerlege(argv, [...FIRMEN_OPTIONEN, "arbeitsplatz", "werkbank"], s);
40
55
  if (!zerlegt.ok) {
41
56
  console.error(`${zerlegt.meldung}\n${EINRICHTEN_TEXTE[s].help}`);
42
57
  return 1;
43
58
  }
44
- const wunsch = { firma: zerlegt.werte.optionen.firma, firmaId: zerlegt.werte.optionen["firma-id"] };
59
+ const angaben = zerlegt.werte.optionen;
60
+ const wunsch = { firma: angaben.firma, firmaId: angaben["firma-id"] };
61
+ // Ein mitkopierter Platzhalter aus einer Rückfrage („<pfad>“) ist kein Ordner -- sonst entstünde ein Ordner
62
+ // dieses Namens (macOS) oder ein Schreibfehler mitten im Lauf (Windows). Vor der Anmeldung: nichts geschrieben.
63
+ if (angaben.arbeitsplatz !== undefined && /[<>]/.test(angaben.arbeitsplatz))
64
+ return frage([EINRICHTEN_TEXTE[s].placeholderPath(angaben.arbeitsplatz)]);
45
65
  console.log(EINRICHTEN_TEXTE[s].start);
46
66
  const gespeichert = leseAnmeldung();
47
67
  if (gespeichert)
48
68
  console.log(ALLGEMEIN_TEXTE[s].signedInAs(gespeichert.email));
49
69
  else {
50
- const angemeldet = await anmeldenMitBrowser();
70
+ const angemeldet = await anmeldenEinmal();
51
71
  if (angemeldet !== 0)
52
72
  return angemeldet;
53
73
  }
74
+ // Gleich nach der Anmeldung, VOR der ersten Zeile über den Arbeitsplatz: Erst danach kennt `sprache()` die Kontosprache,
75
+ // und schon „Dein Arbeitsplatz liegt schon unter …“ soll in ihr stehen (Gesamt-Durchsicht, Fund 7). Liest nur -- eine
76
+ // Rückfrage danach hat weiter nichts geschrieben (eine nötige Neuanmeldung ausgenommen, wie oben).
77
+ await kursstandVorab();
78
+ // Die Adresse entscheidet, welcher gemerkte Ort und welche Werkbank diesem Konto gehören (V-12b).
79
+ const email = leseAnmeldung()?.email ?? "";
80
+ const gemerkt = leseGemerktenOrt(email);
81
+ const ziel = zielBestimmen(angaben.arbeitsplatz, gemerkt);
82
+ if (ziel.art === "rueckfrage")
83
+ return frage(ziel.zeilen);
84
+ let ort;
85
+ if (ziel.art === "vorhanden") {
86
+ // 🔑 Kein Abruf der Vorlage (V-19): Ein gefundener Arbeitsplatz braucht sie nicht, also auch keine Firmenfrage.
87
+ console.log(EINRICHTEN_TEXTE[sprache()].foundWorkplace(ziel.pfad));
88
+ const werkbank = werkbankBestimmen(ziel.pfad, email, angaben.werkbank, gemerkt);
89
+ if (typeof werkbank !== "string")
90
+ return frage(werkbank);
91
+ ort = { pfad: ziel.pfad, werkbank };
92
+ }
93
+ else {
94
+ const neu = await neuAnlegen(ziel.pfad, wunsch, email, gespeichert !== null);
95
+ if (typeof neu === "number")
96
+ return neu;
97
+ ort = neu;
98
+ }
99
+ // 🌐 Die Antwort der Vorlage kann die Kontosprache umgestellt haben -- ab hier in der neuen.
100
+ const t = EINRICHTEN_TEXTE[sprache()];
101
+ console.log(t.workplaceLine(ort.pfad));
102
+ console.log(t.workbenchLine(ort.werkbank));
103
+ console.log(t.nextLine(ersteSitzungOffen(liesOderNull(join(ort.pfad, ort.werkbank, "CLAUDE.md"))))); // kein-anzeigetext: Dateiname
104
+ merkeOrt({ email, pfad: ort.pfad, werkbank: ort.werkbank, zustand: "fertig" });
105
+ // Plugins VOR der Wache: So hält ihre Sicherungskopie Sabines unveränderte Einstellungen (QA-Fund 9).
106
+ const git = gitAntwortet();
107
+ pluginsEinrichten(git);
108
+ // Der Coach VOR `skillInstall()`: Das frischt einen vorhandenen Coach auf und findet ihn so schon aktuell -- still.
109
+ meldeCoach(legeCoachAb(sprache(), true), sprache());
110
+ const befehle = await skillInstall();
111
+ const alt = alterKursCoachPfad();
112
+ if (istAlterKursCoach(liesOderNull(alt) ?? ""))
113
+ console.log(COACH_TEXTE[sprache()].oldCourseCoach(alt, alterKursCoachAblage()));
114
+ // `beat2-coach` heißt „Coach und /k3-Befehle sind dauerhaft eingerichtet“ (E-9): Unter dem Namen liegt eine DATEI
115
+ // (auch eine fremde, E-5 -- ein Ordner ist kein Coach) und die Befehle stehen. Logo und Plugins zählen nicht.
116
+ const coachSteht = befehle === 0 && coachDateiLiegt();
117
+ const haken = git ? ["beat2-werkzeuge", "beat2-kimal3", "beat2-arbeitsplatz", "beat2-coach"] : ["beat2-kimal3", "beat2-arbeitsplatz", "beat2-coach"];
118
+ await kursAbhaken(haken.filter((id) => id !== "beat2-coach" || coachSteht));
119
+ if (befehle !== 0)
120
+ return befehle;
121
+ console.log(EINRICHTEN_TEXTE[sprache()].done);
122
+ return 0;
123
+ }
124
+ /**
125
+ * Fragt den Kursstand, bevor etwas in einer Sprache geschrieben wird (Spec 2026-10-16, Bauform 5): Jede Antwort trägt
126
+ * die Kontosprache, und `ruf` übernimmt sie -- so ist die Einrichtung in der Sprache, die Sabine gerade in der App
127
+ * gewählt hat (C-6). Den Stand selbst braucht `einrichten` nicht.
128
+ * 🔑 `401` -> neu anmelden (`anmeldenEinmal`) und noch einmal fragen. Scheitert das, läuft die Einrichtung trotzdem
129
+ * weiter (Durchsicht Aufgabe 5, Fund 5): Plugins, Coach und Befehle brauchen kein Netz; nur die Haken fehlen dann, und
130
+ * `kursAbhaken` sagt es in einer Zeile. Ein zweites `401` nach der Anmeldung bleibt ohne weitere Anmeldung.
131
+ * 🔑 Jeder andere Ausgang (auch `404` einer App ohne diese Route, Netzfehler) zählt nicht: weiter, ohne Zeile (Spec 6).
132
+ */
133
+ async function kursstandVorab() {
134
+ const stand = async () => {
135
+ try {
136
+ return (await ruf("/api/cli/kurs", { methode: "GET", mitAusweis: true })).status; // kein-anzeigetext: Pfad der Schnittstelle
137
+ }
138
+ catch {
139
+ return 0;
140
+ }
141
+ };
142
+ if ((await stand()) === 401 && (await anmeldenEinmal()) === 0)
143
+ await stand();
144
+ }
145
+ /**
146
+ * Höchstens EINE Anmeldung mit Browser je Lauf (Durchsicht Aufgabe 5, Fund 5). Jede davon wartet bis zu zehn Minuten
147
+ * auf Sabines Bestätigung; scheiterte die erste (abgelaufen, kein Netz), soll ein zweiter Versuch sie nicht noch einmal
148
+ * zehn Minuten warten lassen. Jeder weitere Aufruf antwortet `2` („nicht angemeldet“), ohne Browser.
149
+ */
150
+ let anmeldungVersucht = false;
151
+ async function anmeldenEinmal() {
152
+ if (anmeldungVersucht)
153
+ return 2;
154
+ anmeldungVersucht = true;
155
+ return anmeldenMitBrowser();
156
+ }
157
+ /**
158
+ * Die Reihenfolge aus Spec 2026-10-16, Bauform 2 und 3: die Angabe des Menschen, dann der gemerkte Ort
159
+ * dieses Kontos, dann die Suche. Liest nur, schreibt nichts und fragt kein Netz.
160
+ */
161
+ function zielBestimmen(angabe, gemerkt) {
162
+ const t = EINRICHTEN_TEXTE[sprache()];
163
+ if (angabe !== undefined) {
164
+ const pfad = resolve(angabe);
165
+ // Auch ein Ordner IN einem Arbeitsplatz meint diesen -- nie einen zweiten darin anlegen. Ohne Werkbank zählt nur
166
+ // der genannte Ordner selbst (V-12c); ein Ordner DARÜBER ist gesucht, nicht genannt, und braucht wie bei der Suche
167
+ // eine Werkbank (Ruling 8) -- sonst wäre `--arbeitsplatz ~/Projects/app/KI-Arbeitsplatz` das Repo `app`.
168
+ for (let oben = pfad;; oben = dirname(oben)) {
169
+ if (istArbeitsplatz(oben, oben !== pfad))
170
+ return { art: "vorhanden", pfad: oben };
171
+ if (dirname(oben) === oben)
172
+ break;
173
+ }
174
+ if (istFremd(pfad))
175
+ return { art: "rueckfrage", zeilen: [t.foreignFolder(pfad), t.otherPlace, platzBefehl(t.pathPlaceholder)] };
176
+ return { art: "neu", pfad };
177
+ }
178
+ // Ein eigener, abgebrochener Lauf wird fertig geschrieben (V-17) -- der einzige Fall, in dem nachgeliefert wird.
179
+ if (gemerkt?.zustand === "angefangen" && existsSync(gemerkt.pfad))
180
+ return { art: "neu", pfad: gemerkt.pfad };
181
+ if (gemerkt?.zustand === "fertig" && istArbeitsplatz(gemerkt.pfad, false))
182
+ return { art: "vorhanden", pfad: gemerkt.pfad };
183
+ const { fund, vollstaendig } = sucheArbeitsplatz({
184
+ start: process.cwd(),
185
+ heim: homedir(),
186
+ dokumente: windowsDokumente(),
187
+ plattform: platform(),
188
+ fristMs: 3000,
189
+ });
190
+ if (fund.art === "einer")
191
+ return { art: "vorhanden", pfad: fund.pfad };
192
+ if (fund.art === "mehrere")
193
+ return { art: "rueckfrage", zeilen: [t.severalFound, ...fund.pfade.map((p) => platzBefehl(p))] };
194
+ // 🔑 Es gab einen, oder die Suche kam nicht überall hin: dann NICHT still einen zweiten anlegen (V-7, V-8).
195
+ const woIstEr = [t.existingWorkplace, platzBefehl(t.pathPlaceholder), t.newWorkplace, platzBefehl(join(homedir(), ORDNER_NAMEN[sprache()].wurzel))];
196
+ if (gemerkt !== null)
197
+ return { art: "rueckfrage", zeilen: [t.rememberedGone(gemerkt.pfad), ...woIstEr] };
198
+ if (!vollstaendig)
199
+ return { art: "rueckfrage", zeilen: [t.searchIncomplete, ...woIstEr] };
200
+ return { art: "neu", pfad: null };
201
+ }
202
+ /**
203
+ * Welche Werkbank im gefundenen Arbeitsplatz diesem Konto gehört (Bauform 5) -- ihr Name, oder die Zeilen
204
+ * der Rückfrage. 🔑 Die Rückfrage-Befehle nennen den Arbeitsplatz mit: Kam er aus `--arbeitsplatz` (nach
205
+ * „mehrere gefunden“), fände ein Lauf nur mit `--werkbank` wieder mehrere -- eine Frage im Kreis.
206
+ */
207
+ function werkbankBestimmen(pfad, email, angabe, gemerkt) {
208
+ const t = EINRICHTEN_TEXTE[sprache()];
209
+ const echte = kandidatAus(pfad).werkbaenke.filter((n) => !WERKBANK_VORDRUCKE.includes(n));
210
+ const frueher = gemerkt !== null && resolve(gemerkt.pfad) === resolve(pfad) ? gemerkt.werkbank : null;
211
+ const wahl = werkbankWahl(echte, email, angabe ?? frueher);
212
+ const mit = (name) => platzBefehl(pfad, name);
213
+ const eigene = [t.ownWorkbench, ...v12c(pfad)];
214
+ // Eine genannte Werkbank, die es hier nicht gibt, wird nie still durch eine andere ersetzt (Bauform 6).
215
+ const passt = wahl.art === "genau" && (angabe === undefined || wahl.name === angabe);
216
+ if (passt)
217
+ return wahl.name;
218
+ if (wahl.art === "keine")
219
+ return [t.noWorkbenchInWorkplace(pfad), ...v12c(pfad)];
220
+ if (wahl.art === "bestaetigen" && angabe === undefined) {
221
+ const andere = echte.filter((n) => n !== wahl.name);
222
+ return [t.confirmWorkbench(wahl.name), t.yes, mit(wahl.name), ...(andere.length > 0 ? [t.noOther, ...andere.map((n) => mit(n))] : []), ...eigene];
223
+ }
224
+ return [t.noMatchingWorkbench(pfad), t.oneOfThese, ...echte.map((n) => mit(n)), ...eigene];
225
+ }
226
+ /** Der Weg aus V-12c: `werkbank neu` arbeitet im aktuellen Ordner, erst der zweite Lauf trägt Plugins und Haken ein. */
227
+ function v12c(pfad) {
228
+ const t = EINRICHTEN_TEXTE[sprache()];
229
+ return [t.cdLine(alsWort(pfad, true)), t.commandLine(befehlIn("werkbank neu", sprache())), platzBefehl(pfad)]; // kein-anzeigetext: Befehlsschluessel
230
+ }
231
+ /** `kimal3 einrichten --arbeitsplatz "…" [--werkbank …]` in der Sprache dieses Laufs, zum Kopieren. */
232
+ function platzBefehl(pfad, werkbank) {
233
+ const s = sprache();
234
+ const teile = [befehlIn("einrichten", s), `--${schalterIn("arbeitsplatz", s)}`, alsWort(pfad, true)]; // kein-anzeigetext: Befehlsschluessel
235
+ if (werkbank !== undefined)
236
+ teile.push(`--${schalterIn("werkbank", s)}`, alsWort(werkbank)); // kein-anzeigetext: Befehlsschluessel
237
+ return EINRICHTEN_TEXTE[s].commandLine(teile.join(" "));
238
+ }
239
+ function frage(zeilen) {
240
+ console.log(zeilen.join("\n"));
241
+ return 1;
242
+ }
243
+ /** `mitWerkbank`: wie bei der Suche nur mit echter Werkbank (Ruling 8) -- für Ordner, die der Mensch nicht selbst genannt hat. */
244
+ const istArbeitsplatz = (pfad, mitWerkbank) => fundAus([kandidatAus(pfad)], { mitWerkbank }).art === "einer";
245
+ /**
246
+ * Ein Ordner, der da ist, kein Arbeitsplatz ist und etwas enthält -- dort legt `einrichten` nichts ab (V-11).
247
+ * Ohne Werkbank-Pflicht: Ein eigener Arbeitsplatz mit gelöschter Werkbank ist nicht fremd, `einrichten` ergänzt ihn.
248
+ */
249
+ function istFremd(pfad) {
250
+ try {
251
+ return statSync(pfad).isDirectory() && readdirSync(pfad).length > 0 && !istArbeitsplatz(pfad, false);
252
+ }
253
+ catch {
254
+ return false;
255
+ }
256
+ }
257
+ function liesOderNull(pfad) {
258
+ try {
259
+ return readFileSync(pfad, "utf8");
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ /**
266
+ * Einen neuen Arbeitsplatz anlegen oder einen eigenen, abgebrochenen fertig schreiben -- wie bisher über
267
+ * die Vorlage. 🔑 Gemerkt wird `angefangen` VOR der ersten Datei: Bricht der Lauf ab, sähe der halbe Ordner
268
+ * sonst beim nächsten Mal wie ein fremder aus (V-17).
269
+ */
270
+ async function neuAnlegen(pfad, wunsch, email, gespeichert) {
54
271
  let holen = await vorlageHolen("einrichten", "", wunsch, null);
55
272
  // Die gespeicherte Anmeldung galt nicht mehr (401 -> 2): einmal neu anmelden, dann noch einmal holen.
56
273
  if (!holen.ok && holen.wert === 2 && gespeichert) {
57
- const angemeldet = await anmeldenMitBrowser();
274
+ const angemeldet = await anmeldenEinmal();
58
275
  if (angemeldet !== 0)
59
276
  return angemeldet;
60
277
  holen = await vorlageHolen("einrichten", "", wunsch, null);
@@ -64,7 +281,7 @@ export async function einrichten(argv) {
64
281
  // 🌐 Die Antwort kann die Kontosprache umgestellt haben -- ab hier in der neuen.
65
282
  const t = EINRICHTEN_TEXTE[sprache()];
66
283
  const { wurzel, dateien } = holen.daten;
67
- if (istUnsichererOrdnername(wurzel)) {
284
+ if (pfad === null && istUnsichererOrdnername(wurzel)) {
68
285
  console.error(t.unsafeFolder(wurzel));
69
286
  return 3;
70
287
  }
@@ -73,22 +290,15 @@ export async function einrichten(argv) {
73
290
  console.error(t.noWorkbank);
74
291
  return 3;
75
292
  }
76
- const ziel = join(homedir(), wurzel);
293
+ const ziel = pfad ?? join(homedir(), wurzel);
294
+ if (pfad === null && istFremd(ziel))
295
+ return frage([t.foreignFolder(ziel), t.otherPlace, platzBefehl(t.pathPlaceholder)]);
296
+ merkeOrt({ email, pfad: ziel, werkbank, zustand: "angefangen" });
77
297
  const geschrieben = fehlendeDateienSchreiben(dateien, ziel);
78
298
  if (!geschrieben.ok)
79
299
  return geschrieben.wert;
80
300
  console.log(t.filesWritten(geschrieben.neu, geschrieben.schonDa));
81
- console.log(t.workplaceLine(ziel));
82
- console.log(t.workbenchLine(werkbank));
83
- // Plugins VOR der Wache: So hält ihre Sicherungskopie Sabines unveränderte Einstellungen (QA-Fund 9).
84
- const git = gitAntwortet();
85
- pluginsEinrichten(git);
86
- const befehle = await skillInstall();
87
- await kursAbhaken(git ? ["beat2-werkzeuge", "beat2-kimal3", "beat2-arbeitsplatz"] : ["beat2-kimal3", "beat2-arbeitsplatz"]);
88
- if (befehle !== 0)
89
- return befehle;
90
- console.log(t.done);
91
- return 0;
301
+ return { pfad: ziel, werkbank };
92
302
  }
93
303
  /**
94
304
  * superpowers und das Logo: Ordner ablegen, in Claudes Einstellungen eintragen, eine Zeile `Plugins:`.
@@ -148,14 +358,22 @@ function gitAntwortet() {
148
358
  /**
149
359
  * Die Haken sind eine Zugabe: Scheitern sie, ist der Arbeitsplatz trotzdem da -- eine Zeile,
150
360
  * kein Abbruch, und der nächste Lauf setzt sie (die Route ist dafür wiederholbar).
361
+ * 🔑 `401` -> einmal neu anmelden (S-8): Ohne diesen Zweig bliebe eine abgelaufene Anmeldung für immer stehen, und
362
+ * „der nächste Lauf holt das nach“ wäre falsch. Hat dieser Lauf schon eine Anmeldung versucht (beim Kursstand vorab),
363
+ * öffnet `anmeldenEinmal` keine zweite -- die Zeile sagt dann, dass die Haken fehlen.
151
364
  */
152
365
  async function kursAbhaken(schritte) {
366
+ const haken = async () => {
367
+ try {
368
+ return (await ruf("/api/cli/kurs", { koerper: { schritte }, mitAusweis: true })).status; // kein-anzeigetext: Pfad der Schnittstelle
369
+ }
370
+ catch {
371
+ return 0;
372
+ }
373
+ };
374
+ let status = await haken();
375
+ if (status === 401 && (await anmeldenEinmal()) === 0)
376
+ status = await haken();
153
377
  const t = EINRICHTEN_TEXTE[sprache()];
154
- try {
155
- const a = await ruf("/api/cli/kurs", { koerper: { schritte }, mitAusweis: true }); // kein-anzeigetext: Pfad der Schnittstelle
156
- console.log(a.status === 200 ? t.courseMarked : t.courseNotMarked);
157
- }
158
- catch {
159
- console.log(t.courseNotMarked);
160
- }
378
+ console.log(status === 200 ? t.courseMarked : t.courseNotMarked);
161
379
  }