SLOPSHOPPER

odoo-env-band

A row above the prompt: the addons checkout's branch and dirty state, the local Odoo service, the worktree envs and their ports

newbandguardtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · odoo-env-band
› 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 ⟨Claude Code's own drawing⟩ env ? ✓ clean · odoo : ○ pg : ○ nginx : ○ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ env ? ✓ clean · odoo : ○ pg : ○ nginx : ○
README

nixodoo-copier-template

A Copier template that generates a complete Odoo development environment on Nix flakes: native processes, systemd user services, a project-local PostgreSQL cluster, and a Python environment locked with uv.

No container, no global install, no pip in sight. The same generated project also runs as a test or production server.

Quickstart

uvx copier copy --trust gh:okolovmark/nixodoo-copier-template my-odoo-project
cd my-odoo-project

nix run .#create-env          # .env: ports, database credentials
nix run .#update-repos        # clone Odoo and the addon repos, build the symlink farm
nix run .#bootstrap-deps      # import Odoo's requirements.txt into uv.lock
nix profile add .#dev-server  # odoo, psql, ruff and friends into your profile
nix run .#setup-dev           # postgres cluster, odoo.conf, nginx, systemd units
systemctl --user enable --now postgres.service odoo.service nginx.service odoo-logrotate.timer

Odoo is then on the port the template derived from your version, with its own PostgreSQL, its own addons farm and a log rotating daily. The generated README.md walks through the test and production flows the same way.

You need Nix with flakes enabled, uv (uvx runs copier without installing it), and a systemd user session. Starting from nothing:

curl -LsSf https://astral.sh/uv/install.sh | sh
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
sudo systemctl restart nix-daemon

What it generates

  • Odoo 16.0 to 19.0 on Python 3.10 to 3.14 and PostgreSQL 13 to 18, chosen at generation time and verified end to end for every version.
  • Three toolchains as flake packages: dev-server, test-server, prod-server, the first two wrapping odoo and the Postgres client tools against this project's cluster; prod-server carries Odoo alone - no proxy, no database client.
  • Pinned sources: repos.yaml for Odoo, addons.yaml for OCA and custom addon repos, pinned by branch or commit, with a symlink farm builder.
  • Generated configuration: .env, odoo.conf with a random master password, systemd user units, a log-rotation timer, and an nginx reverse proxy for dev and test - production is left to whatever already fronts it.
  • Python dependencies straight from Odoo's own requirements.txt, locked with uv and built with uv2nix, so the flake and your shell agree.
  • Claude Code integration, optional: CLAUDE.md, guard hooks, Odoo skills including semantic code navigation over the official language server, a requirements interview, a development pipeline, deploy and production-operations runbooks, and a persistent memory.
  • Optional extras, each behind a question: a custom addons repo, S3 backup restore with native neutralization, SSH helpers for the prod and test boxes, OCA queue_job.

The full inventory, including every skill and agent, is in docs/features.md.

The choices that matter

Copier asks about twenty questions; most have a sensible default derived from your Odoo version. These are the ones worth a thought:

QuestionDefaultWhy you might change it
odoo_version19.0everything else follows from it
custom_repo_pattern / custom_repo_nameemptyyour own addons repo; unlocks the development pipeline
use_claude_codetruethe whole .claude/ layer
use_kbfalsememory in kb instead of markdown files
service_suffixemptyset it to run two generated projects on one machine
project_dir_varODOO<major>_PROJECT_DIRsame reason, for the project-root variable
status_mcp / tickets_mcpnonewire status posting and ticket tracking to Teams and Odoo
backup_s3_bucketemptyrestore production dumps locally
prod_ssh_host / test_ssh_hostsemptythe deploy and prod-ops runbooks

Every question and its default: docs/questions.md.

Keeping a project current

uvx copier update --trust

Template improvements land in the generated project. A few files are yours and are never overwritten (odools.toml, the estimate calibration table); for the rest, resolve conflicts in favour of the project where you have edited it. Hook arrays in .claude/settings.json deserve a second look after an update: copier can add a second key with the same name, and JSON keeps only the last one.

