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

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.
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
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.repos.yaml for Odoo, addons.yaml for OCA and custom addon repos, pinned by branch or commit, with a symlink farm builder..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.requirements.txt, locked with uv and built with uv2nix, so the flake and your shell agree.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.queue_job.The full inventory, including every skill and agent, is in docs/features.md.
Copier asks about twenty questions; most have a sensible default derived from your Odoo version. These are the ones worth a thought:
| Question | Default | Why you might change it |
|---|---|---|
odoo_version | 19.0 | everything else follows from it |
custom_repo_pattern / custom_repo_name | empty | your own addons repo; unlocks the development pipeline |
use_claude_code | true | the whole .claude/ layer |
use_kb | false | memory in kb instead of markdown files |
service_suffix | empty | set it to run two generated projects on one machine |
project_dir_var | ODOO<major>_PROJECT_DIR | same reason, for the project-root variable |
status_mcp / tickets_mcp | none | wire status posting and ticket tracking to Teams and Odoo |
backup_s3_bucket | empty | restore production dumps locally |
prod_ssh_host / test_ssh_hosts | empty | the deploy and prod-ops runbooks |
Every question and its default: docs/questions.md.
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.
nix flake check shellchecks every generated script; python3 tests/test_nudge_find_code.py runs the hook regression corpusMIT
hooks/register.tsx 303 lines1import { 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}
303hooks/env.ts 108 lines1import 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}
108types/index.d.ts 37 lines1export 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