kimal3 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,84 +1,111 @@
1
- import type { Register } from 'claude-code'
2
- import { appOeffnen } from './app-oeffnen'
3
-
4
- // Die Sprache des Kontos. `kimal3 einrichten` setzt diese Zeile beim Ablegen in `~/.kimal3/claude-plugins` und prüft
5
- // danach, dass sie gesetzt ist (`cli/src/logo-ablegen.ts`).
6
- const SPRACHE: 'de' | 'en' = 'de'
7
-
8
- // Icon: docs/design/kimal3-icon-animated.svg, unverändert bis auf die viewBox:
9
- // Sie schneidet den leeren Rand um die drei Dreiecke weg (Original: 0 0 80 80).
10
- const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="20 19 40 36" width="24" height="22">
11
- <style>
12
- /* color-scheme: sonst malt der Browser hinter die Grafik eine eigene Fläche, wenn hell/dunkel nicht zur App passt */
13
- :root { background: transparent; color-scheme: light dark; }
14
- .icon { opacity: 0.8; }
15
- .tri { fill: none; stroke: hsl(194 100% 51%); stroke-width: 2.8; stroke-linejoin: round; opacity: 0.7; }
16
- .t1 { animation: trianglePulse 3s ease-in-out infinite; }
17
- .t2 { animation: trianglePulse 3.5s ease-in-out infinite; animation-delay: .4s; }
18
- .t3 { animation: trianglePulse 4s ease-in-out infinite; animation-delay: .8s; }
19
- @keyframes trianglePulse { 0%,100% { opacity: .5; } 50% { opacity: 1; } }
20
- </style>
21
- <g class="icon">
22
- <polygon class="tri t1" points="33.28,21.6 22.08,41.0 44.48,41.0" />
23
- <polygon class="tri t2" points="46.72,21.6 35.52,41.0 57.92,41.0" />
24
- <polygon class="tri t3" points="40.00,33.36 28.80,52.76 51.20,52.76" />
25
- </g>
26
- </svg>`
27
-
28
- // Das Cyan der Marke (kimal3.de, --brand in der App)
29
- const MARKEN_CYAN = '#00C3FF'
30
- // Der Kurs, für alle (Dennis, 15.10.: „mach alle auf den Kurs“). Abgemeldete schickt die App von dort zum Login.
31
- // Fest auf `/firma` zeigte der Link Kostenlosen ohne Firma eine leere Seite (Spec L-51). Im Terminal: Strg+Klick; Claude Code hängt dort die Adresse
32
- // grau dahinter, wenn es dem Terminal keine Links zutraut — FORCE_HYPERLINK hilft nicht.
33
- const APP = 'https://app.kimal3.de/kurs'
34
-
35
- // Vor und nach der Wortmarke. Englisch: „AI Workplace“, nie „workspace“ (Spec, Global Constraints).
36
- const TEXT = {
37
- de: { vor: 'Dein ', nach: '-Arbeitsplatz', alt: 'KImal3-Logo' },
38
- en: { vor: 'Your ', nach: ' AI Workplace', alt: 'KImal3 logo' },
39
- }[SPRACHE]
40
-
41
- export const register: Register = (on, options) => {
42
- // Beim Start die Projektseite in der App öffnen (hooks/app-oeffnen.ts).
43
- appOeffnen(on, options)
44
-
45
- on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
46
- if (e.props.hasSurvey) {
47
- return next(e)
48
- }
49
-
50
- // Im Terminal gibt es kein Svg: drei Dreiecke als Zeichen, die Wortmarke wie auf kimal3.de
51
- // („KI" in Schriftfarbe, „mal3" im Marken-Cyan). Das Cyan steht als Farbwert, nicht als „cyan" —
52
- // das Terminal-Cyan ist je nach Farbschema ein anderes, oft ein blasses Türkis.
53
- if (e.surface === 'terminal') {
54
- const { Box, Link, Text } = $.ui.resolve(e)
55
- return (
56
- <Box flexDirection="row" gap={1}>
57
- <Text color={MARKEN_CYAN} bold>{'▲▲▲'}</Text>
58
- <Text bold>
59
- {TEXT.vor}
60
- <Link href={APP}>
61
- {'KI'}
62
- <Text color={MARKEN_CYAN}>{'mal3'}</Text>
63
- {TEXT.nach}
64
- </Link>
65
- {'.'}
66
- </Text>
67
- </Box>
68
- )
69
- }
70
-
71
- // isInteractive: erst damit spielt die Leiste die CSS-Animation ab, sonst steht das Icon still.
72
- const { Box, Link, Svg, Text } = $.ui.resolve(e)
73
- return (
74
- <Box flexDirection="row" alignItems="center" gap={1}>
75
- <Svg source={ICON} alt={TEXT.alt} width={24} height={22} isInteractive />
76
- <Text bold>
77
- {TEXT.vor}
78
- <Link href={APP}>{`KImal3${TEXT.nach}`}</Link>
79
- {'.'}
80
- </Text>
81
- </Box>
82
- )
83
- })
84
- }
1
+ import type { Register } from 'claude-code'
2
+ import { KEINE_ABLAGE, type Ablage } from './ablage'
3
+ import { appOeffnen } from './app-oeffnen'
4
+ import { chatBenennen } from './chat-benennen'
5
+ import { updateClaudeFragen, updateInstallieren, updateZustand } from './update'
6
+ import { satzWeicht, TEXTE, type Zustand } from './update-rechnung'
7
+
8
+ // Die Sprache des Kontos. `kimal3 einrichten` setzt diese Zeile beim Ablegen in `~/.kimal3/claude-plugins` und prüft
9
+ // danach, dass sie gesetzt ist (`cli/src/logo-ablegen.ts`).
10
+ const SPRACHE: 'de' | 'en' = 'de'
11
+ // Fassung und Orte von `kimal3` auf diesem Rechner; beim Ablegen gesetzt wie die Sprache (hooks/ablage.ts).
12
+ const ABLAGE: Ablage = KEINE_ABLAGE
13
+
14
+ // Icon: docs/design/kimal3-icon-animated.svg, unverändert bis auf die viewBox:
15
+ // Sie schneidet den leeren Rand um die drei Dreiecke weg (Original: 0 0 80 80).
16
+ const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="20 19 40 36" width="24" height="22">
17
+ <style>
18
+ /* color-scheme: sonst malt der Browser hinter die Grafik eine eigene Fläche, wenn hell/dunkel nicht zur App passt */
19
+ :root { background: transparent; color-scheme: light dark; }
20
+ .icon { opacity: 0.8; }
21
+ .tri { fill: none; stroke: hsl(194 100% 51%); stroke-width: 2.8; stroke-linejoin: round; opacity: 0.7; }
22
+ .t1 { animation: trianglePulse 3s ease-in-out infinite; }
23
+ .t2 { animation: trianglePulse 3.5s ease-in-out infinite; animation-delay: .4s; }
24
+ .t3 { animation: trianglePulse 4s ease-in-out infinite; animation-delay: .8s; }
25
+ @keyframes trianglePulse { 0%,100% { opacity: .5; } 50% { opacity: 1; } }
26
+ </style>
27
+ <g class="icon">
28
+ <polygon class="tri t1" points="33.28,21.6 22.08,41.0 44.48,41.0" />
29
+ <polygon class="tri t2" points="46.72,21.6 35.52,41.0 57.92,41.0" />
30
+ <polygon class="tri t3" points="40.00,33.36 28.80,52.76 51.20,52.76" />
31
+ </g>
32
+ </svg>`
33
+
34
+ // Das Cyan der Marke (kimal3.de, --brand in der App)
35
+ const MARKEN_CYAN = '#00C3FF'
36
+ // Der Kurs, für alle (Dennis, 15.10.: „mach alle auf den Kurs“). Abgemeldete schickt die App von dort zum Login.
37
+ // Fest auf `/firma` zeigte der Link Kostenlosen ohne Firma eine leere Seite (Spec L-51). Im Terminal: Strg+Klick; Claude Code hängt dort die Adresse
38
+ // grau dahinter, wenn es dem Terminal keine Links zutraut — FORCE_HYPERLINK hilft nicht.
39
+ const APP = 'https://app.kimal3.de/kurs'
40
+
41
+ // Vor und nach der Wortmarke: aus update-rechnung.ts, damit die Breitenrechnung denselben Satz zählt (`satzWeicht`).
42
+ const TEXT = { ...TEXTE[SPRACHE], alt: { de: 'KImal3-Logo', en: 'KImal3 logo' }[SPRACHE] }
43
+
44
+ export const register: Register = (on, options) => {
45
+ // Beim Start die Projektseite in der App öffnen (hooks/app-oeffnen.ts).
46
+ appOeffnen(on, options)
47
+ // Den Chat nach seinem Thema benennen, sobald es klar ist (hooks/chat-benennen.ts).
48
+ chatBenennen(on, options)
49
+
50
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
51
+ if (e.props.hasSurvey) {
52
+ return next(e)
53
+ }
54
+ // Ohne Ablage (Entwicklung) `null`: keine Fassung, kein Knopf, die Leiste wie früher (U-9).
55
+ const zustand = await updateZustand($, ABLAGE, e.props.isWorking)
56
+ const { Button, Text } = $.ui.resolve(e)
57
+
58
+ // Hinter dem Satz: die eigene Fassung, gedimmt, und was das Update gerade zu sagen hat (Bauplan 1.4).
59
+ // Kein Tastenkürzel an den Knöpfen (Spec P-12): eine Ziffer im leeren Eingabefeld darf kein Update anstoßen.
60
+ const dahinter = (z: Zustand) => [
61
+ <Text dimColor>{`v${z.fassung}`}</Text>,
62
+ ...(z.art === 'knopf' ? [<Button key="update" label={TEXT.knopf} onPress={() => void updateInstallieren($, ABLAGE, SPRACHE)} />] : []),
63
+ ...(z.art === 'laeuft' ? [<Text dimColor>{TEXT.laeuft(z.ziel)}</Text>] : []),
64
+ ...(z.art === 'geklappt' ? [<Text>{TEXT.geklappt(z.ziel)}</Text>] : []),
65
+ ...(z.art === 'fehler'
66
+ ? [<Text>{TEXT.fehler}</Text>, <Button key="fragen" label={TEXT.fragen} onPress={() => void updateClaudeFragen($, SPRACHE)} />]
67
+ : []),
68
+ ...(z.art === 'gesperrt' ? [<Text>{TEXT.gesperrt}</Text>] : []),
69
+ ]
70
+
71
+ // Im Terminal gibt es kein Svg: drei Dreiecke als Zeichen, die Wortmarke wie auf kimal3.de
72
+ // („KI" in Schriftfarbe, „mal3" im Marken-Cyan). Das Cyan steht als Farbwert, nicht als „cyan" —
73
+ // das Terminal-Cyan ist je nach Farbschema ein anderes, oft ein blasses Türkis.
74
+ // Ist es zu schmal, weicht der Satz; Fassung, Knopf und Zustand bleiben (U-14).
75
+ if (e.surface === 'terminal') {
76
+ const { Box, Link } = $.ui.resolve(e)
77
+ const weicht = zustand !== null && satzWeicht(zustand, SPRACHE, e.props.bodyColumns)
78
+ return (
79
+ <Box flexDirection="row" gap={1}>
80
+ <Text color={MARKEN_CYAN} bold>{'▲▲▲'}</Text>
81
+ {weicht ? null : (
82
+ <Text bold>
83
+ {TEXT.vor}
84
+ <Link href={APP}>
85
+ {'KI'}
86
+ <Text color={MARKEN_CYAN}>{'mal3'}</Text>
87
+ {TEXT.nach}
88
+ </Link>
89
+ {'.'}
90
+ </Text>
91
+ )}
92
+ {zustand === null ? null : dahinter(zustand)}
93
+ </Box>
94
+ )
95
+ }
96
+
97
+ // isInteractive: erst damit spielt die Leiste die CSS-Animation ab, sonst steht das Icon still.
98
+ const { Box, Link, Svg } = $.ui.resolve(e)
99
+ return (
100
+ <Box flexDirection="row" alignItems="center" gap={1}>
101
+ <Svg source={ICON} alt={TEXT.alt} width={24} height={22} isInteractive />
102
+ <Text bold>
103
+ {TEXT.vor}
104
+ <Link href={APP}>{`KImal3${TEXT.nach}`}</Link>
105
+ {'.'}
106
+ </Text>
107
+ {zustand === null ? null : dahinter(zustand)}
108
+ </Box>
109
+ )
110
+ })
111
+ }
@@ -0,0 +1,175 @@
1
+ // Die reinen Rechnungen und Texte hinter „Update installieren“ in der Leiste (Bauplan 2026-10-23-update-in-der-leiste.md,
2
+ // Abschnitte 1.2 bis 1.4). Kein `$`, kein Haken: Den Ablauf baut `hooks/update.ts`. Herausgelöst, damit `check:logo`
3
+ // jede Entscheidung gegen von Hand geschriebene Soll-Werte prüft (messen-und-pruefen §17), statt sie nur live zu sehen.
4
+
5
+ const MINUTE = 60_000
6
+ // Nach einem gescheiterten npm-Anruf (Netz, Firmen-Spiegel) frühestens nach einer Stunde wieder fragen (U-12).
7
+ const FEHLER_PAUSE = 60 * MINUTE
8
+ // Der Lauf dauert höchstens 10 + 2 + 2 Minuten (npm, `--version`, `skill install`); danach gilt eine Sperre als liegen
9
+ // geblieben — etwa weil Claude mittendrin geschlossen wurde und `finally` nie lief (Bauplan 1.3 Punkt 3, A-5).
10
+ const SPERRE_DAUER = 20 * MINUTE
11
+ const FERTIG_DAUER = 24 * 60 * MINUTE
12
+ // Was Claude Code im Terminal womöglich grau hinter den Link hängt: `https://app.kimal3.de/kurs` (Spec P-4, S-22).
13
+ // Der Mod weiß nicht, ob es geschieht, also rechnet er immer damit.
14
+ const KURS_ADRESSE = 26
15
+
16
+ /** Nur `1.2.3` gilt (Positivliste, Spec 1.4 Punkt 1): keine Vorabfassung, kein `v`, nichts, was in einen Befehl rutscht. */
17
+ export function istFassung(s: unknown): s is string {
18
+ return typeof s === 'string' && /^\d+\.\d+\.\d+$/.test(s)
19
+ }
20
+
21
+ /** `a` ist neuer als `b`, Zahl für Zahl — als Text wäre `"0.10.0" < "0.9.0"`. Ungültig auf einer Seite: `false` (kein Knopf). */
22
+ export function istNeuer(a: string, b: string): boolean {
23
+ if (!istFassung(a) || !istFassung(b)) return false
24
+ const x = a.split('.').map(Number)
25
+ const y = b.split('.').map(Number)
26
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]
27
+ return false
28
+ }
29
+
30
+ /**
31
+ * Die drei Befehle in fester Reihenfolge (Bauplan 1.3 Punkt 4), jeder als Wortliste für `$.process.run` ohne Kommandozeile.
32
+ * Von außen kommt nur `ziel`, und nur als Fassung (§23). `null`, wenn `ziel` keine ist oder ein Pfad fehlt — ein leeres
33
+ * `npm` heißt „beim Ablegen nicht gefunden“, dann geht der Knopf den Weg über Claude (U-10).
34
+ */
35
+ export function befehle(ablage: { node: string; npm: string; kimal3: string }, ziel: string): string[][] | null {
36
+ const { node, npm, kimal3 } = ablage
37
+ if (!istFassung(ziel) || node === '' || npm === '' || kimal3 === '') return null
38
+ return [
39
+ [node, npm, 'install', '-g', `kimal3@${ziel}`],
40
+ [node, kimal3, '--version'],
41
+ [node, kimal3, 'skill', 'install'],
42
+ ]
43
+ }
44
+
45
+ /**
46
+ * Was sich der Mod über Sitzungen hinweg merkt (`$.store`), damit npm höchstens einmal am Tag gefragt wird.
47
+ * `tag`: deutscher Kalendertag der letzten gelungenen Antwort (`2026-10-23`) · `neueste`: was npm da sagte — ein zweites
48
+ * Fenster am selben Tag zeigt den Knopf daraus, ohne zu fragen · `fehlerAm`: Zeitpunkt (ms) des letzten Fehlschlags.
49
+ */
50
+ export type Merkzettel = { tag?: string; neueste?: string; fehlerAm?: number }
51
+
52
+ /** Der Kalendertag in deutscher Zeit, wie `heuteInDeutschland` in der App (`src/app/(erfolgsprofil)/typen.ts`). */
53
+ export function deutscherTag(jetzt: number): string {
54
+ return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date(jetzt))
55
+ }
56
+
57
+ /** npm fragen? Einmal je deutschem Kalendertag (U-11), nach einem Fehlschlag höchstens einmal je Stunde (U-12). */
58
+ export function sollFragen(merkzettel: Merkzettel, jetzt: number): boolean {
59
+ if (merkzettel.tag === deutscherTag(jetzt)) return false
60
+ return merkzettel.fehlerAm === undefined || jetzt - merkzettel.fehlerAm >= FEHLER_PAUSE
61
+ }
62
+
63
+ /** Läuft ein Update (`$.store`, auch aus einem anderen Fenster)? `seit` in ms. */
64
+ export type Sperre = { ziel: string; seit: number }
65
+
66
+ /**
67
+ * Die Sperre gilt 20 Minuten und nur, solange der Mod selbst noch älter als `ziel` ist: Ein Mod, der die neue Fassung
68
+ * schon trägt, hat das Update hinter sich, eine übrig gebliebene Sperre darf ihm den Stand nicht verdecken (U-5, U-6b).
69
+ */
70
+ export function sperreGilt(sperre: Sperre | undefined, jetzt: number, eigeneFassung: string): boolean {
71
+ return sperre !== undefined && jetzt - sperre.seit < SPERRE_DAUER && istNeuer(sperre.ziel, eigeneFassung)
72
+ }
73
+
74
+ /** Ein Update hat geklappt (`$.store`), Claude ist noch nicht neu gestartet. `am` in ms. */
75
+ export type Fertig = { ziel: string; am: number }
76
+
77
+ /**
78
+ * „✓ installiert – Claude neu starten“ gilt, solange der Mod älter als `ziel` ist (U-6b) — und höchstens 24 Stunden: Hat
79
+ * ein Tag mit Neustarts den Mod nicht auf `ziel` gebracht, stimmt der Haken offenbar nicht (etwa startet eine andere
80
+ * Node-Fassung `kimal3`), und die Leiste zeigt wieder, was npm sagt. Ein Wert in anderer Form (bis zum 23.10. ein bloßer
81
+ * Text) gilt nicht.
82
+ */
83
+ export function fertigGilt(fertig: unknown, jetzt: number, eigeneFassung: string): fertig is Fertig {
84
+ const f = (fertig ?? {}) as Record<string, unknown>
85
+ return istFassung(f.ziel) && typeof f.am === 'number' && jetzt - f.am < FERTIG_DAUER && istNeuer(f.ziel, eigeneFassung)
86
+ }
87
+
88
+ /**
89
+ * Hat `kimal3 --version` nach dem Install wirklich die neue Fassung? Die Ausgabe ist mehrzeilig und eingerückt
90
+ * (` kimal3 0.6.6` und darunter weitere Zeilen); eine Zeile muss nach `trim` GENAU `kimal3 X` lauten — `kimal3 0.6.66`
91
+ * zählt nicht für `0.6.6` (Bauplan 1.3 Punkt 4, QA-1).
92
+ */
93
+ export function versionStimmt(ausgabe: string, ziel: string): boolean {
94
+ return istFassung(ziel) && ausgabe.split(/\r?\n/).some(zeile => zeile.trim() === `kimal3 ${ziel}`)
95
+ }
96
+
97
+ // Vor und nach der Wortmarke „KImal3“; register.tsx zeichnet die Wortmarke dazwischen als Link. Englisch: „AI Workplace“,
98
+ // nie „workspace“ (Spec, Global Constraints). Eine Quelle für Leiste und `satzWeicht`, damit die Breite mit dem Satz
99
+ // rechnet, der wirklich dasteht.
100
+ const VOR_NACH = {
101
+ de: { vor: 'Dein ', nach: '-Arbeitsplatz' },
102
+ en: { vor: 'Your ', nach: ' AI Workplace' },
103
+ }
104
+
105
+ // Wortlaute (Bauplan 1.4).
106
+ export const TEXTE = {
107
+ de: {
108
+ ...VOR_NACH.de,
109
+ satz: `${VOR_NACH.de.vor}KImal3${VOR_NACH.de.nach}.`,
110
+ knopf: 'Update installieren',
111
+ frage: (von: string, auf: string) =>
112
+ `Es laufen: npm install -g kimal3@${auf} und kimal3 skill install. Dauert etwa eine Minute, danach Claude neu starten. kimal3 von ${von} auf ${auf} aktualisieren?`,
113
+ ja: 'Installieren',
114
+ nein: 'Abbrechen',
115
+ laeuft: (ziel: string) => `kimal3 ${ziel} wird installiert …`,
116
+ geklappt: (ziel: string) => `✓ ${ziel} installiert – Claude neu starten`,
117
+ fehler: 'Update hat nicht geklappt.',
118
+ fragen: 'Claude fragen',
119
+ bitteFehler: (ziel: string, meldung: string) =>
120
+ `Das kimal3-Update auf ${ziel} hat nicht geklappt. Hier die Meldung:\n\n${meldung}\n\nWas ist los, und was kann ich tun?`,
121
+ bitteOhneNpm: (ziel: string) => `Aktualisiere kimal3 auf ${ziel}: erst npm install -g kimal3@${ziel}, danach kimal3 skill install.`,
122
+ gesperrt: 'Update hier gesperrt – frag deine IT.',
123
+ },
124
+ en: {
125
+ ...VOR_NACH.en,
126
+ satz: `${VOR_NACH.en.vor}KImal3${VOR_NACH.en.nach}.`,
127
+ knopf: 'Install update',
128
+ frage: (von: string, auf: string) =>
129
+ `Runs: npm install -g kimal3@${auf} and kimal3 skill install. Takes about a minute, then restart Claude. Update kimal3 from ${von} to ${auf}?`,
130
+ ja: 'Install',
131
+ nein: 'Cancel',
132
+ laeuft: (ziel: string) => `Installing kimal3 ${ziel} …`,
133
+ geklappt: (ziel: string) => `✓ ${ziel} installed – restart Claude`,
134
+ fehler: "The update didn't work.",
135
+ fragen: 'Ask Claude',
136
+ bitteFehler: (ziel: string, meldung: string) =>
137
+ `The kimal3 update to ${ziel} didn't work. Here is the message:\n\n${meldung}\n\nWhat's wrong, and what can I do?`,
138
+ bitteOhneNpm: (ziel: string) => `Update kimal3 to ${ziel}: first npm install -g kimal3@${ziel}, then kimal3 skill install.`,
139
+ gesperrt: 'Update blocked here – ask your IT.',
140
+ },
141
+ }
142
+
143
+ /** Was die Leiste hinter der eigenen Fassung zeigt; `still` = nichts (kein Update, npm stumm, Claude arbeitet). */
144
+ export type Zustand = { fassung: string } & (
145
+ | { art: 'still' | 'knopf' | 'fehler' | 'gesperrt' }
146
+ | { art: 'laeuft' | 'geklappt'; ziel: string }
147
+ )
148
+
149
+ // Ein Knopf im Terminal ist `[ Beschriftung ]` (Typen von Claude Code, `ButtonProps`).
150
+ const knopf = (text: string) => `[ ${text} ]`
151
+
152
+ /** Die Teile hinter der Fassung, wie das Terminal sie zeichnet. */
153
+ function zustandsteile(zustand: Zustand, sprache: 'de' | 'en'): string[] {
154
+ const t = TEXTE[sprache]
155
+ switch (zustand.art) {
156
+ case 'still': return []
157
+ case 'knopf': return [knopf(t.knopf)]
158
+ case 'laeuft': return [t.laeuft(zustand.ziel)]
159
+ case 'geklappt': return [t.geklappt(zustand.ziel)]
160
+ case 'fehler': return [t.fehler, knopf(t.fragen)]
161
+ case 'gesperrt': return [t.gesperrt]
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Muss im Terminal der Satz „Dein KImal3-Arbeitsplatz.“ weichen? Ja, wenn `▲▲▲`, Satz, die womöglich angehängte
167
+ * Kurs-Adresse, `v0.6.5` und der Zustandsteil nicht in `bodyColumns` passen — Fassung, Knopf und Zustand weichen nie
168
+ * (Bauplan 1.4, U-14). Zwischen zwei Teilen steht je ein Leerzeichen (`gap={1}` in register.tsx).
169
+ */
170
+ export function satzWeicht(zustand: Zustand, sprache: 'de' | 'en', bodyColumns: number): boolean {
171
+ const teile = ['▲▲▲', TEXTE[sprache].satz, `v${zustand.fassung}`, ...zustandsteile(zustand, sprache)]
172
+ // Zeichen statt UTF-16-Einheiten; ▲, ✓, – und … sind im Terminal je eine Spalte breit.
173
+ const breite = teile.reduce((summe, teil) => summe + [...teil].length, teile.length - 1) + KURS_ADRESSE
174
+ return breite > bodyColumns
175
+ }
@@ -0,0 +1,244 @@
1
+ import { atom, read, update, type Engine } from 'claude-code'
2
+ import type { Ablage } from './ablage'
3
+ import {
4
+ befehle, deutscherTag, fertigGilt, istFassung, istNeuer, sollFragen, sperreGilt, TEXTE, versionStimmt,
5
+ type Fertig, type Merkzettel, type Sperre, type Zustand,
6
+ } from './update-rechnung'
7
+
8
+ // Der Ablauf hinter „Update installieren“ in der Leiste (Bauplan docs/superpowers/plans/2026-10-23-update-in-der-leiste.md,
9
+ // Abschnitte 1.2 und 1.3, Szenarien U-1 bis U-20). Die Entscheidungen selbst stehen rein in update-rechnung.ts; hier
10
+ // steht, wann was gefragt, gemerkt und ausgeführt wird. Gezeichnet wird in register.tsx.
11
+
12
+ const MINUTE = 60_000
13
+ const NPM = 'https://registry.npmjs.org/kimal3/latest'
14
+ // `HttpInit` kennt kein Zeitlimit: Was npm nach 5 s nicht beantwortet hat, zählt als Fehlschlag (Bauplan 1.2, A-6).
15
+ const NPM_GEDULD = 5000
16
+ // Was der Mod über Sitzungen und Fenster hinweg weiß (`$.store`, eine Datei je Mod): wann npm gefragt wurde, ob ein
17
+ // Update läuft, und dass eines geklappt hat, aber Claude noch nicht neu gestartet ist.
18
+ const MERKZETTEL = 'updateMerkzettel'
19
+ const SPERRE = 'updateSperre'
20
+ const FERTIG = 'updateFertig'
21
+ // Die zwei Befehle, die der Klick ausführt, so wie Claude sie in seinen Erlaubnis-Regeln sähe (Bauplan 1.3 Punkt 2).
22
+ // Beide Werkzeuge: Eine Firma, die `npm install` nur für PowerShell verbietet, verbietet es trotzdem (A-3).
23
+ const WERKZEUGE = ['Bash', 'PowerShell']
24
+
25
+ // Was die Leiste neu zeichnen soll, sobald es sich ändert (`$.state`, Vertrag in types/index.d.ts).
26
+ const neueste = atom({ plugin: 'kimal3-logo', key: 'updateNeueste' } as const, '')
27
+ const lauf = atom({ plugin: 'kimal3-logo', key: 'updateLauf' } as const, null)
28
+
29
+ /**
30
+ * Die Fehlermeldung für die Bitte an Claude: die letzten 10 Zeilen, höchstens 1500 Zeichen (Bauplan 1.3 Punkt 6).
31
+ * npm schreibt vorne Fortschritt und hinten den Grund; der Grund ist, was Claude braucht.
32
+ */
33
+ export function meldungKurz(text: string): string {
34
+ return text.trim().split(/\r?\n/).slice(-10).join('\n').slice(-1500)
35
+ }
36
+
37
+ // Was im Store steht, ist Daten von gestern — vielleicht von einer älteren Fassung des Mods. Nur Felder mit dem
38
+ // richtigen Typ zählen; ein `fehlerAm` als Text ergäbe sonst `NaN`, und npm würde nie wieder gefragt.
39
+ function merkzettelAus(roh: unknown): Merkzettel {
40
+ const m = (roh ?? {}) as Record<string, unknown>
41
+ return {
42
+ ...(typeof m.tag === 'string' ? { tag: m.tag } : {}),
43
+ ...(istFassung(m.neueste) ? { neueste: m.neueste } : {}),
44
+ ...(typeof m.fehlerAm === 'number' ? { fehlerAm: m.fehlerAm } : {}),
45
+ }
46
+ }
47
+
48
+ function versionAus(text: string): string | null {
49
+ try {
50
+ const version: unknown = (JSON.parse(text) as { version?: unknown } | null)?.version
51
+ return istFassung(version) ? version : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ function sperreAus(roh: unknown): Sperre | undefined {
58
+ const s = (roh ?? {}) as Record<string, unknown>
59
+ return istFassung(s.ziel) && typeof s.seit === 'number' ? { ziel: s.ziel, seit: s.seit } : undefined
60
+ }
61
+
62
+ // Ein Fehlschlag im Hintergrund gehört ins Debug-Protokoll, nie in den Chat (ohne `to: 'debug'` stünde er dort).
63
+ function protokoll($: Engine, text: string): void {
64
+ $.ui.log(`kimal3-logo: ${text.slice(0, 300)}`, { to: 'debug' })
65
+ }
66
+
67
+ type Sprache = 'de' | 'en'
68
+ // Gegen einen zweiten Klick, solange die Rückfrage noch offen ist oder der Lauf läuft.
69
+ let beschaeftigt = false
70
+ // Der Blick auf npm startet mit dem ersten Zeichnen der Leiste, einmal je Laden des Mods.
71
+ let angestossen = false
72
+
73
+ /** Nach dem Laden: Aufräumen, Wache-Schalter, Merkzettel — und npm fragen, wenn es heute noch nicht geschah. */
74
+ async function beimLaden($: Engine, ablage: Ablage): Promise<void> {
75
+ const fassung = ablage.fassung
76
+ const jetzt = await $.clock.now()
77
+ // U-6b: Ein Mod, der die neue Fassung schon trägt, hat das Update hinter sich; nach 24 Stunden gilt der Haken nicht mehr.
78
+ const fertig = await $.store.get(FERTIG)
79
+ if (fertig !== undefined && !fertigGilt(fertig, jetzt, fassung)) await $.store.delete(FERTIG)
80
+ const sperre = sperreAus(await $.store.get(SPERRE))
81
+ if (sperre !== undefined && !istNeuer(sperre.ziel, fassung)) await $.store.delete(SPERRE)
82
+
83
+ if (await wacheAus($, ablage)) return
84
+ const merkzettel = merkzettelAus(await $.store.get(MERKZETTEL))
85
+ // Ein zweites Fenster am selben Tag zeigt den Knopf aus dem Merkzettel, ohne zu fragen (U-11).
86
+ if (merkzettel.tag === deutscherTag(jetzt) && merkzettel.neueste !== undefined) await update($, neueste, () => merkzettel.neueste ?? '')
87
+ if (sollFragen(merkzettel, jetzt)) await npmFragen($, jetzt)
88
+ }
89
+
90
+ /** `kimal3 wache aus` schaltet auch den Blick auf npm ab (E-8, U-8). Keine oder kaputte Datei: an. */
91
+ async function wacheAus($: Engine, ablage: Ablage): Promise<boolean> {
92
+ try {
93
+ const inhalt: unknown = JSON.parse(await $.fs.read(`${ablage.kimal3Ordner}/wache.json`))
94
+ return (inhalt as { aus?: unknown } | null)?.aus === true
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
100
+ async function npmFragen($: Engine, jetzt: number): Promise<void> {
101
+ // Ein Zeitgeber statt `$.clock.sleep`: Ein `sleep` liefe auf das Zeitbudget des Hakens, aus dem `$` stammt — hier das
102
+ // Zeichnen der Leiste.
103
+ let zuEnde: (wert: null) => void = () => {}
104
+ const zuLangsam = new Promise<null>(aufloesen => { zuEnde = aufloesen })
105
+ const zeitgeber = $.clock.after(NPM_GEDULD, () => zuEnde(null))
106
+ const antwort = await Promise.race([$.http.fetch(NPM).catch(() => null), zuLangsam])
107
+ zeitgeber.cancel()
108
+ const version = versionAus(antwort?.ok ? antwort.text : '')
109
+ // Nur `1.2.3` zählt als Antwort; Vorabfassung, Unsinn, Fehler und Schweigen sind ein Fehlschlag (U-2, U-12).
110
+ if (version === null) {
111
+ protokoll($, `keine gültige Antwort von npm: ${antwort === null ? 'keine Antwort' : `${antwort.status} ${antwort.text}`}`)
112
+ await $.store.set(MERKZETTEL, { fehlerAm: jetzt } satisfies Merkzettel)
113
+ return
114
+ }
115
+ await $.store.set(MERKZETTEL, { tag: deutscherTag(jetzt), neueste: version } satisfies Merkzettel)
116
+ await update($, neueste, () => version)
117
+ }
118
+
119
+ /** Was die Leiste hinter der Fassung zeigt; `null` ohne Ablage (U-9). Schreibt nichts: Es wird gerade gezeichnet. */
120
+ export async function updateZustand($: Engine, ablage: Ablage, claudeArbeitet: boolean): Promise<Zustand | null> {
121
+ const fassung = ablage.fassung
122
+ if (fassung === '') return null
123
+ // Erst danach, nie in diesem Zeichnen (es soll nicht auf npm warten, und schreiben darf es nicht). Am ersten Zeichnen
124
+ // statt an `session.start`: Den Start hört schon hooks/app-oeffnen.ts (je Ereignis ein Haken ohne Auswahl), und
125
+ // ohne Leiste — `claude -p`, Skripte — braucht niemand die Antwort von npm.
126
+ if (!angestossen) {
127
+ angestossen = true
128
+ $.clock.after(0, () => void beimLaden($, ablage).catch(f => protokoll($, `Update-Blick: ${String(f)}`)))
129
+ }
130
+ const jetzt = await $.clock.now()
131
+ const sperre = sperreAus(await $.store.get(SPERRE))
132
+ const laeuft = sperreGilt(sperre, jetzt, fassung)
133
+ const hier = await read($, lauf)
134
+ // „läuft“ aus diesem Fenster nur, solange die Sperre gilt: Sonst ist es liegen geblieben (der Lauf kam nie zu Ende,
135
+ // etwa weil der Mod mittendrin neu geladen wurde), und die Leiste zeigte für immer „wird installiert“.
136
+ if (hier !== null && (hier.art !== 'laeuft' || laeuft)) {
137
+ return hier.art === 'laeuft' || hier.art === 'geklappt' ? { fassung, art: hier.art, ziel: hier.ziel } : { fassung, art: hier.art }
138
+ }
139
+ // Aus einem anderen Fenster (oder vor dem Schließen): geklappt, aber noch nicht neu gestartet (U-6b) — oder es läuft (U-5).
140
+ const fertig = await $.store.get(FERTIG)
141
+ if (fertigGilt(fertig, jetzt, fassung)) return { fassung, art: 'geklappt', ziel: fertig.ziel }
142
+ if (sperre !== undefined && laeuft) return { fassung, art: 'laeuft', ziel: sperre.ziel }
143
+ // Kein Knopf, solange Claude antwortet (U-18): ein Klick mitten in einer Antwort stellte eine Frage quer dazu.
144
+ return istNeuer(await read($, neueste), fassung) && !claudeArbeitet ? { fassung, art: 'knopf' } : { fassung, art: 'still' }
145
+ }
146
+
147
+ /** Der Klick auf „Update installieren“ (Bauplan 1.3). */
148
+ export async function updateInstallieren($: Engine, ablage: Ablage, sprache: Sprache): Promise<void> {
149
+ const t = TEXTE[sprache]
150
+ const fassung = ablage.fassung
151
+ const ziel = await read($, neueste)
152
+ if (beschaeftigt || !istNeuer(ziel, fassung)) return
153
+ beschaeftigt = true
154
+ try {
155
+ // K-4, U-10: Ohne npm-Pfad kein Direktbefehl — Claude bekommt die Bitte und fragt selbst um Erlaubnis.
156
+ const liste = befehle(ablage, ziel)
157
+ if (liste === null) {
158
+ await $.prompt.submit({ text: t.bitteOhneNpm(ziel) })
159
+ return
160
+ }
161
+ // U-3: Abbrechen, „Other“, eine Chat-Nachricht statt einer Antwort und das Wegklicken (Wurf): nichts passiert.
162
+ const antwort = await $.ui.ask(t.frage(fassung, ziel), [t.ja, t.nein]).catch(() => null)
163
+ if (antwort !== t.ja) return
164
+ // U-5: Während der Rückfrage kann ein anderes Fenster angefangen haben — dann zeigt auch dieses „wird installiert“.
165
+ const sperre = sperreAus(await $.store.get(SPERRE))
166
+ if (sperre !== undefined && sperreGilt(sperre, await $.clock.now(), fassung)) {
167
+ await update($, lauf, () => ({ art: 'laeuft', ziel: sperre.ziel, meldung: '' }))
168
+ return
169
+ }
170
+ if (await verboten($, ziel)) {
171
+ await update($, lauf, () => ({ art: 'gesperrt', ziel, meldung: '' }))
172
+ return
173
+ }
174
+ await ausfuehren($, ablage, liste, ziel)
175
+ } catch (fehler) {
176
+ // Etwa `$.store` scheitert (die Sperre lässt sich nicht schreiben): kein Erfolg und kein „gesperrt“, sondern ein Fehler mit Grund.
177
+ await update($, lauf, () => ({ art: 'fehler', ziel, meldung: meldungKurz(String(fehler)) }))
178
+ } finally {
179
+ beschaeftigt = false
180
+ }
181
+ }
182
+
183
+ /**
184
+ * U-4: Verbietet eine Erlaubnis-Regel einen der zwei Befehle in einem der zwei Werkzeuge? `ask` (der Normalfall im
185
+ * Auto-Modus, M-16) ist kein Verbot. ⚠️ Die Abfrage kennt nur die Regeln, nicht die PreToolUse-Hooks einer Firma und
186
+ * nicht den Prüfer des Auto-Modus (Typen von `ToolCheckResult.hook`; Bauplan Abschnitt 4).
187
+ * Gefragt werden nur Werkzeuge, die es hier gibt (ohne PowerShell etwa unter macOS); wirft die Liste, nur `Bash`. Eine
188
+ * Abfrage, die wirft, ist kein Verbot — nur ein ausgesprochenes `deny` sperrt; sonst hielte ein fehlendes Werkzeug
189
+ * jedes Update auf.
190
+ */
191
+ async function verboten($: Engine, ziel: string): Promise<boolean> {
192
+ const da = await $.tool.list().then(liste => liste.map(w => w.name), () => ['Bash'])
193
+ for (const tool of WERKZEUGE.filter(w => da.includes(w))) {
194
+ for (const command of [`npm install -g kimal3@${ziel}`, 'kimal3 skill install']) {
195
+ const urteil = await $.tool.check({ tool, input: { command } }).catch((fehler: unknown) => {
196
+ protokoll($, `Regel-Abfrage ${tool}: ${String(fehler)}`)
197
+ return null
198
+ })
199
+ if (urteil?.decision === 'deny') return true
200
+ }
201
+ }
202
+ return false
203
+ }
204
+
205
+ /**
206
+ * Die drei Befehle nacheinander, jeder ohne Kommandozeile, im Ordner `~/.kimal3` (nie im Projekt: ein `.npmrc` dort
207
+ * gälte sonst mit). Scheitert eine Stufe, läuft die nächste nicht (U-6, U-7, U-7b). Die Sperre hält andere Fenster ab
208
+ * (U-5) und geht in `finally` weg — auch wenn ein Befehl wirft.
209
+ */
210
+ async function ausfuehren($: Engine, ablage: Ablage, liste: string[][], ziel: string): Promise<void> {
211
+ await $.store.set(SPERRE, { ziel, seit: await $.clock.now() } satisfies Sperre)
212
+ await update($, lauf, () => ({ art: 'laeuft', ziel, meldung: '' }))
213
+ try {
214
+ const meldung = await stufen($, ablage.kimal3Ordner, liste, ziel).catch((fehler: unknown) => String(fehler))
215
+ if (meldung === null) {
216
+ // Bleibt stehen, bis ein Mod mit dieser Fassung lädt oder 24 Stunden um sind (beimLaden, `fertigGilt`): Erst der
217
+ // Neustart bringt Coach und Befehle.
218
+ await $.store.set(FERTIG, { ziel, am: await $.clock.now() } satisfies Fertig)
219
+ await update($, lauf, () => ({ art: 'geklappt', ziel, meldung: '' }))
220
+ } else {
221
+ await update($, lauf, () => ({ art: 'fehler', ziel, meldung: meldungKurz(meldung) }))
222
+ }
223
+ } finally {
224
+ await $.store.delete(SPERRE)
225
+ }
226
+ }
227
+
228
+ /** `null`: alle drei Stufen geklappt; sonst die Meldung der gescheiterten. */
229
+ async function stufen($: Engine, ordner: string, [npm, version, skill]: string[][], ziel: string): Promise<string | null> {
230
+ const init = (minuten: number) => ({ cwd: ordner, timeoutMs: minuten * MINUTE })
231
+ const a = await $.process.run(npm, init(10))
232
+ if (a.exitCode !== 0) return `${a.stdout}\n${a.stderr}`
233
+ // Hat das npm dieses Node die Fassung wirklich dorthin gelegt, wo `kimal3` liegt? (Homebrew, Volta, A-8)
234
+ const b = await $.process.run(version, init(2))
235
+ if (b.exitCode !== 0 || !versionStimmt(b.stdout, ziel)) return `kimal3 --version:\n${b.stdout}\n${b.stderr}`
236
+ const c = await $.process.run(skill, init(2))
237
+ return c.exitCode !== 0 ? `${c.stdout}\n${c.stderr}` : null
238
+ }
239
+
240
+ /** „Claude fragen“ nach einem Fehlschlag: die Zielfassung und das Ende der Meldung (Bauplan 1.3 Punkt 6). */
241
+ export async function updateClaudeFragen($: Engine, sprache: Sprache): Promise<void> {
242
+ const stand = await read($, lauf)
243
+ if (stand?.art === 'fehler') await $.prompt.submit({ text: TEXTE[sprache].bitteFehler(stand.ziel, stand.meldung) })
244
+ }
@@ -1,8 +1,16 @@
1
- declare module 'claude-code' {
2
- interface PluginState {
3
- 'kimal3-logo': {
4
- // Die Projektseite wurde in dieser Sitzung schon geöffnet (hooks/app-oeffnen.ts) — übersteht das Neuladen des Mods.
5
- appGeoeffnet: boolean
6
- }
7
- }
8
- }
1
+ declare module 'claude-code' {
2
+ interface PluginState {
3
+ 'kimal3-logo': {
4
+ // Die Projektseite wurde in dieser Sitzung schon geöffnet (hooks/app-oeffnen.ts) — übersteht das Neuladen des Mods.
5
+ appGeoeffnet: boolean
6
+ // Der Chat wurde benannt oder es gab schon einen Versuch — danach nie wieder (hooks/chat-benennen.ts, B-3, B-6).
7
+ chatBenannt: boolean
8
+ // Wie oft das Modell in dieser Sitzung schon gefragt wurde, ob das Thema klar ist (höchstens 8, B-8).
9
+ benennenFragen: number
10
+ // Die neueste Fassung, die npm heute genannt hat ('' = keine bekannt); daraus zeichnet die Leiste den Knopf (hooks/update.ts).
11
+ updateNeueste: string
12
+ // Was der Klick in diesem Fenster angestoßen hat; `meldung` = Ende der Fehlermeldung für „Claude fragen“ (hooks/update.ts).
13
+ updateLauf: { art: 'laeuft' | 'geklappt' | 'fehler' | 'gesperrt'; ziel: string; meldung: string } | null
14
+ }
15
+ }
16
+ }