More

  • docs/features.md — everything a generated project contains
  • docs/questions.md — every question copier asks
  • docs/notes.md — gotchas worth knowing before they bite
  • nix flake check shellchecks every generated script; python3 tests/test_nudge_find_code.py runs the hook regression corpus

License

MIT

Source 3 files
hooks/register.tsx 303 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { EnvSnapshot, GitStatus, Service, Touched, WtEnv } from '../types'
5import {
6  isSameTouched,
7  mergeTouched,
8  parseActive,
9  parseGitStatus,
10  touchedBy,
11  worktreeSlug,
12  wtDb,
13} from './env'
14import type { Where } from './env'
15import { ADDONS, ADDONS_BRANCH, ANSWERS_FILE, DB_NAME, ENV_BRANCHES, PORTS, SERVICE_SUFFIX } from './config'
16
17const REFRESH_MS = 20_000
18const SERVICES = [
19  { name: 'odoo', unit: `odoo${SERVICE_SUFFIX}.service`, port: PORTS.odoo },
20  { name: 'pg', unit: `postgres${SERVICE_SUFFIX}.service`, port: PORTS.pg },
21  { name: 'nginx', unit: `nginx${SERVICE_SUFFIX}.service`, port: PORTS.nginx },
22] as const
23
24type Tip = 'env-env' | 'env-addons' | 'env-branch' | 'env-dirty' | 'env-sync' | 'env-services' | 'env-others' | `env-wt-${number}`
25
26// what each segment means; shown above the row while the pointer is on the segment
27const TIPS: { scope: Tip; text: string }[] = [
28  {
29    scope: 'env-env',
30    text: 'The env repo itself (this project: flake, .claude, docs/adr, scripts): shown because this session edited files in it or ran a changing git command there.',
31  },
32  {
33    scope: 'env-addons',
34    text: `The main checkout of the custom modules, ${ADDONS}: shown because this session edited files or ran git in it.`,
35  },
36  {
37    scope: 'env-branch',
38    text: `The checkout's branch: plain on its default (env ${ENV_BRANCHES.join('/')}, addons ${ADDONS_BRANCH}), cyan on any other; "(local)" = no upstream yet.`,
39  },
40  { scope: 'env-dirty', text: 'Working tree: ✓ clean, or ±N files changed, staged or untracked (git status).' },
41  {
42    scope: 'env-sync',
43    text: '↓N behind: commits on the upstream branch not pulled yet. ↑N ahead: local commits not pushed. As of the last fetch.',
44  },
45  {
46    scope: 'env-services',
47    text: `The local dev stack (systemctl --user): ${SERVICES[0].unit} on :${PORTS.odoo} (db ${DB_NAME}), ${SERVICES[1].unit} on :${PORTS.pg} (${DB_NAME} and every wt_* db), ${SERVICES[2].unit} proxy on :${PORTS.nginx}. ● up, ○ down.`,
48  },
49]
50
51const snapshot = atom({ plugin: 'odoo-env-band', key: 'snapshot' } as const, null)
52const touched = atom({ plugin: 'odoo-env-band', key: 'touched' } as const, { env: false, addons: false, wts: [] })
53
54let project: string | null = null
55
56// the project root: the nearest folder up from the session's root holding the copier answers file
57async function projectDir($: EngineInterface): Promise<string> {
58  if (project !== null) return project
59  const root = await $.session.root()
60  for (let dir = root; dir !== ''; dir = dir.slice(0, dir.lastIndexOf('/'))) {
61    if (await $.fs.exists(`${dir}/${ANSWERS_FILE}`)) return (project = dir)
62  }
63  return (project = root)
64}
65
66async function gitStatus($: EngineInterface, dir: string): Promise<GitStatus | null> {
67  const run = await $.process.run(['git', '-C', dir, 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 })
68  return run.exitCode === 0 ? parseGitStatus(run.stdout) : null
69}
70
71async function collect($: EngineInterface): Promise<EnvSnapshot> {
72  const project = await projectDir($)
73  const cwd = await $.session.cwd()
74  const here = worktreeSlug(cwd, project)
75  // the cwd counts for addons and a worktree (going there is deliberate), never for the env repo it starts in
76  const mine = mergeTouched(await read($, touched), {
77    env: false,
78    addons: `${cwd}/`.startsWith(`${project}/${ADDONS}/`),
79    wts: here === null ? [] : [here],
80  })
81
82  const envRoot = `${project}/.worktrees/_env`
83  const slugs = (await $.fs.exists(envRoot))
84    ? (await $.fs.list(envRoot)).filter(entry => entry.kind === 'dir').map(entry => entry.name).sort()
85    : []
86  const ports = await Promise.all(
87    slugs.map(slug => $.fs.read(`${envRoot}/${slug}/port`).then(text => text.trim(), () => '?')),
88  )
89  const wtUnit = (slug: string) => `odoo${SERVICE_SUFFIX}-wt@${slug}.service`
90  const units = [...SERVICES.map(service => service.unit), ...slugs.map(wtUnit)]
91  const systemctl = await $.process.run(['systemctl', '--user', 'is-active', ...units], { timeoutMs: 5000 })
92  const active = parseActive(systemctl.stdout, units)
93
94  const wts: WtEnv[] = await Promise.all(
95    slugs.map(async (slug, i) => {
96      const isMine = mine.wts.includes(slug)
97      return {
98        slug,
99        port: ports[i] ?? '?',
100        db: wtDb(slug),
101        isActive: active[wtUnit(slug)] === true,
102        isMine,
103        git: isMine ? await gitStatus($, `${project}/.worktrees/${slug}`) : null,
104      }
105    }),
106  )
107  const services: Service[] = SERVICES.map(service => ({
108    name: service.name,
109    port: service.port,
110    isActive: active[service.unit] === true,
111  }))
112  return {
113    env: mine.env ? { git: await gitStatus($, project) } : null,
114    addons: mine.addons ? { git: await gitStatus($, `${project}/${ADDONS}`) } : null,
115    services,
116    wts,
117  }
118}
119
120let running: Promise<void> | null = null
121
122// one refresh at a time; a call while one runs joins it
123function refresh($: EngineInterface): Promise<void> {
124  running ??= collect($)
125    .then(next => update($, snapshot, () => next))
126    .then(
127      () => undefined,
128      error => $.ui.log(`odoo-env-band: ${String(error)}`, { to: 'debug' }),
129    )
130    .finally(() => {
131      running = null
132    })
133  return running
134}
135
136async function where($: EngineInterface): Promise<Where> {
137  return { project: await projectDir($), cwd: await $.session.cwd(), addons: ADDONS }
138}
139
140// Recomputed from the transcripts, the main thread's and each subagent's, on every load: what this
141// session works in is a fact of its tool calls, so a value an earlier version of these rules kept is
142// replaced, never merged in.
143async function rescan($: EngineInterface): Promise<void> {
144  const at = await where($)
145  const uses = (await $.session.messages()).flatMap(message => message.toolUses)
146  for (const agent of await $.agent.list()) {
147    const found = await $.session.messages({ agentId: agent.id })
148    if (Array.isArray(found)) uses.push(...found.flatMap(message => message.toolUses))
149  }
150  const found = uses
151    .filter(use => use.isError !== true)
152    .reduce<Touched>((all, use) => mergeTouched(all, touchedBy(String(use.tool), use.input, at)), {
153      env: false,
154      addons: false,
155      wts: [],
156    })
157  await update($, touched, () => found)
158}
159
160export const register: Register = on => {
161  on('session.start', async ($, e, next) => {
162    const started = await next(e)
163    await rescan($)
164    void refresh($)
165    $.clock.every(REFRESH_MS, () => void refresh($))
166    return started
167  })
168
169  // a checkout this session starts working in shows at once; so does a git command or a service restart
170  on('tool.call', async ($, e, next) => {
171    const ran = await next(e)
172    if (ran.deny !== undefined || ran.isError === true) return ran
173    const tool = String(e.tool)
174    const found = touchedBy(tool, e as unknown as Record<string, unknown>, await where($))
175    const before = await read($, touched)
176    const after = mergeTouched(before, found)
177    if (!isSameTouched(before, after)) await update($, touched, () => after)
178    if (tool === 'Bash' || !isSameTouched(before, after)) void refresh($)
179    return ran
180  })
181
182  on('turn.complete', async ($, e, next) => {
183    void refresh($)
184    return next(e)
185  })
186
187  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
188    const below = await next(e)
189    const env = await read($, snapshot)
190    if (env === null || e.props.hasSurvey) return below
191
192    const { Box, Text } = $.ui.resolve(e)
193    const mine = env.wts.filter(wt => wt.isMine)
194    const others = env.wts.filter(wt => !wt.isMine)
195    // a segment joins its tip's hover group: pointing at it underlines it and reveals the tip row
196    const hint = (scope: Tip) => ({ scope, underline: true })
197
198    const gitState = (git: GitStatus | null, defaults: readonly string[], isWorktree: boolean) =>
199      git === null ? (
200        <Text color="red">no git</Text>
201      ) : (
202        <Box flexDirection="row" columnGap={1}>
203          <Text bold color={defaults.includes(git.branch) ? undefined : 'cyan'} hover={hint('env-branch')}>
204            {git.hasUpstream || defaults.includes(git.branch) || isWorktree
205              ? git.branch
206              : `${git.branch} (local)`}
207          </Text>
208          {git.dirty > 0 ? (
209            <Text color="yellow" hover={hint('env-dirty')}>{`±${git.dirty} changed`}</Text>
210          ) : (
211            <Text color="green" hover={hint('env-dirty')}>
212              ✓ clean
213            </Text>
214          )}
215          {git.ahead > 0 && <Text dimColor hover={hint('env-sync')}>{`↑${git.ahead} ahead`}</Text>}
216          {git.behind > 0 && <Text color="yellow" hover={hint('env-sync')}>{`↓${git.behind} behind`}</Text>}
217        </Box>
218      )
219
220    const dynamicTips: { scope: Tip; text: string }[] = [
221      ...mine.map((wt, i) => ({
222        scope: `env-wt-${i}` as const,
223        text: `Worktree env this session works in: .worktrees/${wt.slug}, Odoo odoo${SERVICE_SUFFIX}-wt@${wt.slug} on :${wt.port} (${wt.isActive ? 'up' : 'down'}), db ${wt.db}.`,
224      })),
225      ...(others.length === 0
226        ? []
227        : [
228            {
229              scope: 'env-others' as const,
230              text: `Worktree envs of other sessions on this machine: ${others
231                .map(wt => `${wt.slug} :${wt.port} ${wt.isActive ? 'up' : 'down'}`)
232                .join(', ')}.`,
233            },
234          ]),
235    ]
236
237    // a tip only for a segment that is drawn
238    const hasCheckout = env.env !== null || env.addons !== null || mine.length > 0
239    const isTipShown = (scope: Tip) =>
240      scope === 'env-services' ||
241      (scope === 'env-env' ? env.env !== null : scope === 'env-addons' ? env.addons !== null : hasCheckout)
242
243    const row = (
244      <Box key="odoo-env" flexDirection="row" columnGap={1} flexWrap="wrap">
245        {env.env !== null && (
246          <Box flexDirection="row" columnGap={1}>
247            <Text dimColor hover={hint('env-env')}>
248              env
249            </Text>
250            {gitState(env.env.git, ENV_BRANCHES, false)}
251            <Text dimColor>·</Text>
252          </Box>
253        )}
254        {env.addons !== null && (
255          <Box flexDirection="row" columnGap={1}>
256            <Text dimColor hover={hint('env-addons')}>
257              addons
258            </Text>
259            {gitState(env.addons.git, [ADDONS_BRANCH], false)}
260            <Text dimColor>·</Text>
261          </Box>
262        )}
263        {mine.map((wt, i) => (
264          <Box flexDirection="row" columnGap={1}>
265            <Text dimColor hover={hint(`env-wt-${i}`)}>
266              wt
267            </Text>
268            {gitState(wt.git, [ADDONS_BRANCH], true)}
269            <Text color={wt.isActive ? 'green' : undefined} dimColor={!wt.isActive} hover={hint(`env-wt-${i}`)}>
270              {`:${wt.port} ${wt.isActive ? '● up' : '○ down'}`}
271            </Text>
272            <Text dimColor>·</Text>
273          </Box>
274        ))}
275        {env.services.map(service => (
276          <Text color={service.isActive ? 'green' : 'red'} hover={hint('env-services')}>
277            {`${service.name} :${service.port} ${service.isActive ? '●' : '○'}`}
278          </Text>
279        ))}
280        {others.length > 0 && <Text dimColor>·</Text>}
281        {others.length > 0 && (
282          <Text dimColor hover={hint('env-others')}>
283            {`${others.length} other wt env${others.length === 1 ? '' : 's'}`}
284          </Text>
285        )}
286      </Box>
287    )
288    return (
289      <Box flexDirection="column">
290        {/* every band plugin draws its tips before the plugins beneath it and its rows after them: all tips sit on top */}
291        {[...TIPS.filter(tip => isTipShown(tip.scope)), ...dynamicTips].map(tip => (
292          <Box key={`tip-${tip.scope}`} display="none" hover={{ scope: tip.scope, display: 'flex' }} flexDirection="row" columnGap={1}>
293            <Text color="cyan">ⓘ</Text>
294            <Text dimColor>{tip.text}</Text>
295          </Box>
296        ))}
297        {below}
298        {row}
299      </Box>
300    )
301  })
302}
303
hooks/env.ts 108 lines
1import type { GitStatus, Touched } from '../types'
2
3// `git status --porcelain=v2 --branch`: header lines start with '#', every other line is one changed path
4export function parseGitStatus(out: string): GitStatus {
5  let branch = '?'
6  let oid = ''
7  let ahead = 0
8  let behind = 0
9  let hasUpstream = false
10  let dirty = 0
11  for (const line of out.split('\n')) {
12    if (line.startsWith('# branch.head ')) branch = line.slice('# branch.head '.length).trim()
13    else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length).trim()
14    else if (line.startsWith('# branch.upstream ')) hasUpstream = true
15    else if (line.startsWith('# branch.ab ')) {
16      const ab = /\+(\d+) -(\d+)/.exec(line)
17      if (ab) {
18        ahead = Number(ab[1])
19        behind = Number(ab[2])
20      }
21    } else if (line !== '' && !line.startsWith('#')) dirty += 1
22  }
23  if (branch === '(detached)') branch = oid.slice(0, 8) || 'detached'
24  return { branch, dirty, ahead, behind, hasUpstream }
25}
26
27// the worktree env slug when cwd is inside <project>/.worktrees/<slug>/
28export function worktreeSlug(cwd: string, project: string): string | null {
29  const root = `${project.replace(/\/+$/, '')}/.worktrees/`
30  if (!`${cwd}/`.startsWith(root)) return null
31  const slug = `${cwd}/`.slice(root.length).split('/')[0] ?? ''
32  return slug !== '' && slug !== '_env' ? slug : null
33}
34
35// `systemctl --user is-active a b c` prints one state per unit, in argument order
36export function parseActive(out: string, units: readonly string[]): Record<string, boolean> {
37  const states = out.split('\n').map(line => line.trim())
38  return Object.fromEntries(units.map((unit, i) => [unit, states[i] === 'active']))
39}
40
41// worktree-env's db_name_for: slug -> safe pg identifier
42export function wtDb(slug: string): string {
43  return `wt_${slug.replace(/-/g, '_')}`
44}
45
46const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
47const WT_PATH = /\.worktrees\/([A-Za-z0-9][A-Za-z0-9._-]*)/g
48const WT_SCRIPT = /\bwt-(?:start|stop|bootstrap|restore|link)\.sh\s+([A-Za-z0-9][A-Za-z0-9._-]*)/g
49// git that changes a checkout, with the -C options before the verb; status, log, diff and worktree list only look
50const GIT_WRITE = /\bgit\b((?:\s+-[Cc]\s+\S+)*)\s+(?:commit|checkout|switch|pull|push|merge|rebase|reset|stash|cherry-pick|revert|am|apply|add|rm|mv|restore)\b/g
51
52export type Where = { project: string; cwd: string; addons: string }
53
54const NONE: Touched = { env: false, addons: false, wts: [] }
55
56function slugsIn(text: string): string[] {
57  return [...text.matchAll(WT_PATH), ...text.matchAll(WT_SCRIPT)]
58    .map(match => match[1] ?? '')
59    .filter(slug => slug !== '' && slug !== '_env')
60}
61
62// an absolute path: the env repo (anything under the project but src/ and .worktrees/), addons, a worktree
63function classify(path: string, where: Where): Touched {
64  const project = `${where.project.replace(/\/+$/, '')}/`
65  const target = `${path.replace(/\/+$/, '')}/`
66  if (!target.startsWith(project)) return NONE
67  const wts = slugsIn(target)
68  if (wts.length > 0) return { env: false, addons: false, wts }
69  if (target.startsWith(`${project}${where.addons}/`)) return { env: false, addons: true, wts: [] }
70  if (target.startsWith(`${project}src/`) || target.startsWith(`${project}.worktrees/`)) return NONE
71  return { env: true, addons: false, wts: [] }
72}
73
74// a `git -C` argument as written: quotes off, $ODOO16_PROJECT_DIR and relative paths resolved
75function resolvePath(raw: string, where: Where): string {
76  const bare = raw.replace(/^["']|["']$/g, '').replace(/^\$\{?ODOO16_PROJECT_DIR\}?/, where.project)
77  return bare.startsWith('/') ? bare : `${where.cwd.replace(/\/+$/, '')}/${bare}`
78}
79
80// Which checkouts one tool call works in. An edit counts, a Bash command that names a worktree or runs a
81// wt-*.sh script on it counts, and so does a changing git command, in its -C directory or the session's
82// cwd. A read, a grep or a `git status` does not.
83export function touchedBy(tool: string, input: Record<string, unknown>, where: Where): Touched {
84  if (FILE_TOOLS.has(tool)) {
85    const path = typeof input.file_path === 'string' ? input.file_path : input.notebook_path
86    return typeof path === 'string' ? classify(path, where) : NONE
87  }
88  if (tool !== 'Bash' || typeof input.command !== 'string') return NONE
89  let found: Touched = { env: false, addons: false, wts: slugsIn(input.command) }
90  for (const match of input.command.matchAll(GIT_WRITE)) {
91    const dir = /-C\s+(\S+)/.exec(match[1] ?? '')?.[1]
92    found = mergeTouched(found, classify(dir === undefined ? where.cwd : resolvePath(dir, where), where))
93  }
94  return found
95}
96
97export function mergeTouched(a: Touched, b: Touched): Touched {
98  return {
99    env: a.env || b.env,
100    addons: a.addons || b.addons,
101    wts: [...a.wts, ...b.wts.filter(slug => !a.wts.includes(slug))],
102  }
103}
104
105export function isSameTouched(a: Touched, b: Touched): boolean {
106  return a.env === b.env && a.addons === b.addons && a.wts.length === b.wts.length && a.wts.every(slug => b.wts.includes(slug))
107}
108
types/index.d.ts 37 lines
1export type GitStatus = {
2  branch: string
3  dirty: number
4  ahead: number
5  behind: number
6  hasUpstream: boolean
7}
8
9// the checkouts this session works in: by its cwd, its edits and its git / wt-*.sh commands
10export type Touched = { env: boolean; addons: boolean; wts: string[] }
11
12export type Service = { name: string; port: string; isActive: boolean }
13
14export type WtEnv = {
15  slug: string
16  port: string
17  db: string
18  isActive: boolean
19  // this session works in it; only these are drawn, the rest are a count
20  isMine: boolean
21  git: GitStatus | null
22}
23
24export type EnvSnapshot = {
25  // the env repo (the project itself) and the main addons checkout, when this session works in them
26  env: { git: GitStatus | null } | null
27  addons: { git: GitStatus | null } | null
28  services: Service[]
29  wts: WtEnv[]
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'odoo-env-band': { snapshot: EnvSnapshot | null; touched: Touched }
35  }
36}
37