Connects Claude Code to tenantree: administration of SAP BTP landscapes with approval and audit. Shows the active target (organization, landscape, subaccount)…

tenantree verbindet Claude Code mit der SAP Business Technology Platform (SAP BTP) Ihres Unternehmens: Landschaften wählen, Anmeldung am eigenen SAP Cloud Identity Services (IAS), Administration der BTP-Dienste mit Freigabe durch Menschen und lückenlosem Protokoll.
Dieses Repository ist der Plugin-Marketplace von tenantree. Es enthält keine kundenspezifische Konfiguration; welche Landschaften Sie sehen, legt Ihr Unternehmen im Tenantree-Portal fest.
claude --version); Installation: https://code.claude.com/docs/en/setupclaude plugin marketplace add tenantree-de/claude-plugin
claude plugin install tenantree@tenantree
Alternativ in einer laufenden Claude-Code-Sitzung:
/plugin marketplace add tenantree-de/claude-plugin
/plugin install tenantree@tenantree
Danach Claude Code neu starten, /mcp aufrufen, den Server tenantree auswählen und die Anmeldung starten.
claude plugin marketplace update tenantree
claude plugin update tenantree@tenantree
Die vollständige Einrichtung (SAP Cloud Identity Services, Anwendung „tenantree“, Gruppen, Portal) beschreibt die Anleitung im Portal. Eine Kontextdatei, mit der ein KI-Assistent Sie Schritt für Schritt durch die Einrichtung führt: https://portal.tenantree.de/tenantree-einrichtung-kontext.md
tenantree (https://portal.tenantree.de/mcp) mit Anmeldung per OAuthSandner Consulting, Inh. Jonathan Sandner · kontakt@tenantree.de · Impressum · Datenschutz
Die Nutzung von tenantree setzt einen Vertrag mit Sandner Consulting voraus. Siehe LICENSE.
hooks/register.tsx 1184 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AccountState, ActiveTarget, Attached, CostSection, CostTile, DiagramTile, PlanTile, Row, Target, TargetState } from '../types'
5
6/**
7 * tenantree in Claude Code (operator, 15./16.10.2026):
8 *
9 * THE BAND above the prompt shows organization -> landscape -> subaccount of the active target, a
10 * picker to switch it, the sign-in at the IAS and the open approvals; the tenantree mark sits on
11 * the right.
12 *
13 * THE TILES come AFTER the reply, never in the middle of it -- IN THE TERMINAL ONLY. Since about
14 * 21.10.2026 the Claude desktop app (and VS Code) draws the MCP Apps views of a plugin's server again,
15 * inline at the tool call (observed 21.10.2026 for tenantree_cost_query and a plan preview, Claude
16 * Code 2.1.296; not documented by Anthropic). There the original views are shown and the mod draws
17 * nothing twice. The terminal cannot draw them, so there the mod keeps drawing its tiles.
18 *
19 * NO CALLS OF ITS OWN IN THE BACKGROUND: auto mode refuses a plugin's calls nobody asked for
20 * (measured 16.10.2026). The tiles read the plan from the text of the tool answer; the plugin calls
21 * tenantree only when the person presses a button, and the press is passed on as their request.
22 */
23const target = atom({ plugin: 'tenantree', key: 'target' } as const, { status: 'unknown' } as TargetState)
24const server = atom({ plugin: 'tenantree', key: 'server' } as const, null as string | null)
25const message = atom({ plugin: 'tenantree', key: 'message' } as const, null as string | null)
26const tiles = atom({ plugin: 'tenantree', key: 'tiles' } as const, {} as Record<string, PlanTile>)
27const turn = atom({ plugin: 'tenantree', key: 'turn' } as const, [] as string[])
28const attached = atom({ plugin: 'tenantree', key: 'attached' } as const, [] as Attached[])
29const diagrams = atom({ plugin: 'tenantree', key: 'diagrams' } as const, {} as Record<string, DiagramTile>)
30const costs = atom({ plugin: 'tenantree', key: 'costs' } as const, {} as Record<string, CostTile>)
31const expanded = atom({ plugin: 'tenantree', key: 'expanded' } as const, {} as Record<string, boolean>)
32const signin = atom({ plugin: 'tenantree', key: 'signin' } as const, null as { url: string; system: string | null; at: number } | null)
33// After signing out (17.10.2026): the one-time link that ends the IAS session in the browser.
34const logout = atom({ plugin: 'tenantree', key: 'logout' } as const, null as { url: string; at: number } | null)
35const LOGOUT_LINK = /https:\/\/[^\s)]+\/sso\/signout\/[A-Za-z0-9_-]+/
36/** TEPLU-12: the tenantree account of this connection; signedOut after Sign out or when the server asks for a sign-in. */
37const account = atom({ plugin: 'tenantree', key: 'account' } as const, { status: 'unknown' } as AccountState)
38
39const PREFIX = 'mcp__plugin_tenantree_tenantree__'
40const PORTAL = 'https://portal.tenantree.de'
41const TENANTREE_TOOL = /^mcp__plugin_tenantree_tenantree__([a-z0-9_]+)$/
42const PLAN_LINK = /https:\/\/[^\s"<>\\]+\/plan\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/
43const HANDOVER_LINK = /https:\/\/[^\s"<>\\]+\/secret\/[A-Za-z0-9_-]+/
44const SIGNIN_LINK = /https:\/\/[^\s"<>\\]+\/sso\/start\/[A-Za-z0-9_-]+/
45const SIGNIN_MS = 10 * 60 * 1000
46/** The last line of every tenantree answer names the active target (server, 16.10.2026). */
47const TARGET_LINE = /tenantree target: (.+?) → (.+?) → (.+?) \((production|quality|development|other)\)\s*$/m
48const STORE_KEY = 'lastTarget'
49const PLAN_MS = 15 * 60 * 1000
50const KIND: Record<ActiveTarget['kind'], string> = { production: 'Production', quality: 'Quality', development: 'Development', other: 'Other' }
51const LEVEL: Record<string, string> = { structure: 'Structure', landscape: 'Landscape', account: 'Account' }
52// The tenantree mark (Logo E) for dark surfaces.
53const MARK =
54 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-84 -38 780 780"><g transform="matrix(1.05 0 0 1.05 -0.7 -36)"><path fill="#05A4A0" d="M320 470 Q300 214 556 190 Q576 446 320 470Z"/><rect fill="#FFFFFF" x="224" y="80" width="137" height="640"/><rect fill="#0078CF" x="14" y="20" width="556" height="125"/></g></svg>'
55
56const textOf = (r: unknown): string => {
57 if (typeof r === 'string') return r
58 if (Array.isArray(r)) return r.map(textOf).join('\n')
59 if (r && typeof r === 'object') {
60 const o = r as Record<string, unknown>
61 if (typeof o.text === 'string') return o.text
62 if ('content' in o) return textOf(o.content)
63 if ('result' in o) return textOf(o.result)
64 }
65 return ''
66}
67
68/** A field of the structured content, which Claude Code hands a plugin as JSON text (measured 15.10.2026). */
69function fieldOf(value: unknown, name: string, depth = 0): unknown {
70 if (typeof value === 'string' && value.startsWith('{') && depth <= 4) {
71 try {
72 return fieldOf(JSON.parse(value), name, depth + 1)
73 } catch {
74 return undefined
75 }
76 }
77 if (!value || typeof value !== 'object' || depth > 4) return undefined
78 const v = value as Record<string, unknown>
79 if (name in v) return v[name]
80 for (const child of Object.values(v)) {
81 const found = fieldOf(child, name, depth + 1)
82 if (found !== undefined) return found
83 }
84 return undefined
85}
86
87// --- The plan text ----------------------------------------------------------
88
89const FACT = /^([A-Z][A-Za-z ()/-]{1,28}):\s+(\S.*)$/
90const FOR_THE_MODEL = /^(Repeat the same call|APPROVAL REQUIRED|On this installation|Approval page|Give the person|Do not call again|COST CONFIRMATION)/
91// THE COST NOTICE (operator, 21.10.2026): the marker of a plan with costs or unchecked costs, and the head of its statement.
92const COST_MARK = /^Cost confirmation required \((paid|unclear)\):/
93const COST_HEAD = /^(Cost \(cost calculator\):|Cost:|Cost of |Cost afterwards|Cost while running|Afterwards:|From then on)/
94const COST_TEXT = {
95 title: 'Cost notice',
96 lead: {
97 paid: 'This plan can incur costs. Please review the cost assessment before you approve it.',
98 unclear: 'The costs of this plan have not been checked. tenantree cannot rule out that costs arise.',
99 },
100 confirm: {
101 paid: 'I have read the cost notice and confirm that costs may arise.',
102 unclear: 'I have read the cost notice and confirm that the costs have not been checked and may arise.',
103 },
104 required: 'Approval is possible only after you have confirmed the cost notice.',
105} as const
106
107/** The cost notice of a plan text: its kind (unclear wins) and the statements above its markers. */
108function costOfPlan(text: string): PlanTile['cost'] {
109 const lines = text.split('\n')
110 let kind: 'paid' | 'unclear' | null = null
111 const parts: string[] = []
112 for (let i = 0; i < lines.length; i++) {
113 const m = COST_MARK.exec((lines[i] ?? '').trim())
114 if (!m) continue
115 kind = kind === 'unclear' || m[1] === 'unclear' ? 'unclear' : 'paid'
116 const block: string[] = []
117 for (let j = i - 1; j >= 0; j--) {
118 const t = (lines[j] ?? '').trim()
119 if (!t || /^Plan:/.test(t) || /^Step \d+:/.test(t) || COST_MARK.test(t)) break
120 block.unshift(t)
121 }
122 const head = block.findIndex(l => COST_HEAD.test(l))
123 parts.push((head >= 0 ? block.slice(head) : block.filter(l => /EUR|cost/i.test(l))).join('\n'))
124 }
125 return kind ? { kind, text: parts.filter(Boolean).join('\n\n') } : null
126}
127const LEFT_OUT = new Set(['Plan', 'Tool', 'Enabled because'])
128
129/** The plan as its text states it: what the tile shows. */
130function planOf(text: string): Omit<PlanTile, 'planId' | 'url' | 'expiresAt' | 'status'> {
131 const lines = text.split('\n')
132 const facts: Row[] = []
133 const values: Row[] = []
134 const warnings: string[] = []
135 let title = ''
136 let level: string | null = null
137 let target: string | null = null
138 let headline = ''
139 for (let i = 0; i < lines.length; i++) {
140 const t = (lines[i] ?? '').trim()
141 if (!t || FOR_THE_MODEL.test(t) || COST_MARK.test(t)) continue
142 if (!headline) {
143 headline = t
144 continue
145 }
146 if (t === 'With:') {
147 const block: string[] = []
148 for (i++; i < lines.length && (lines[i] ?? '').trim(); i++) block.push(lines[i] ?? '')
149 try {
150 for (const [k, v] of Object.entries(JSON.parse(block.join('\n')) as Record<string, unknown>)) values.push({ label: k, value: typeof v === 'string' ? v : JSON.stringify(v) })
151 } catch {
152 // Not JSON: left out of the tile; the reply names it.
153 }
154 continue
155 }
156 const plan = /^Plan:\s+(.*?)(?:\s+\(write level (\w+)\))?$/.exec(t)
157 if (plan) {
158 title = plan[1] ?? ''
159 level = plan[2] ?? null
160 continue
161 }
162 if (/^(WARNING|Warning|TRAP)[:\s]/.test(t)) {
163 warnings.push(t.replace(/^(WARNING|Warning|TRAP):?\s*/, ''))
164 continue
165 }
166 const fact = FACT.exec(t)
167 if (fact && fact[1] && fact[2]) {
168 if (fact[1] === 'Tenant') target = fact[2]
169 else if (!LEFT_OUT.has(fact[1])) facts.push({ label: fact[1], value: fact[2] })
170 }
171 }
172 return { title: title || 'Plan', level, target, headline, facts, values, warnings, cost: costOfPlan(text) }
173}
174
175// --- The cost text -----------------------------------------------------------
176
177const VIEW_LINK = /Graphical view in the Tenantree Dashboard: (https:\/\/\S+)/
178const HEADING = /^[A-Z][A-Z ()/-]{2,}$/
179
180/** The cost assessment as its text states it, by section (THE ACCOUNT NOW, COST RISKS, NOT CHECKED, ...). */
181function costOf(text: string): CostTile {
182 const sections: CostSection[] = []
183 const risks: string[] = []
184 const unchecked: string[] = []
185 let current: CostSection | null = null
186 for (const raw of text.split('\n')) {
187 const t = raw.trim()
188 if (!t || TARGET_LINE.test(t) || VIEW_LINK.test(t) || /^Prices:/.test(t)) continue
189 if (HEADING.test(t)) {
190 current = { name: t.charAt(0) + t.slice(1).toLowerCase(), rows: [], lines: [] }
191 if (!/^COST RISKS$|^NOT CHECKED$/.test(t)) sections.push(current)
192 continue
193 }
194 if (!current) continue
195 if (/^Cost risks$/.test(current.name)) risks.push(t)
196 else if (/^Not checked$/.test(current.name)) unchecked.push(t)
197 else {
198 const m = /^([^:]{2,60}):\s+(.+)$/.exec(t)
199 if (m && m[1] && m[2] && !/^https?$/.test(m[1])) current.rows.push({ label: m[1], value: m[2] })
200 else current.lines.push(t)
201 }
202 }
203 return { sections, risks, unchecked, url: VIEW_LINK.exec(text)?.[1] ?? null }
204}
205
206// --- The diagram ---------------------------------------------------------------
207
208/** The diagram as its text states it: layers with their nodes, the connections, findings and notes. */
209function diagramOf(text: string): DiagramTile {
210 const lines = text.split('\n')
211 const tile: DiagramTile = { title: 'Landscape', layers: [], edges: [], findings: [], notes: [], url: VIEW_LINK.exec(text)?.[1] ?? null }
212 let mode: 'none' | 'layers' | 'edges' | 'findings' | 'notes' = 'none'
213 for (const raw of lines) {
214 const t = raw.trim()
215 if (/^```/.test(t) || /^Mermaid:/.test(t)) { mode = 'none'; if (/^Mermaid:/.test(t)) break; continue }
216 const title = /^Architecture diagram:\s*(.+)$/.exec(t)
217 if (title?.[1]) { tile.title = title[1]; continue }
218 if (/^Layers/.test(t)) { mode = 'layers'; continue }
219 if (/^Connections/.test(t)) { mode = 'edges'; continue }
220 if (/^Findings/.test(t)) { mode = 'findings'; continue }
221 if (/^Notes/.test(t)) { mode = 'notes'; continue }
222 if (!t || TARGET_LINE.test(t) || VIEW_LINK.test(t)) continue
223 if (mode === 'layers') {
224 const layer = /^\d+\.\s+(.+)$/.exec(t)
225 if (layer?.[1]) { tile.layers.push({ name: layer[1], nodes: [] }); continue }
226 const node = /^-\s+(.+)$/.exec(t)
227 const current = tile.layers[tile.layers.length - 1]
228 if (node?.[1] && current) {
229 const [name, ...rest] = node[1].split(' -- ')
230 current.nodes.push({ name: (name ?? '').trim(), detail: rest.length ? [rest.join(' -- ').trim()] : [] })
231 continue
232 }
233 const last = current?.nodes[current.nodes.length - 1]
234 if (last) last.detail.push(t)
235 } else if (mode === 'edges') {
236 const m = /^(.+?)\s*-(?:>|>)\s*(.+?)(?:\s*\(([^)]*)\))?$/.exec(t)
237 if (m?.[1] && m[2]) tile.edges.push({ from: m[1].trim(), to: m[2].trim(), label: m[3] ?? null })
238 } else if (mode === 'findings') {
239 if (!/^None\.?$/.test(t)) tile.findings.push(t.replace(/^-\s*/, ''))
240 } else if (mode === 'notes') tile.notes.push(t.replace(/^-\s*/, ''))
241 }
242 return tile
243}
244
245const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
246const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
247
248/** The blocks of the second layer of a tenant diagram, in drawing order. */
249const GROUPS = ['SAP subscriptions', 'tenantree access', 'Other instances'] as const
250type Group = (typeof GROUPS)[number]
251
252/**
253 * Sorts the nodes of a layer into blocks: SAP subscriptions (reached by a connection labelled
254 * 'subscribed'), tenantree's own access (named tenantree-… or tenantree_…) and other instances.
255 * Null when no node is a subscription or a tenantree access: the layer keeps one box per node.
256 */
257function groupsOf(nodes: DiagramTile['layers'][number]['nodes'], edges: DiagramTile['edges']): Map<Group, typeof nodes> | null {
258 const subscribed = new Set(edges.filter(e => /^subscribed$/i.test((e.label ?? '').trim())).map(e => e.to))
259 const groupOf = (name: string): Group => (subscribed.has(name) ? 'SAP subscriptions' : /^tenantree[-_]/i.test(name) ? 'tenantree access' : 'Other instances')
260 if (!nodes.some(n => groupOf(n.name) !== 'Other instances')) return null
261 const out = new Map<Group, typeof nodes>()
262 for (const g of GROUPS) {
263 const members = nodes.filter(n => groupOf(n.name) === g)
264 if (members.length) out.set(g, members)
265 }
266 return out
267}
268
269/**
270 * The diagram as SVG: one column per layer, a box per node (in blocks where the layer groups),
271 * curved connections in the tenantree colours.
272 *
273 * Colour scheme: a render hook learns the surface (desktop, mobile, editor) and its size, not the
274 * person's light or dark theme ($.config's 'theme' row is the terminal's setting, not the app's).
275 * The SVG therefore adapts itself: presentation attributes carry the light palette, and an embedded
276 * <style> overrides them under @media (prefers-color-scheme: dark), which an SVG drawn as an image
277 * evaluates against the app's own appearance. Should a surface ever drop the <style>, the light
278 * palette remains complete and legible on its own background. 'light' and 'dark' force one scheme.
279 */
280function diagramSvg(d: DiagramTile, scheme: 'auto' | 'light' | 'dark' = 'auto'): { source: string; width: number; height: number } {
281 const MAX_WIDTH = 1100, GAP_X = 64, TOP = 34, PAD = 16, INSET = 10, HEAD = 22, GROUP_GAP = 14
282 const NAME_PX = 7.2, DETAIL_PX = 6.4, TEXT_PAD = 24, MIN_W = 200, MAX_W = 320
283 const layers = d.layers.map((layer, li) => ({ layer, groups: li > 0 ? groupsOf(layer.nodes, d.edges) : null }))
284
285 // Column widths from the longest text; past the overall cap the widest columns give way first.
286 const wanted = layers.map(({ layer, groups }) => {
287 const text = Math.max(0, ...layer.nodes.map(n => Math.max(n.name.length * NAME_PX, (n.detail[0] ?? '').length * DETAIL_PX)))
288 return Math.min(MAX_W, Math.max(MIN_W, Math.ceil(text + TEXT_PAD))) + (groups ? 2 * INSET : 0)
289 })
290 const room = MAX_WIDTH - 2 * PAD - Math.max(0, layers.length - 1) * GAP_X
291 let ceiling = Math.max(...wanted, 0)
292 while (ceiling > 120 && wanted.reduce((s, w) => s + Math.min(w, ceiling), 0) > room) ceiling -= 4
293 const widths = wanted.map(w => Math.min(w, ceiling))
294
295 // Vertical extent of each column: plain boxes, or blocks of compact boxes.
296 const H = 50, GAP_Y = 12, HG = 44, GAP_G = 8
297 const extent = layers.map(({ layer, groups }) =>
298 groups
299 ? [...groups.values()].reduce((s, ns) => s + HEAD + ns.length * (HG + GAP_G) - GAP_G + 2 * INSET, 0) + (groups.size - 1) * GROUP_GAP
300 : layer.nodes.length * (H + GAP_Y) - GAP_Y,
301 )
302 const inner = Math.max(H, ...extent)
303 const height = TOP + inner + PAD + 4
304 const width = PAD * 2 + widths.reduce((s, w) => s + w, 0) + Math.max(0, layers.length - 1) * GAP_X
305
306 const anchor = new Map<string, { left: number; right: number; mid: number }>()
307 const block = new Map<string, { left: number; mid: number }>()
308 const parts: string[] = []
309 const box = (x: number, y: number, w: number, h: number, n: DiagramTile['layers'][number]['nodes'][number], first: boolean) => {
310 const chars = (px: number) => Math.max(4, Math.floor((w - TEXT_PAD) / px))
311 const nameY = h >= 50 ? 21 : 19, detailY = h >= 50 ? 38 : 34
312 parts.push(
313 `<rect class="${first ? 'box1' : 'box'}" x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${first ? '#e1f4ff' : '#ffffff'}" stroke="${first ? '#0078cf' : '#05a4a0'}" stroke-width="1.5"/>`,
314 `<text class="name" x="${x + 12}" y="${y + nameY}" font-size="13" font-weight="600" fill="#1d2d3e">${esc(cut(n.name, chars(NAME_PX)))}</text>`,
315 `<text class="detail" x="${x + 12}" y="${y + detailY}" font-size="11" fill="#556b82">${esc(cut(n.detail[0] ?? '', chars(DETAIL_PX)))}</text>`,
316 )
317 anchor.set(n.name, { left: x, right: x + w, mid: y + h / 2 })
318 }
319
320 let x = PAD
321 layers.forEach(({ layer, groups }, li) => {
322 const w = widths[li] ?? MIN_W
323 let y = TOP + (inner - (extent[li] ?? 0)) / 2
324 parts.push(`<text class="head" x="${x}" y="20" font-size="12" font-weight="600" fill="#758ca4">${esc(cut(layer.name.toUpperCase(), Math.floor(w / 7.6)))}</text>`)
325 if (groups) {
326 for (const [g, ns] of groups) {
327 const h = HEAD + ns.length * (HG + GAP_G) - GAP_G + 2 * INSET
328 parts.push(
329 `<rect class="group" x="${x}" y="${y}" width="${w}" height="${h}" rx="10" fill="#eef1f4" stroke="#c9d2db" stroke-width="1"/>`,
330 `<text class="ghead" x="${x + INSET + 2}" y="${y + INSET + 11}" font-size="11" font-weight="600" fill="#556b82">${esc(g)} (${ns.length})</text>`,
331 )
332 ns.forEach((n, ni) => box(x + INSET, y + INSET + HEAD + ni * (HG + GAP_G), w - 2 * INSET, HG, n, false))
333 for (const n of ns) block.set(n.name, { left: x, mid: y + h / 2 })
334 y += h + GROUP_GAP
335 }
336 } else {
337 layer.nodes.forEach((n, ni) => box(x, y + ni * (H + GAP_Y), w, H, n, li === 0))
338 }
339 x += w + GAP_X
340 })
341
342 // One connection per source and block where the target sits in a block, otherwise one per connection.
343 const drawn = new Set<string>()
344 const lines = d.edges
345 .map(e => {
346 const a = anchor.get(e.from), grouped = block.get(e.to), b = grouped ?? anchor.get(e.to)
347 if (!a || !b) return ''
348 const key = `${e.from}\u0000${grouped ? `${b.left}:${b.mid}` : e.to}`
349 if (drawn.has(key)) return ''
350 drawn.add(key)
351 const x1 = a.right, y1 = a.mid, x2 = b.left, y2 = b.mid, mx = (x1 + x2) / 2
352 return `<path class="edge" d="M${x1} ${y1} C${mx} ${y1} ${mx} ${y2} ${x2 - 6} ${y2}" fill="none" stroke="#758ca4" stroke-width="1.2" marker-end="url(#arrow)"/>`
353 })
354 .join('')
355
356 const dark = '.bg{fill:#1d232a}.box{fill:#28313a;stroke:#05a4a0}.box1{fill:#28313a;stroke:#4db1ff}.name{fill:#eaecee}.detail,.head,.ghead{fill:#a9b4be}.group{fill:#222a32;stroke:#3a4652}.edge{stroke:#758ca4}.arrow{fill:#758ca4}'
357 const style = scheme === 'light' ? '' : `<style>${scheme === 'dark' ? dark : `@media (prefers-color-scheme: dark){${dark}}`}</style>`
358 const source =
359 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" font-family="Inter, Segoe UI, Arial, sans-serif">` +
360 style +
361 `<defs><marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path class="arrow" d="M0 0 L8 4 L0 8 z" fill="#758ca4"/></marker></defs>` +
362 `<rect class="bg" width="${width}" height="${height}" rx="10" fill="#f5f6f7"/>${lines}${parts.join('')}</svg>`
363 return { source, width, height }
364}
365
366/** What the diagram shows, for a reader that cannot see it: layers, their blocks and nodes, and the findings. */
367function diagramAlt(d: DiagramTile): string {
368 const layers = d.layers.map((l, li) => {
369 const groups = li > 0 ? groupsOf(l.nodes, d.edges) : null
370 const content = groups ? [...groups].map(([g, ns]) => `${g}: ${ns.map(n => n.name).join(', ')}`).join('; ') : l.nodes.map(n => n.name).join(', ')
371 return `${l.name}: ${content}`
372 })
373 const findings = d.findings.length ? `${d.findings.length} finding${d.findings.length === 1 ? '' : 's'}` : 'No findings'
374 return `Architecture diagram ${d.title}. ${layers.join('. ')}. ${findings}.`
375}
376
377// --- Target and sign-in -----------------------------------------------------
378
379/**
380 * The picker labels come from the list of targets, which only the landscape query sends. When an
381 * answer names the active target with other names (a landscape was renamed in the portal), the
382 * active entry of the list follows, so that the band does not keep showing the old name.
383 */
384function withActive(targets: Target[] | undefined, t: Pick<ActiveTarget, 'organization' | 'landscape' | 'system'>): Target[] | undefined {
385 if (!targets?.length) return targets
386 const i = targets.findIndex(x => x.active)
387 if (i < 0) return targets
388 const a = targets[i]!
389 if (a.organization === t.organization && a.landscape === t.landscape && a.system === t.system) return targets
390 return targets.map((x, k) => (k === i ? { ...x, organization: t.organization, landscape: t.landscape, system: t.system } : x))
391}
392
393async function remember($: EngineInterface, result: unknown) {
394 const zone = fieldOf(result, 'timeZone')
395 if (typeof zone === 'string' && zone) personZone = zone
396 const found = fieldOf(result, 'activeTarget') as ActiveTarget | null | undefined
397 if (found === undefined) {
398 // An SAP tool's answer names the target in its last line: enough for the band, without a call.
399 const m = TARGET_LINE.exec(textOf(result))
400 if (!m) return
401 const [, organization = '', landscape = '', system = '', kind = 'other'] = m
402 const at = await $.clock.now()
403 await update($, target, (prev): TargetState => {
404 const before = prev.status === 'known' ? prev : undefined
405 const same = before?.target && before.target.organization === organization && before.target.landscape === landscape && before.target.system === system
406 // The line comes only with an answer that went through: the person is admitted there.
407 if (same && before?.target) return before.target.right === 'none' || !before.target.signedIn ? { ...before, target: { ...before.target, right: 'user', signedIn: true } } : before
408 return { status: 'known', target: { organization, landscape, system, kind: kind as ActiveTarget['kind'], region: '', subaccountId: null, signedIn: true, right: 'user' }, account: before?.account, targets: withActive(before?.targets, { organization, landscape, system }), at }
409 })
410 await update($, message, m => (m && !m.endsWith('…') ? null : m))
411 await update($, signin, () => null)
412 await keep($)
413 return
414 }
415 const signedInAs = fieldOf(result, 'signedInAs') as string | undefined
416 const organizations = fieldOf(result, 'organizations') as Array<{ name: string; role: 'admin' | 'user' }> | undefined
417 if (signedInAs) {
418 await update($, account, (): AccountState => ({ status: 'signedIn', email: signedInAs, organizations: organizations ?? [] }))
419 await update($, logout, () => null)
420 // Signed in (again): a sign-in hint in the band is out of date (operator, 17.10.2026).
421 await update($, message, m => (m && SIGNIN_NOTES.some(n => m.startsWith(n)) ? null : m))
422 }
423 // NOTICES LEAVE WHEN THINGS WORKED (operator, 17.10.2026): an answer that went through clears the band's error
424 // or hint line (progress lines ending in '…' stay until their action ends); signed in at the IAS of the active
425 // target clears the pending IAS sign-in.
426 await update($, message, m => (m && !m.endsWith('…') ? null : m))
427 if (found?.signedIn) await update($, signin, () => null)
428 const targets = fieldOf(result, 'targets') as Target[] | undefined
429 const at = await $.clock.now()
430 await update($, target, (prev): TargetState => {
431 const before = prev.status === 'known' ? prev : undefined
432 return { status: 'known', target: found, account: signedInAs ?? before?.account, targets: targets ?? (found ? withActive(before?.targets, found) : before?.targets), at }
433 })
434 await keep($)
435}
436
437/** The target outlives the session (it lives per person on the server anyway): the band stands at once. */
438async function keep($: EngineInterface) {
439 const now = await read($, target)
440 if (now.status === 'known') await $.store.set(STORE_KEY, now).catch(() => {})
441}
442
443async function serverOf($: EngineInterface): Promise<string | null> {
444 const known = await read($, server)
445 if (known) return known
446 const connected = await $.mcp.connect('tenantree')
447 if (!connected.isConnected) {
448 // The server asks for a sign-in: the band offers it (TEPLU-12).
449 if (connected.reason === 'auth') {
450 await update($, account, (): AccountState => ({ status: 'signedOut' }))
451 void watchSignIn($)
452 }
453 return null
454 }
455 await update($, server, () => connected.server)
456 return connected.server
457}
458
459/** Reads the target (read-only tools; at session start and when the person comes back). */
460async function refresh($: EngineInterface) {
461 const name = await serverOf($)
462 if (!name) return
463 const profile = await $.mcp.call(name, 'tenantree_profile_read', {})
464 if (profile.isError && NEEDS_SIGNIN.test(textOf(profile))) return signedOut($)
465 if (!profile.isError) await remember($, profile)
466 const landscapes = await $.mcp.call(name, 'tenantree_landscape_query', {})
467 if (!landscapes.isError) await remember($, landscapes)
468}
469
470/** Reads the list of subaccounts for the picker (read-only), at most every 30 seconds. */
471let lastTargets = 0
472async function loadTargets($: EngineInterface) {
473 const now = await $.clock.now()
474 if (now - lastTargets < 30_000) return
475 lastTargets = now
476 const name = await serverOf($)
477 if (!name) return
478 const landscapes = await $.mcp.call(name, 'tenantree_landscape_query', {})
479 if (!landscapes.isError) await remember($, landscapes)
480}
481
482/** A call the person asked for with a button: the press goes along as their request. */
483async function pressed($: EngineInterface, tool: string, args: Record<string, unknown>, what: string, consent?: string) {
484 const r = (await $.tool.call({ tool: `${PREFIX}${tool}`, ...args, consent: consent ?? `The user pressed "${what}" in the tenantree band.` } as never)) as unknown
485 if (r && typeof r === 'object' && 'deny' in r) throw new Error(String((r as { deny: unknown }).deny))
486 return r as { text?: string; isError?: boolean; result?: unknown }
487}
488
489/**
490 * THE TILE FOLLOWS ITS PLAN BY ITSELF (operator, 16.10.2026): while a plan is open, the tile asks every
491 * few seconds whether it was decided -- on the approval page, in another tile, or not at all. The read
492 * tool is marked read-only, so auto mode lets it through without a request of the person.
493 */
494let ticking: { cancel: () => void } | null = null
495let lastTick = 0
496const POLL_MS = 5000
497
498async function check($: EngineInterface, id: string) {
499 const tile = (await read($, tiles))[id]
500 if (!tile || tile.status !== 'open') return
501 const name = await serverOf($)
502 if (!name) return
503 const r = await $.mcp.call(name, 'tenantree_plan_read', { planId: tile.planId })
504 if (r.isError) return
505 const status = String(fieldOf(r, 'status') ?? '')
506 if (status === 'open' || !status) return
507 const get = <T,>(k: string) => fieldOf(r, k) as T | undefined
508 await update($, tiles, all => ({
509 ...all,
510 [id]: {
511 ...tile,
512 code: undefined,
513 status: (status === 'decided' ? 'decided' : 'closed') as PlanTile['status'],
514 decision: get<'approve' | 'reject'>('decision'),
515 by: get<string | null>('by'),
516 ok: get<boolean>('ok'),
517 at: get<number>('at'),
518 first: get<string>('first'),
519 },
520 }))
521}
522
523async function tick($: EngineInterface) {
524 const now = await $.clock.now()
525 lastTick = now
526 const open = Object.entries(await read($, tiles)).filter(([, p]) => p.status === 'open' && now <= p.expiresAt + POLL_MS)
527 if (!open.length) {
528 ticking?.cancel()
529 ticking = null
530 return
531 }
532 for (const [id] of open) await check($, id).catch(() => {})
533}
534
535async function follow($: EngineInterface) {
536 // A refused period ends an interval: a timer that has not ticked for two periods is started anew.
537 const now = await $.clock.now()
538 if (ticking && now - lastTick < 2 * POLL_MS) return
539 ticking?.cancel()
540 lastTick = now
541 ticking = $.clock.every(POLL_MS, () => void tick($).catch(() => {}))
542}
543
544/** The approval code of a call, where Claude Code hands _meta "claude/plugins" to plugins (way B, 16.10.2026). */
545function codeOf(meta: unknown): string | undefined {
546 const plugins = (meta as Record<string, unknown> | undefined)?.['claude/plugins'] as Record<string, unknown> | undefined
547 const plan = (plugins?.tenantree as { plan?: { code?: unknown } } | undefined)?.plan
548 return typeof plan?.code === 'string' ? plan.code : undefined
549}
550
551/** Approve or reject on the person's press, with the code only the tile holds. */
552async function decide($: EngineInterface, id: string, decision: 'approve' | 'reject') {
553 const tile = (await read($, tiles))[id]
554 if (!tile?.code) return
555 await update($, tiles, all => ({ ...all, [id]: { ...tile, busy: decision } }))
556 try {
557 // A plan with costs is approved only with the confirmed cost notice; the server checks it again (21.10.2026).
558 if (decision === 'approve' && tile.cost && !tile.costAck) {
559 await update($, tiles, all => ({ ...all, [id]: { ...tile, busy: undefined } }))
560 return
561 }
562 const args: Record<string, unknown> = { planId: tile.planId, code: tile.code, decision }
563 if (decision === 'approve' && tile.cost && tile.costAck) args.costConfirmed = true
564 const r = await pressed($, 'tenantree_plan_decide', args, decision === 'approve' ? 'Approve' : 'Reject')
565 const body = r.text ?? textOf(r)
566 const at = await $.clock.now()
567 const closed = Boolean(r.isError) && /no longer open/.test(body)
568 await update($, tiles, all => ({
569 ...all,
570 [id]: {
571 ...tile,
572 busy: undefined,
573 code: undefined,
574 // "Not carried out: ..." is the decide tool's own refusal; any other error is the result of the approved operation.
575 status: (closed ? 'closed' : r.isError && /^Not carried out:/.test(body) ? 'unknown' : 'decided') as PlanTile['status'],
576 decision,
577 ok: !r.isError,
578 at,
579 first: body.split('\n')[0],
580 handover: HANDOVER_LINK.exec(body)?.[0] ?? null,
581 error: r.isError ? body.split('\n')[0] : undefined,
582 },
583 }))
584 } catch (err) {
585 await update($, tiles, all => ({ ...all, [id]: { ...tile, busy: undefined, status: 'unknown' as const, error: String((err as Error)?.message ?? err) } }))
586 }
587}
588
589/**
590 * THE PERSON SIGNS OUT IN THE PORTAL (TEPLU-12, TEN-537, 17.10.2026): the auto mode of Claude Code gives no
591 * verdict for a sign-out the plugin calls itself, so Sign out opens the connections of the profile in the
592 * portal. The band notices the sign-out with the next answer that asks for a sign-in.
593 */
594const NEEDS_SIGNIN = /Sign-in required|\b401\b|Unauthorized|needs? (?:a )?(?:new )?(?:sign-in|authentication)/i
595
596/**
597 * SIGN OUT AS THE PERSON'S OWN REQUEST (operator, 17.10.2026). The button and "/tenantree signout" put
598 * "Sign me out of tenantree." into the prompt box; the person sends it with Enter, and Claude signs the
599 * connection out because the person asked for it. The guard lets the model's call through only after the
600 * person themselves sent such a request.
601 */
602let signOutRequestedAt = 0
603const SIGNOUT_WINDOW_MS = 3 * 60 * 1000
604/** A button's call that Claude Code's Auto permission mode refused (operator, 17.10.2026): the band says so. */
605const AUTO_BLOCKED = 'The Auto permission mode blocks this action. Please switch the permission mode and press the button again.'
606const blockedByAuto = (err: unknown) => /auto mode|classifier|no verdict|permission/i.test(String((err as Error)?.message ?? err))
607
608const LOGIN_CONTEXT =
609 'The person wrote "tenantree login". 1) Call tenantree_landscape_query. 2) If there is no active target and exactly one system is listed, set it with tenantree_landscape_use (the person agreed to this for the single-system case); if several are listed, ask which one. 3) If the person is not signed in at the IAS of the active target, call tenantree_landscape_signin and give the link. Answer briefly: account, active target, IAS sign-in state. If the tenantree tools are not available, tell the person to run /mcp, select tenantree and choose Authenticate, then to write "tenantree login" again.'
610const SIGNOUT_PROMPT = 'Sign me out of tenantree.'
611const ASKS_SIGNOUT = /\bsign (?:me )?out\b.*\btenantree\b|\btenantree\b.*\bsign (?:me )?out\b/i
612
613async function signOut($: EngineInterface) {
614 await update($, message, () => 'Signing out …')
615 signOutRequestedAt = await $.clock.now()
616 try {
617 const r = await pressed($, 'tenantree_session_delete', {}, 'Sign out', 'The user pressed the "Sign out" button in the tenantree band and asks to sign this connection out of tenantree.')
618 if (r.isError) throw new Error(r.text ?? textOf(r))
619 await signedOut($)
620 await showLogout($, r.text ?? textOf(r))
621 await update($, message, () => null)
622 } catch (err) {
623 signOutRequestedAt = 0
624 await update($, message, () => (blockedByAuto(err) ? AUTO_BLOCKED : 'The sign-out was not carried out.'))
625 }
626}
627
628/**
629 * THE BAND FOLLOWS THE SIGN-IN BY ITSELF (operator, 17.10.2026): while signed out, the plugin asks Claude Code
630 * every 5 seconds whether the tenantree connection is back (no tool call); once it is, it reads the profile.
631 */
632let watching: { cancel: () => void } | null = null
633let lastWatch = 0
634const WATCH_MS = 5000
635
636/**
637 * THE CONNECTION STAYS (operator, 17.10.2026): Claude Code drops an MCP server whose connection broke (for
638 * example while tenantree is redeployed) and does not dial it again by itself. The plugin dials it on a timer;
639 * when it comes back, or the person signed in meanwhile, the band reads the state again. Connecting calls no tool.
640 */
641let wasConnected = true
642async function checkSignIn($: EngineInterface) {
643 lastWatch = await $.clock.now()
644 const c = await $.mcp.connect('tenantree').catch(() => null)
645 const before = wasConnected
646 wasConnected = Boolean(c?.isConnected)
647 if (!c?.isConnected) return
648 if (!before || (await read($, account)).status !== 'signedIn') {
649 await update($, server, () => c.server)
650 await refresh($).catch(() => {})
651 }
652}
653
654/**
655 * Started whenever the band shows "Not signed in" (also after a reload of the plugin, which ends timers), and
656 * started anew when Claude Code let a period pass without a tick.
657 */
658async function watchSignIn($: EngineInterface) {
659 const now = await $.clock.now()
660 if (watching && now - lastWatch < 2 * WATCH_MS) return
661 watching?.cancel()
662 lastWatch = now
663 watching = $.clock.every(WATCH_MS, () => void checkSignIn($).catch(() => {}))
664}
665
666async function showLogout($: EngineInterface, text: string) {
667 const url = LOGOUT_LINK.exec(text)?.[0]
668 const at = await $.clock.now()
669 await update($, logout, () => (url ? { url, at } : null))
670}
671
672async function signedOut($: EngineInterface) {
673 void watchSignIn($)
674 await update($, account, (): AccountState => ({ status: 'signedOut' }))
675 await update($, target, (): TargetState => ({ status: 'unknown' }))
676 await update($, server, () => null)
677 await update($, signin, () => null)
678 await $.store.set(STORE_KEY, null).catch(() => {})
679}
680
681/** THE PERSON SIGNS IN: Claude Code holds the OAuth sign-in; connecting starts it where the host can. */
682async function signIn($: EngineInterface) {
683 await update($, message, () => 'Connecting to tenantree …')
684 const connected = await $.mcp.connect('tenantree').catch(() => null)
685 if (connected?.isConnected) {
686 await update($, server, () => connected.server)
687 await refresh($).catch(() => {})
688 }
689 const now = await read($, account)
690 if (now.status === 'signedIn') return update($, message, () => null)
691 // Claude Code starts the OAuth sign-in only from /mcp (measured 17.10.2026): the band puts the command into
692 // the prompt box, so one Enter opens it. The person keeps the choice; nothing is sent by itself.
693 const filled = await $.prompt.fill({ text: '/mcp' }).catch(() => null)
694 await update($, message, () => (filled?.isFilled ? SIGNIN_READY : SIGNIN_HELP))
695}
696const SIGNIN_NOTES = ['/mcp is in the prompt box', 'To sign in,', 'Connecting to tenantree']
697const SIGNIN_READY = '/mcp is in the prompt box: press Enter, select tenantree and choose Authenticate. Afterwards write: tenantree login'
698let lastStatus = 0
699const STATUS_MS = 30_000
700const SIGNIN_HELP = 'To sign in, run /mcp, select tenantree and choose Authenticate. Afterwards write: tenantree login'
701
702const path = (t: { organization: string; landscape: string; system: string }) => `${t.organization} → ${t.landscape} → ${t.system}`
703/**
704 * TEN-375 / TEPLU-6: SAP BTP time (UTC) and the person's time zone from the profile (the server
705 * names it in its answers), one time when both are equal. Without a known zone, UTC alone.
706 */
707let personZone: string | null = null
708const time = (ms?: number) => {
709 if (!ms) return ''
710 const hm = (tz: string) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz })
711 const utc = hm('UTC')
712 try {
713 const local = personZone ? hm(personZone) : utc
714 return local === utc ? `${utc} UTC` : `${utc} UTC (${local} ${personZone})`
715 } catch {
716 return `${utc} UTC`
717 }
718}
719const normal = (s: string) => s.replace(/\s+/g, ' ').trim()
720
721export const register: Register = on => {
722 on('session.start', async ($, e, next) => {
723 const result = await next(e)
724 const last = (await $.store.get(STORE_KEY).catch(() => null)) as TargetState | null
725 if (last && last.status === 'known') await update($, target, prev => (prev.status === 'unknown' ? last : prev)).catch(() => {})
726 if (Object.values(await read($, tiles).catch(() => ({}))).some(p => p.status === 'open')) await follow($)
727 await $.command.register({ name: 'tenantree', description: 'tenantree account: show it, sign in or sign out', argumentHint: '[signin | signout]' })
728 void refresh($).catch(() => {})
729 void watchSignIn($).catch(() => {})
730 return result
731 })
732
733 // THE STATUS ON EVERY INPUT (operator, 17.10.2026): one read-only call tells whether this connection is
734 // signed in and as whom; the band offers Sign out or Sign in accordingly. At most every 30 seconds.
735 on('prompt.submit', async ($, e, next) => {
736 await update($, turn, () => []).catch(() => {})
737 // Only a message the person sent themselves counts as a sign-out request.
738 const origin = (e as { origin?: { kind?: string } }).origin?.kind
739 const said = String((e as { text?: unknown }).text ?? '').trim()
740 if (origin !== 'plugin' && (ASKS_SIGNOUT.test(said) || /^tenantree\s+log\s?out\.?$/i.test(said))) signOutRequestedAt = await $.clock.now()
741 // "tenantree login" (operator, 17.10.2026): one read-only call that connects tenantree and fills the band.
742 if (origin !== 'plugin' && /^tenantree\s+log\s?in\.?$/i.test(said)) {
743 return next({ ...e, context: [...((e as { context?: readonly string[] }).context ?? []), LOGIN_CONTEXT] } as typeof e)
744 }
745 const at = await $.clock.now()
746 if (at - lastStatus > STATUS_MS) {
747 lastStatus = at
748 void refresh($).catch(() => {})
749 }
750 return next(e)
751 })
752
753 // Only the Sign out button signs the connection out (TEN-537): never the model on its own.
754 // Signing out is the person's own step in the portal (TEN-537): never the model's.
755 on('tool.call', { tool: `${PREFIX}tenantree_session_delete` }, async ($, e, next) => {
756 const at = await $.clock.now()
757 if (at - signOutRequestedAt > SIGNOUT_WINDOW_MS) return { deny: 'Sign out only when the person has asked for it in their own message.' }
758 signOutRequestedAt = 0
759 const result = await next(e)
760 const failed = Boolean((result as { isError?: boolean } | null)?.isError) || 'deny' in (result as object)
761 if (!failed) {
762 await signedOut($)
763 await showLogout($, textOf(result))
764 }
765 await update($, message, () => (failed ? 'The sign-out was not carried out.' : null))
766 return result
767 })
768
769 on('tool.call', async ($, e, next) => {
770 const result = await next(e)
771 const m = typeof e.tool === 'string' ? TENANTREE_TOOL.exec(e.tool) : null
772 const tool = m?.[1]
773 if (!tool) return result
774 // The band and the tiles must never disturb the tool call itself.
775 try {
776 await remember($, result)
777 const text = textOf(result)
778 const failed = Boolean((result as { isError?: boolean } | null)?.isError)
779 if (failed && NEEDS_SIGNIN.test(text)) {
780 await signedOut($)
781 return result
782 }
783 if (!failed && (await read($, account)).status === 'signedOut') void refresh($).catch(() => {})
784 const at = await $.clock.now()
785 if (tool === 'tenantree_landscape_signin') {
786 const url = SIGNIN_LINK.exec(text)?.[0]
787 const t = await read($, target)
788 // A link while already signed in (a renewal) is no pending sign-in for the band.
789 const already = t.status === 'known' && t.target?.signedIn
790 if (url && !already) await update($, signin, () => ({ url, system: t.status === 'known' && t.target ? t.target.system : null, at }))
791 } else if (!failed && !tool.startsWith('tenantree_')) await update($, signin, () => null)
792 const id = e.tool_use_id
793 // THE DIAGRAM TILE (16.10.2026): the landscape drawn by the plugin after the reply.
794 if (tool === 'tenantree_diagram_read' && id && !failed) {
795 const d = diagramOf(text)
796 if (d.layers.length) {
797 await update($, diagrams, all => ({ ...all, [id]: d }))
798 await update($, turn, ids => [...ids, id])
799 }
800 }
801 // THE COST TILE (16.10.2026): the assessment as a tile after the reply.
802 if (tool === 'tenantree_cost_query' && id && !failed) {
803 await update($, costs, all => ({ ...all, [id]: costOf(text) }))
804 await update($, turn, ids => [...ids, id])
805 }
806 const link = tool === 'tenantree_plan_read' ? null : PLAN_LINK.exec(text)
807 if (link && link[1] && id) {
808 const tile: PlanTile = { ...planOf(text), planId: link[1], url: link[0], expiresAt: at + PLAN_MS, status: 'open' }
809 await update($, tiles, all => ({ ...all, [id]: tile }))
810 await update($, turn, ids => [...ids, id])
811 await follow($)
812 }
813 } catch {
814 // Nothing of the band is worth a failed tool call.
815 }
816 return result
817 })
818
819 // The tiles of a turn go under the text of its reply.
820 on('turn.complete', async ($, e, next) => {
821 const ids = await read($, turn).catch(() => [] as string[])
822 if (ids.length) {
823 // The codes, where Claude Code hands them to plugins; the model never reads this part.
824 try {
825 const messages = await $.session.messages()
826 const codes = new Map<string, string>()
827 for (const m of messages) for (const u of m.toolUses ?? []) {
828 const code = codeOf((u as { meta?: unknown }).meta)
829 if (code && ids.includes(u.tool_use_id)) codes.set(u.tool_use_id, code)
830 }
831 if (codes.size) await update($, tiles, all => Object.fromEntries(Object.entries(all).map(([k, v]) => [k, codes.has(k) ? { ...v, code: codes.get(k) } : v])))
832 } catch {
833 // No codes: the tile offers the approval page.
834 }
835 }
836 if (ids.length && e.answer) {
837 const tail = normal(e.answer).slice(-160)
838 await update($, attached, list => [...list.slice(-50), { tail, ids }]).catch(() => {})
839 await update($, turn, () => []).catch(() => {})
840 }
841 return next(e)
842 })
843
844 on('command.run', { command: 'tenantree' }, async ($, e) => {
845 const verb = String((e as { args?: unknown }).args ?? '').trim().toLowerCase().replace(/[^a-z]/g, '')
846 if (verb === 'signout' || verb === 'logout') {
847 await signOut($)
848 return { text: (await read($, message)) ?? '' }
849 }
850 if (verb === 'signin' || verb === 'login') {
851 await signIn($)
852 return { text: (await read($, message)) ?? 'Signed in to tenantree.' }
853 }
854 await refresh($).catch(() => {})
855 const state = await read($, target)
856 const acc = await read($, account)
857 if (acc.status === 'signedOut') return { text: `Not signed in to tenantree. ${SIGNIN_HELP}` }
858 if (state.status === 'unknown') return { text: 'tenantree is not connected in this session. Run /mcp, select the server tenantree and sign in.' }
859 const lines = [`Signed in to tenantree as ${state.account ?? 'unknown'}.`]
860 if (acc.status === 'signedIn' && acc.organizations.length) lines.push('', 'Organizations:', ...acc.organizations.map(o => ` ${o.name} (${o.role === 'admin' ? 'administrator' : 'user'})`))
861 lines.push('', 'Available subaccounts:')
862 for (const t of state.targets ?? []) lines.push(`${t.active ? '▶' : ' '} ${path(t)} (${KIND[t.kind]})`)
863 if (!state.targets?.length) lines.push(' none assigned')
864 return { text: lines.join('\n') }
865 })
866
867 // THE TILES AFTER THE REPLY (operator, 16.10.2026).
868 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
869 // On every surface (operator, 21.10.2026): tenantree announces no MCP Apps views any more; plan, cost and
870 // diagram are shown by this tile alone.
871 const list = await read($, attached)
872 if (!list.length) return next(e)
873 const text = normal(e.props.text)
874 const hit = list.find(a => a.tail && text.endsWith(a.tail))
875 if (!hit) return next(e)
876 const all = await read($, tiles)
877 const allCosts = await read($, costs)
878 const allDiagrams = await read($, diagrams)
879 const shownDiagrams = hit.ids.map(id => [id, allDiagrams[id]] as const).filter((x): x is readonly [string, DiagramTile] => Boolean(x[1]))
880 const shown = hit.ids.map(id => [id, all[id]] as const).filter((x): x is readonly [string, PlanTile] => Boolean(x[1]))
881 const shownCosts = hit.ids.map(id => [id, allCosts[id]] as const).filter((x): x is readonly [string, CostTile] => Boolean(x[1]))
882 if (!shown.length && !shownCosts.length && !shownDiagrams.length) return next(e)
883 const reply = await next(e)
884 const opened = await read($, expanded)
885 const els = $.ui.resolve(e)
886 const { Box, Text, Button, Link, Markdown } = els
887 const Svg = 'Svg' in els ? els.Svg : null
888 const now = await $.clock.now()
889 const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
890 const rows = (rs: Row[], width = 22) =>
891 rs.map(f => (
892 <Box key={f.label}>
893 <Box width={width}><Text dimColor>{f.label}</Text></Box>
894 <Text>{f.value}</Text>
895 </Box>
896 ))
897 return (
898 <Box flexDirection="column" gap={1}>
899 {reply}
900 {shownDiagrams.map(([id, d]) => {
901 const svg = diagramSvg(d)
902 return (
903 <Box key={id} flexDirection="column" borderStyle="round" paddingX={1} paddingY={1} gap={1}>
904 <Box justifyContent="space-between">
905 <Markdown text={`### ${d.title}`} />
906 {d.url ? <Link href={d.url} label="Open in the Tenantree Dashboard" /> : null}
907 </Box>
908 {Svg ? (
909 <Svg source={svg.source} alt={diagramAlt(d)} width={Math.min(svg.width, 1100)} />
910 ) : (
911 <Markdown text={d.layers.map(l => `**${l.name}**\n${l.nodes.map(n => `- ${n.name}${n.detail[0] ? ` — ${n.detail[0]}` : ''}`).join('\n')}`).join('\n\n')} />
912 )}
913 {d.findings.length ? <Markdown text={`**Findings**\n${d.findings.map(f => `- ${f}`).join('\n')}`} /> : <Text color="success">No findings.</Text>}
914 {d.notes.length ? <Markdown dimColor text={d.notes.map(n => `- ${n}`).join('\n')} /> : null}
915 </Box>
916 )
917 })}
918 {shownCosts.map(([id, c]) => (
919 <Box key={id} flexDirection="column" borderStyle="round" paddingX={1} paddingY={1} gap={1}>
920 <Box justifyContent="space-between">
921 <Markdown text="### Costs" />
922 {c.url ? <Link href={c.url} label="Open in the Tenantree Dashboard" /> : null}
923 </Box>
924 {c.risks.length ? (
925 c.risks.map(r => {
926 const [head, ...rest] = r.split(': ')
927 const days = /(\d+) days remaining/.exec(r)?.[1]
928 const ends = /ends on approximately (\d{4}-\d{2}-\d{2})/.exec(r)?.[1]
929 return (
930 <Box key={r} flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
931 <Box justifyContent="space-between">
932 <Text color="warning" bold>◆ Cost risk · {rest.length ? head : 'Account'}</Text>
933 {days ? <Text color="warning" bold>{days} days left{ends ? ` · until ${ends}` : ''}</Text> : null}
934 </Box>
935 <Text>{rest.length ? rest.join(': ') : r}</Text>
936 </Box>
937 )
938 })
939 ) : (
940 <Text color="success">◆ No cost risk found.</Text>
941 )}
942 {c.sections.map(sec => (
943 <Box key={sec.name} flexDirection="column">
944 <Markdown text={`#### ${sec.name}`} />
945 {sec.rows.length ? <Markdown text={`| Item | Value |\n|:--|:--|\n${sec.rows.map(r => `| ${cell(r.label)} | ${cell(r.value)} |`).join('\n')}`} /> : null}
946 {sec.lines.map(l => <Markdown key={l} dimColor text={l} />)}
947 </Box>
948 ))}
949 {c.unchecked.length ? (
950 <Box flexDirection="column">
951 <Markdown text="#### Not checked" />
952 <Markdown dimColor text={c.unchecked.map(u => `- ${u}`).join('\n')} />
953 </Box>
954 ) : null}
955 </Box>
956 ))}
957 {shown.map(([id, p]) => {
958 const expired = p.status === 'open' && now > p.expiresAt
959 const open = p.status === 'open' && !expired
960 const isOpen = Boolean(opened[id])
961 const decided =
962 p.status === 'decided'
963 ? `${p.decision === 'approve' ? (p.ok === false ? 'Approved, but not carried out' : 'Approved and carried out') : 'Rejected. Nothing was changed.'}${p.by ? ` by ${p.by}` : ''}${p.at ? ` at ${time(p.at)}` : ''}.`
964 : null
965 return (
966 <Box key={id} flexDirection="column" borderStyle="round" borderColor={p.status === 'open' && !expired ? 'suggestion' : undefined} paddingX={1}>
967 <Box justifyContent="space-between">
968 <Text bold>{p.title}</Text>
969 {p.level ? <Text dimColor>Write level: {LEVEL[p.level] ?? p.level}</Text> : null}
970 </Box>
971 {p.target ? <Text dimColor>Target: {p.target}</Text> : null}
972 {p.cost && (open || p.status === 'checking') ? (
973 <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="warning" paddingX={1}>
974 <Text color="warning" bold>{COST_TEXT.title}</Text>
975 <Text>{COST_TEXT.lead[p.cost.kind]}</Text>
976 {p.cost.text ? <Text dimColor>{p.cost.text}</Text> : null}
977 {p.code ? (
978 <Box marginTop={1}>
979 <Button
980 key={`cost-${id}`}
981 label={`${p.costAck ? '☑' : '☐'} ${COST_TEXT.confirm[p.cost.kind]}`}
982 onPress={() => void update($, tiles, all => { const cur = all[id]; return cur ? { ...all, [id]: { ...cur, costAck: !cur.costAck } } : all })}
983 />
984 </Box>
985 ) : null}
986 </Box>
987 ) : null}
988 {open && p.code && p.cost && !p.costAck ? (
989 <Box marginTop={1} gap={2} alignItems="center">
990 <Button key={`reject-${id}`} label={p.busy === 'reject' ? 'Rejecting …' : 'Reject'} onPress={() => void (p.busy ? null : decide($, id, 'reject'))} />
991 <Text dimColor>{COST_TEXT.required}</Text>
992 </Box>
993 ) : open && p.code ? (
994 <Box marginTop={1} gap={2} alignItems="center">
995 <Button key={`approve-${id}`} label={p.busy === 'approve' ? 'Carrying out …' : 'Approve'} variant="primary" onPress={() => void (p.busy ? null : decide($, id, 'approve'))} />
996 <Button key={`reject-${id}`} label={p.busy === 'reject' ? 'Rejecting …' : 'Reject'} onPress={() => void (p.busy ? null : decide($, id, 'reject'))} />
997 <Text dimColor>Valid until {time(p.expiresAt)}. Nothing is changed until you approve.</Text>
998 </Box>
999 ) : open ? (
1000 <Box marginTop={1} flexDirection="column">
1001 <Text dimColor>Valid until {time(p.expiresAt)}. Nothing is changed until you approve.</Text>
1002 <Text dimColor>Approve and reject in this tile need the plugin channel of Claude Code, which is not enabled yet; please decide on the approval page.</Text>
1003 </Box>
1004 ) : null}
1005 {expired ? <Text dimColor>Expired at {time(p.expiresAt)} unless decided before.</Text> : null}
1006 {decided ? <Text color={p.decision === 'approve' && p.ok !== false ? 'success' : p.ok === false ? 'error' : undefined} bold>{decided}</Text> : null}
1007 {p.handover ? <Link href={p.handover} label="Open the secure one-time link" /> : null}
1008 {p.status === 'closed' ? <Text dimColor>This plan is no longer open (expired or superseded).</Text> : null}
1009 {p.status === 'checking' ? <Text dimColor>Reading the status …</Text> : null}
1010 {p.status === 'unknown' ? <Text color="error">The status could not be read{p.error ? `: ${p.error}` : '.'}</Text> : null}
1011 {isOpen ? (
1012 <Box flexDirection="column" marginTop={1}>
1013 {p.facts.length ? <Box flexDirection="column">{rows(p.facts)}</Box> : null}
1014 {p.values.length ? <Box flexDirection="column" marginTop={1}><Text dimColor>Values</Text>{rows(p.values)}</Box> : null}
1015 {p.warnings.map(w => <Text key={w} color="warning">{w}</Text>)}
1016 </Box>
1017 ) : null}
1018 <Box marginTop={1} gap={2} alignItems="center">
1019 <Button key={`plan-${id}`} label={isOpen ? 'Hide plan' : 'Show plan'} onPress={() => void update($, expanded, all => ({ ...all, [id]: !isOpen }))} />
1020 {p.url ? <Link href={p.url} label="Open the approval page" /> : null}
1021 </Box>
1022 {decided && p.first ? <Text dimColor>{p.first}</Text> : null}
1023 </Box>
1024 )
1025 })}
1026 </Box>
1027 )
1028 })
1029
1030 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1031 if (e.props.hasSurvey) return next(e)
1032 const state = await read($, target)
1033 const now = await $.clock.now()
1034 const all = await read($, tiles)
1035 const open = Object.entries(all).filter(([, p]) => p.status === 'open' && now <= p.expiresAt)
1036 const loggedOut = await read($, logout)
1037 const logoutOpen = loggedOut && now - loggedOut.at < SIGNIN_MS ? loggedOut : null
1038 const pending = await read($, signin)
1039 const signinOpen = pending && now - pending.at < SIGNIN_MS ? pending : null
1040 const acc = await read($, account)
1041 void watchSignIn($).catch(() => {})
1042 // The picker needs the list of subaccounts; after a sign-in only the active target may be known.
1043 if (acc.status !== 'signedOut' && state.status === 'known' && !state.targets?.length) void loadTargets($).catch(() => {})
1044 if (state.status === 'unknown' && !open.length && !signinOpen && acc.status !== 'signedOut') return next(e)
1045 const els = $.ui.resolve(e)
1046 const { Box, Text, Link, Button } = els
1047 // The picker and the mark where the surface draws them (terminal: no Svg; mobile: no Select).
1048 const Select = 'Select' in els ? els.Select : null
1049 const Svg = 'Svg' in els ? els.Svg : null
1050 const t = state.status === 'known' ? state.target : null
1051 const targets = state.status === 'known' ? state.targets ?? [] : []
1052 const active = targets.find(x => x.active)
1053 const note = await read($, message)
1054 const choose = async (id: string) => {
1055 if (id === active?.id) return
1056 await update($, message, () => 'Switching the subaccount …')
1057 try {
1058 const r = await pressed($, 'tenantree_landscape_use', { systemId: id }, 'Switch subaccount')
1059 await remember($, r.text ?? r)
1060 await refresh($).catch(() => {})
1061 await update($, message, () => (r.isError ? 'The subaccount could not be switched.' : null))
1062 } catch (err) {
1063 await update($, message, () => (blockedByAuto(err) ? AUTO_BLOCKED : 'The subaccount could not be switched.'))
1064 }
1065 }
1066 const askSignin = async () => {
1067 try {
1068 const r = await pressed($, 'tenantree_landscape_signin', {}, 'Sign in')
1069 const url = SIGNIN_LINK.exec(r.text ?? textOf(r))?.[0]
1070 const at = await $.clock.now()
1071 if (url) await update($, signin, () => ({ url, system: t?.system ?? null, at }))
1072 } catch (err) {
1073 await update($, message, () => (blockedByAuto(err) ? AUTO_BLOCKED : 'The sign-in link could not be created.'))
1074 }
1075 }
1076 const isProduction = t?.kind === 'production'
1077 const picker =
1078 state.status === 'unknown' ? (
1079 <Text dimColor>Reading the subaccount …</Text>
1080 ) : Select && targets.length > 0 ? (
1081 // Without an active target the picker says so instead of showing the first entry as if it were chosen.
1082 <Select
1083 key="target"
1084 options={[...(active ? [] : [{ value: '', label: 'Choose a subaccount' }]), ...targets.map(x => ({ value: x.id, label: path(x) }))]}
1085 value={active?.id ?? ''}
1086 onSelect={(v: string) => void (v ? choose(v) : null)}
1087 />
1088 ) : (
1089 <Text bold>{t ? path(t) : 'No subaccount chosen'}</Text>
1090 )
1091 const notAdmitted = state.status === 'known' && t && (!t.signedIn || t.right === 'none')
1092 // The mark always opens the Tenantree Dashboard of the chosen subaccount (operator, 17.10.2026); the server's
1093 // address when it names one, else built from the chosen entry of the picker, else the portal.
1094 const dashboard = t?.dashboardUrl ?? (active ? `${PORTAL}/t/${active.id}/dashboard/` : `${PORTAL}/`)
1095 const email = acc.status === 'signedIn' ? acc.email : state.status === 'known' ? state.account ?? null : null
1096 // LAYOUT (operator, 17.10.2026, after his sketch): a header of two rows -- the subaccount left and the
1097 // mark right, below it the account left and Sign out right under the mark -- then one row per notice
1098 // (IAS sign-in, approvals, errors). Fixed widths for labels and the right column keep everything aligned.
1099 const ROW = { flexDirection: 'row' as const, alignItems: 'center' as const, gap: 2 }
1100 const LABEL = 14
1101 const RIGHT = 18
1102 return (
1103 <Box flexDirection="column" gap={1} paddingY={1} paddingX={1}>
1104 <Box {...ROW} justifyContent="space-between">
1105 <Box {...ROW}>
1106 {acc.status === 'signedOut' ? (
1107 <Text color="warning" bold>◆ Not signed in to tenantree</Text>
1108 ) : (
1109 <Box {...ROW}>
1110 {isProduction ? <Text color="error" bold>● Production</Text> : null}
1111 {picker}
1112 </Box>
1113 )}
1114 </Box>
1115 <Box width={RIGHT} justifyContent="flex-end">
1116 <Box {...ROW} gap={1}>
1117 {Svg ? <Svg source={MARK} alt="tenantree" width={16} height={16} /> : null}
1118 {/* The mark opens the Tenantree Dashboard of the subaccount in the browser (operator, 16.10.2026). */}
1119 <Link href={dashboard} label="tenantree" />
1120 </Box>
1121 </Box>
1122 </Box>
1123 <Box {...ROW} justifyContent="space-between">
1124 <Box {...ROW}>
1125 {acc.status === 'signedOut' ? (
1126 <Text dimColor>Sign in with /mcp → tenantree → Authenticate, then write: tenantree login</Text>
1127 ) : (
1128 <Box {...ROW} gap={0}>
1129 <Box width={LABEL}>
1130 <Text dimColor>Signed in as</Text>
1131 </Box>
1132 <Text>{email ?? 'Reading the account …'}</Text>
1133 </Box>
1134 )}
1135 </Box>
1136 <Box width={RIGHT} justifyContent="flex-end">
1137 {acc.status === 'signedOut' ? (
1138 <Button key="signin-tenantree" label="Sign in" variant="primary" onPress={() => void signIn($)} />
1139 ) : email ? (
1140 <Button key="signout" label="Sign out" onPress={() => void signOut($)} />
1141 ) : null}
1142 </Box>
1143 </Box>
1144 {note ? (
1145 <Box {...ROW}>
1146 <Text dimColor>{note}</Text>
1147 </Box>
1148 ) : null}
1149 {logoutOpen ? (
1150 <Box {...ROW}>
1151 <Text>Signed out of tenantree. End the IAS session in this browser as well:</Text>
1152 <Link href={logoutOpen.url} label="End IAS session" />
1153 <Text dimColor>valid until {time(logoutOpen.at + SIGNIN_MS)}, once</Text>
1154 </Box>
1155 ) : null}
1156 {signinOpen ? (
1157 <Box {...ROW}>
1158 <Text color="warning">◆ Sign-in required{signinOpen.system ? ` at the IAS of ${signinOpen.system}` : ''}:</Text>
1159 <Link href={signinOpen.url} label="Sign in" />
1160 <Text dimColor>valid until {time(signinOpen.at + SIGNIN_MS)}, once</Text>
1161 </Box>
1162 ) : notAdmitted ? (
1163 <Box {...ROW}>
1164 <Text color="warning">◆ Not signed in at the IAS of {t.system}.</Text>
1165 <Button key="signin" label="Sign in" onPress={() => void askSignin()} />
1166 </Box>
1167 ) : null}
1168 {open.length ? (
1169 <Box flexDirection="column" gap={1}>
1170 {open.map(([id, p]) => (
1171 <Box key={id} {...ROW}>
1172 <Text color="warning">◆ Approval pending:</Text>
1173 <Text bold>{p.title}</Text>
1174 <Text dimColor>until {time(p.expiresAt)}</Text>
1175 {p.url ? <Link href={p.url} label="Approve or reject" /> : null}
1176 </Box>
1177 ))}
1178 </Box>
1179 ) : null}
1180 </Box>
1181 )
1182 })
1183}
1184types/index.d.ts 105 lines1/** The active target as the tenantree server reports it with every tool answer. */
2export type ActiveTarget = {
3 organization: string
4 landscape: string
5 system: string
6 kind: 'production' | 'quality' | 'development' | 'other'
7 region: string
8 subaccountId: string | null
9 signedIn: boolean
10 right: 'none' | 'user' | 'admin'
11 dashboardUrl?: string
12}
13
14/** A system the person may choose (tenantree_landscape_query). */
15export type Target = {
16 id: string
17 organization: string
18 landscape: string
19 system: string
20 kind: ActiveTarget['kind']
21 active: boolean
22}
23
24/** unknown: not read yet or tenantree not connected; null target: connected, no active target. */
25export type TargetState =
26 | { status: 'unknown' }
27 | { status: 'known'; target: ActiveTarget | null; account?: string; targets?: Target[]; at: number }
28
29/** TEPLU-12: the tenantree account of this connection. */
30export type AccountState =
31 | { status: 'unknown' }
32 | { status: 'signedIn'; email: string; organizations: Array<{ name: string; role: 'admin' | 'user' }> }
33 | { status: 'signedOut' }
34
35/** One row of a plan: a label and its value. */
36export type Row = { label: string; value: string }
37
38/** A plan tile: read from the plan text of the tool answer (the plugin calls nothing for it). */
39export type PlanTile = {
40 planId: string
41 title: string
42 level: string | null
43 target: string | null
44 headline: string
45 facts: Row[]
46 values: Row[]
47 warnings: string[]
48 url: string | null
49 expiresAt: number
50 status: 'open' | 'decided' | 'closed' | 'checking' | 'unknown'
51 decision?: 'approve' | 'reject'
52 by?: string | null
53 ok?: boolean
54 at?: number
55 first?: string
56 error?: string
57 /** The approval code from _meta "claude/plugins", where Claude Code hands it over; never shown. */
58 code?: string
59 /** The cost notice of a plan with costs or unchecked costs (21.10.2026): approved only together with its confirmation. */
60 cost?: { kind: 'paid' | 'unclear'; text: string } | null
61 /** The person confirmed the cost notice in the tile. */
62 costAck?: boolean
63 handover?: string | null
64 busy?: 'approve' | 'reject'
65}
66
67/** One section of the cost tile: rows (label and value) and lines of text. */
68export type CostSection = { name: string; rows: Row[]; lines: string[] }
69
70/** The cost tile: read from the text of tenantree_cost_query (the plugin calls nothing for it). */
71export type CostTile = { sections: CostSection[]; risks: string[]; unchecked: string[]; url: string | null }
72
73/** The landscape diagram tile: read from the text of tenantree_diagram_read, drawn as SVG by the plugin. */
74export type DiagramNode = { name: string; detail: string[] }
75export type DiagramTile = {
76 title: string
77 layers: Array<{ name: string; nodes: DiagramNode[] }>
78 edges: Array<{ from: string; to: string; label: string | null }>
79 findings: string[]
80 notes: string[]
81 url: string | null
82}
83
84/** The tiles a reply shows after its text: the end of that text and the tiles' tool use ids. */
85export type Attached = { tail: string; ids: string[] }
86
87declare module 'claude-code' {
88 interface PluginState {
89 tenantree: {
90 target: TargetState
91 server: string | null
92 message: string | null
93 tiles: Record<string, PlanTile>
94 turn: string[]
95 attached: Attached[]
96 signin: { url: string; system: string | null; at: number } | null
97 logout: { url: string; at: number } | null
98 account: AccountState
99 expanded: Record<string, boolean>
100 costs: Record<string, CostTile>
101 diagrams: Record<string, DiagramTile>
102 }
103 }
104}
105