SLOPSHOPPER

crossloom-env

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…

newbandguardcommandtoastprocess
v0.2.0no licenseupdated 2026-10-07hanuele/crossloom-marketplace/plugins/crossloom-env
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crossloom-env
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cl-env ⎿ crossloom-env: unknown no protected environments configured ⟨Claude Code's own drawing⟩ CrossLoom env unknown (cl env show failed) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ CrossLoom env unknown (cl env show failed)
README

crossloom-env

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.

What it does

  • Reads 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.
  • Toasts when the environment changes while the session runs.
  • Before a crossloom MCP write verb (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.

Install

/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).

Configure

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" } } }

Develop

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.

Source 2 files
hooks/register.tsx 111 lines
1// 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}
111
types/index.d.ts 9 lines
1export 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