Shows the active CrossLoom environment (cl env show) under the prompt, toasts when it changes, and asks before a crossloom write verb runs on a protected…

Shows which CrossLoom environment your Claude Code session is pointed at, and asks before a write runs on an environment you have marked as protected.
The active environment is a machine-wide cl env use setting. Nothing in a session shows it, so a run_sql or a deploy can land on the wrong instance without anyone noticing. This plugin puts the answer under the prompt.
cl env show three seconds after the session starts and once a minute after that, and pins CrossLoom <system:stage> under the prompt. On a protected environment the line reads · PROTECTED.run_sql, deploy_*, patch_*, create_*, delete_*, revert_*, property updates, package apply, bootstrap rollout, translations, SmartUI, scheduler triggers, knowledge writes, changesets, cleanup) runs on a protected environment, asks once: Cancel denies the call, Continue lets it through. Read verbs never ask./cl-env prints the environment, its URL and whether it is protected.If the question cannot be asked (a claude -p run, a dismissed dialog) the call goes on. This is a confirmation for a person at the prompt, not a security gate.
/plugin install crossloom-env --marketplace hanuele/crossloom-marketplace
Needs the cl CLI on PATH (the crossloom-cli wheel) and a Claude Code build that loads plugin hooks modules (2.1.291 or later; earlier builds ignore the plugin).
In /config, under the plugin, set Protected environments to a comma-separated list of system:stage names, for example mwm:dev,wiam-3T:prod. Empty (the default) never asks; the status line still shows the environment.
The same value lives in ~/.claude/settings.json:
"pluginConfigs": { "crossloom-env@crossloom": { "options": { "protectedEnvs": "mwm:dev" } } }
claude plugin validate plugins/crossloom-env
claude plugin test plugins/crossloom-env
The tests run against the engine with a mocked cl env show and a mocked dialog; they need no CrossLoom instance.
hooks/register.tsx 111 lines1// crossloom-env — which CrossLoom environment is this session pointed at?
2//
3// The active environment is a machine-wide `cl env use` setting that nothing in
4// the session shows, so a write verb can run against the wrong instance
5// unnoticed. This mod reads `cl env show` at start and every minute, draws
6// `CrossLoom <system:stage>` in a calm band above the prompt, toasts when it
7// changes, and, on an environment listed in the `protectedEnvs` option, asks
8// once before a crossloom MCP write verb runs: Cancel denies the call. If the
9// question cannot be asked (a -p run, a dismissed dialog) the call goes on:
10// this is a confirmation, not a gate.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14import type { BandLine, Tone } from '../types'
15
16const POLL_MS = 60 * 1000
17const WRITE_VERB = /^(run_sql|deploy_|patch_|safe_property_update|create_|delete_|revert_|package_preview_then_apply|bootstrap_rollout|inject_|set_|smartui_init|fire_|toggle_|update_|write_|add_to_|changeset_add|restore_|cleanup_)/
18
19const line = atom({ plugin: 'crossloom-env', key: 'line' } as const, null)
20
21type Ctx = { env: string | null; url: string | null; lastShown: string | null; protectedEnvs: Set<string> }
22
23export function parseEnvShow(stdout: string): { env: string | null; url: string | null } {
24 const env = /^Environment:\s*(\S+)/m.exec(stdout)?.[1] ?? null
25 const url = /^URL:\s*(\S+)/m.exec(stdout)?.[1] ?? null
26 return { env, url }
27}
28
29/** the crossloom verb name from an MCP tool name, or null for any other tool */
30export function crossloomVerb(tool: string): string | null {
31 const m = /^mcp__(?:plugin_crossloom_)?crossloom__([a-z_]+)$/.exec(tool)
32 return m ? m[1] : null
33}
34
35export function parseProtected(value: unknown): Set<string> {
36 const s = typeof value === 'string' ? value : ''
37 return new Set(s.split(',').map(x => x.trim()).filter(Boolean))
38}
39
40function isProtected(ctx: Ctx): boolean {
41 return ctx.env !== null && ctx.protectedEnvs.has(ctx.env)
42}
43
44async function show($: EngineInterface, text: string, tone: Tone): Promise<void> {
45 try { await update($, line, () => ({ text, tone } as BandLine)) } catch { /* no state host (a test): nothing to draw */ }
46}
47
48async function refresh($: EngineInterface, ctx: Ctx): Promise<void> {
49 try {
50 const { exitCode, stdout } = await $.process.run(['cl', 'env', 'show'], { timeoutMs: 15_000 })
51 const parsed = exitCode === 0 ? parseEnvShow(stdout) : { env: null, url: null }
52 const changed = ctx.lastShown !== null && parsed.env !== ctx.lastShown
53 ctx.env = parsed.env
54 ctx.url = parsed.url
55 if (changed) $.ui.toast(`CrossLoom environment changed: ${ctx.lastShown} -> ${parsed.env ?? 'unknown'}`)
56 } catch {
57 ctx.env = null
58 ctx.url = null
59 }
60 ctx.lastShown = ctx.env
61 if (ctx.env === null) await show($, 'CrossLoom env unknown (cl env show failed)', 'attention')
62 else if (isProtected(ctx)) await show($, `CrossLoom ${ctx.env} · PROTECTED`, 'attention')
63 else await show($, `CrossLoom ${ctx.env}`, 'ok')
64}
65
66export const register: Register = (on, options) => {
67 const ctx: Ctx = { env: null, url: null, lastShown: null, protectedEnvs: parseProtected(options.protectedEnvs) }
68
69 on('session.start', async ($, e, next) => {
70 await $.command.register({ name: 'cl-env', description: 'Show the active CrossLoom environment this session is pointed at' })
71 $.clock.every(POLL_MS, () => { void refresh($, ctx) })
72 $.clock.after(3_000, () => { void refresh($, ctx) })
73 await show($, 'CrossLoom env: reading...', 'quiet')
74 return next(e)
75 })
76
77 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78 const current = await read($, line)
79 const rest = await next(e)
80 if (current === null) return rest
81 const { Box, Text } = $.ui.resolve(e)
82 const colour = current.tone === 'attention' ? 'yellow' : current.tone === 'ok' ? 'green' : undefined
83 return (
84 <Box flexDirection="column">
85 {rest}
86 <Text color={colour} dimColor={current.tone === 'quiet'}>{current.text}</Text>
87 </Box>
88 )
89 })
90
91 on('command.run', { command: 'cl-env' }, async ($) => {
92 await refresh($, ctx)
93 const mark = isProtected(ctx) ? 'PROTECTED (write verbs ask first)' : ctx.protectedEnvs.size ? 'not protected' : 'no protected environments configured'
94 return { text: `${ctx.env ?? 'unknown'} ${ctx.url ?? ''} ${mark}` }
95 })
96
97 // confirmation before a crossloom write verb on a protected environment
98 on('tool.call', async ($, e, next) => {
99 const verb = crossloomVerb(e.tool)
100 if (verb === null || !WRITE_VERB.test(verb)) return next(e)
101 if (ctx.lastShown === null) await refresh($, ctx)
102 if (!isProtected(ctx)) return next(e)
103 const answer = await $.ui.ask(
104 `crossloom ${verb} would run on ${ctx.env}, a protected environment. Continue?`,
105 ['Cancel', `Continue on ${ctx.env}`],
106 )
107 if (answer === 'Cancel') return { deny: `crossloom-env: ${verb} cancelled on ${ctx.env} (protected).` }
108 return next(e)
109 }).catch(($, e, next) => next(e))
110}
111types/index.d.ts 9 lines1export type Tone = 'quiet' | 'ok' | 'attention'
2export type BandLine = { text: string; tone: Tone }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'crossloom-env': { line: BandLine | null }
7 }
8}
9