Domaine frontend team plugin for Claude Code: the Shopify theme skills (ship, preview theme, steps to test, TA, breaking changes, translations, PR), the…

fe is the Domaine frontend team plugin for Claude Code. It holds the Shopify theme work: the skills that take a ticket from a Technical Approach to a pull request and a preview theme, the steps to test, the breaking-changes and translation tools, the fe:theme-explorer agent, the project profile (Foundation, another theme, or none) and the store-access rules.
fe builds on base and requires it: the Jira, Figma and doc readers, the Jira writer, the review agents, the task workspace, the commit and review skills, the guards and the shared MCP servers are base's (plugins/base/README.md). base requires slim, so fe runs with slim too.
Current release: fe v0.1.1.
scripts/install.sh --plugin fe exits 2."dependencies": ["base"] in its manifest), and slim through base. The engine does not install a dependency on its own: install all three./plugin marketplace add domaine-oleksandr-kever/claude-plugins
/plugin install slim@domaine
/plugin install band@domaine
/plugin install base@domaine
/plugin install fe@domaine
/reload-plugins
/base-doctor and /fe-doctor then check the install (§ Doctor). The same set as settings, in ~/.claude/settings.json:
{
"enabledPlugins": {
"slim@domaine": true,
"band@domaine": true,
"base@domaine": true,
"fe@domaine": true,
"fnd@domaine": false
}
}
The team plugins fe, qa, be and pm co-install: each needs only base, and none needs another.
To move from fnd, run /plugin uninstall fnd@domaine, install the set above, and rename every FND_ key fe reads to its FE_ name (FND_PROFILE, FND_GQL_PROBE_CACHE, FND_CPT_THROTTLE_WAITS, FND_CPT_OVERLAY_VERIFY, FND_CPT_OVERLAY_VERIFY_WAIT, FND_THEME_JSON_VERIFY, FND_THEME_JSON_VERIFY_WAIT) in .claude/domaine.env, ~/.config/domaine/env and ~/.claude/settings.json → env: fe does not read the old keys.
Invoked by their qualified names (/fe:<skill>). They hand off to base's skills and agents by base's qualified names (/base:commit, /base:pre-commit-review, base:jira-reader, …). A hand-off is an offer at the end of the run, never an automatic start; only /fe:ship runs the series by itself.
| Skill | Does | Uses / hands off to |
|---|---|---|
/fe:ship | a ready ticket end to end: one interview, one plan and QA-checklist approval, then the series runs itself (implement, QA, review and commit, PR, Steps to Test) | base:jira-reader, base:figma-reader, base:doc-reader, fe:theme-explorer; offers /base:worktree before it starts; a missing TA → /fe:write-technical-approach |
/fe:write-technical-approach | drafts a Technical Approach from the ticket's Description and Acceptance Criteria, then writes the field after approval | base:jira-reader, base:doc-reader, base:jira-writer → /fe:develop-feature-or-fix |
/fe:develop-feature-or-fix | implements an approved Technical Approach with in-browser validation | base:jira-reader, base:figma-reader, base:doc-reader, fe:theme-explorer, base:bug-hunter → /fe:qa-feature-or-fix |
/fe:qa-feature-or-fix | structured QA of a finished change against its ticket: checklist, browser checks, pass / fail report | base:jira-reader, base:figma-reader, base:jira-writer → /base:pre-commit-review (with the profile word) once every blocking check passes |
/fe:write-steps-to-test | Steps to Test in Domaine's format, written to the field after approval | base:jira-reader, base:doc-reader, base:jira-writer → /fe:create-pull-request when the branch has no PR |
/fe:create-pull-request | a pull request with the Domaine description and the theme-preview table | base:jira-reader, base:change-reviewer, base:bug-hunter, /fe:preview-theme's script → /fe:write-steps-to-test while the field is empty |
/fe:preview-theme | creates or refreshes an unpublished preview theme from the branch; in a new worktree, un-pins the copied store config first | scripts/create-preview-theme.sh, scripts/worktree-theme.sh |
/fe:preflight-checks | checks the project, the tools and the dev server before work starts | → /fe:write-technical-approach or /fe:develop-feature-or-fix |
/fe:fix-accessibility-issue | fixes an accessibility issue in theme components | → /base:commit |
/fe:get-breaking-changes | lists the breaking changes merged since the last major version in breaking-changes.md | → /fe:fix-breaking-changes |
/fe:fix-breaking-changes | applies the documented breaking changes to templates and settings data | its bundled script template; reads /fe:get-breaking-changes's report |
/fe:update-translations | translates storefront and schema strings into the theme's other languages | the project's own update-translations.js (fe ships none) |
The progress rows these skills tick in a ticket's progress.md are fe's progress-series section (§ Conventions).
A worktree: /base:worktree makes the checkout, copies .env and shopify.theme.toml (fe's worktree copy list: line) and records a dev port; then /fe:preview-theme in the worktree runs scripts/worktree-theme.sh, which un-pins the copied config once (a worktree never inherits the main checkout's session theme) and prints the dev-server line with that port. On the main checkout the script answers error=not_a_linked_worktree and the skill goes on without it. /fe:ship offers the same two steps before it starts.
fnd's smoke-test skill has no fe copy: /base-doctor and /fe-doctor check an install. The qa-preflight skill moves to the qa plugin (/qa:preflight).
| Agent | Does |
|---|---|
fe:theme-explorer | read-only scout of the theme: which files, sections and snippets a change touches; the profile comes in its brief |
The skills and the agent cite fe's own files by their path under fe's root (<fe root>/…, the session's fe plugin root: line) and base's by their path under base's root (<base root>/…); tests/team-refs-lint.sh checks that every cited path exists.
| Reference | Read by | Holds |
|---|---|---|
references/session-theme.md | /fe:ship, /fe:preview-theme, /fe:create-pull-request | one preview theme per work stream: the gate, the pin into shopify.theme.toml, the worktree un-pin |
references/preview-theme-errors.md | /fe:preview-theme, /fe:create-pull-request, /fe:ship | create-preview-theme.sh's error= outcomes and page deep-links |
references/technical-approach-format.md | /fe:write-technical-approach | the short TA format |
references/research-pressure-test.md | /fe:write-technical-approach, /fe:develop-feature-or-fix, /fe:ship | cross-checking a draft plan against fresh external sources |
references/metafield-metaobject-setup.md | /fe:develop-feature-or-fix, /fe:qa-feature-or-fix, /fe:write-technical-approach, /fe:ship | inspecting, creating, mocking and binding store metafields and metaobjects |
references/store-auth-troubleshooting.md | through metafield-metaobject-setup.md | when shopify store auth fails, and the re-auth blurb |
references/theme-customizer-state.md | /fe:develop-feature-or-fix, /fe:qa-feature-or-fix, /fe:write-technical-approach | reading and driving the theme editor's state through theme JSON |
references/customizer-sandbox.md | through theme-customizer-state.md | a disposable theme for a walk that would thrash the shared dev theme |
references/preflight-checklist.md | /fe:preflight-checks, /fe:develop-feature-or-fix, /fe:qa-feature-or-fix | the environment checklist |
references/pipeline-mode.md, references/pipeline-phases.md | /fe:ship | the run contract and the phase briefs |
references/eslint-no-restricted-syntax.md | /fe:develop-feature-or-fix, /fe:fix-accessibility-issue, /fe:ship | Foundation only: state through data-* attributes, not classList / style.* |
references/section-css-variables-pattern.md | /fe:develop-feature-or-fix, /fe:ship | Foundation only: a section that drives its blocks' sizes through CSS variables |
| Script | Run by | Does |
|---|---|---|
scripts/create-preview-theme.sh | /fe:preview-theme, /fe:create-pull-request, /fe:ship | builds and pushes an unpublished preview theme (create), re-pushes one (refresh), pins the session theme (pin) |
scripts/session-theme.sh | sourced by create-preview-theme.sh, run by worktree-theme.sh | the pin and un-pin grammar of shopify.theme.toml (# fe:session-theme, # fe:superseded; a pin fnd wrote is read too) |
scripts/worktree-theme.sh | /fe:preview-theme in a worktree, /fe:ship | the Shopify half of a new worktree: the one-time un-pin and the dev-server line |
scripts/theme-json.sh | the skills, through the store-access section | reads and writes a theme's JSON files (templates/*.json, config/settings_data.json) with a read-back check |
scripts/shopify-admin-gql.sh | the skills, through the store-access section | one Admin GraphQL call against the project's store |
scripts/project-profile.sh | fe's hooks module, worktree-theme.sh, the doctor, the skills' fallback | prints foundation, theme or none |
scripts/doctor.cjs | /fe-doctor, or by hand | the static install checks (§ Doctor) |
scripts/_shopify-common.sh | sourced by the store scripts | the shared Shopify CLI and config helpers |
/fe:fix-breaking-changes copies its own skills/fix-breaking-changes/scripts/fix-breaking-changes.template.js into the project and removes it after the run. /fe:write-steps-to-test names the QA store registry, <base root>/scripts/qa-stores.cjs, only to say why it does not read it.
fe adds its sections to the main session's system prompt after Claude Code's own and base's, in this order, each with the id fe:<name>:
| Name | Holds | When | ||
|---|---|---|---|---|
root | fe plugin root: <path>, the directory fe's scripts and references start from | always | ||
profile | `fe project profile: <foundation\ | theme\ | none>` | always |
comment-discipline-foundation | LiquidDoc on every snippet param; src/entry/core/* is protected; the Liquid core is hand-synced from the foundation repo, so prefer a copy | profile foundation | ||
store-access | the two store runners, scripts/shopify-admin-gql.sh and scripts/theme-json.sh, with their paths under fe's root; never Read .env or shopify.theme.toml | the project root holds shopify.theme.toml or .env | ||
worktree | worktree copy list: shopify.theme.toml (the line /base:worktree reads), then /fe:preview-theme in the new worktree | always | ||
progress-series | the rows of a ticket's progress.md in order — write-technical-approach, develop-feature-or-fix, qa-feature-or-fix, pre-commit-review, commit, write-steps-to-test, create-pull-request — and the skill that ticks each; a batch lists its tickets, then the same tail | always |
The profile is decided once per session id (a /clear decides again): FE_PROFILE when it holds one of the three words, else scripts/project-profile.sh on the project root, which also reads FE_PROFILE from .claude/domaine.env or ~/.config/domaine/env and otherwise detects (snippets/@*.liquid, sections/core-*.liquid, blocks/core-*.liquid or src/entry/core/ ⇒ foundation; layout/theme.liquid ⇒ theme; else none). A probe that fails or times out (5 s) gives none and one profile line saying why. The sections never change within a session, so the prompt cache holds.
Subagents get fe's share as added context at their start (Claude Code's SubagentStart; the engine has no event for a subagent's system prompt): base's readers and writer (base:jira-reader, base:jira-writer, base:figma-reader, base:doc-reader) and Claude Code's claude-code-guide and statusline-setup get nothing from fe; the read-only agents (fe:theme-explorer, base:change-reviewer, base:bug-hunter, Explore, Plan) get the root, the profile and the Foundation section; every other agent also gets store access when it applies.
base required: at a session start fe looks for base's skills in the command list. Without them it shows one toast and writes one install line: fe: needs the base plugin — claude plugin install base@domaine.
/fe-doctor checks fe's side of the install and prints one PASS / FAIL / SKIP / WARN row per check, the counts, and the last 10 fe.events lines. /base-doctor checks base's side (slim, fnd, the MCP servers).
| Row | Checks |
|---|---|
node | Node 18 or newer |
manifest | the manifest's version, the name fe, base in its dependencies |
scripts | every scripts/*.sh but the sourced _*.sh keeps its exec bit and answers --help (project-profile.sh is probed by the profile row) |
base | base installed (user scope or this project) and enabled — else claude plugin install base@domaine |
shopify-cli | shopify version answers; absent or failing only warns (the store scripts need it, the rest of fe does not) |
profile | the word and how it was decided: FE_PROFILE, project-profile.sh, or a fallback to none (warns); in a session, the session's own decision |
store-config | shopify.theme.toml and .env present at the project root — presence only, never a value; one missing warns; neither in a none project skips |
event-log | this session's fe.jsonl: its line count and newest ts; no file yet passes (a /clear's new session has none before its first line) |
base-live, slim-live | what this session loaded: base's skills, slim's mcp__slim__view tool |
The first eight rows come from scripts/doctor.cjs, which also runs by hand: node <fe plugin root>/scripts/doctor.cjs [--project <dir>] [--log-dir <dir>]; it exits 1 when a row fails. By hand its event-log row reads the newest session folder unless --log-dir names one. One doctor line goes to fe.events per run.
fe writes its lines to $HOME/.claude/domaine/log/<session-id>/fe.jsonl under the same contract as every Domaine plugin (plugins/base/README.md):
{"ts":"…","plugin":"fe","version":"<fe's version>","session":"<id>","kind":"profile","agent":"main","text":"theme (project-profile.sh)"}.start (fe <version>, first in every session's file, once), install (base is not loaded), profile (the word and how it was decided), doctor (a /fe-doctor run's counts). The same lines fill fe.events (oldest first, at most 200).fe: event log not written: <reason>.FE_EVENT_LOG=0 stops the file and the fe.events lines alike.band's Log pane (/band-log) shows fe's lines beside the other plugins', fe in its plugin column.
Every switch fe reads has a row here; set it in ~/.claude/settings.json → env. The store scripts also read their switches from ~/.config/domaine/env, and the tuning ones (FE_PROFILE, FE_GQL_PROBE_CACHE, FE_CPT_THROTTLE_WAITS, FE_CPT_OVERLAY_VERIFY_WAIT, FE_THEME_JSON_VERIFY_WAIT, SHOPIFY_ADMIN_GQL_QUIET) from the nearest .claude/domaine.env too; the process environment wins.
| Variable | Default | Effect |
|---|---|---|
FE_PROFILE | detected | foundation, theme or none (spaces around it trimmed) forces the project profile instead of detecting it; any other value is ignored and the profile is detected |
FE_EVENT_LOG | on | 0 keeps fe.events empty and writes no fe.jsonl |
DOMAINE_LOG_DIR | ~/.claude/domaine/log | Where every Domaine plugin (slim, band, base, fe, qa, be, pm) writes its event log on disk: <dir>/<session-id>/<plugin>.jsonl, one JSON line per event. An absolute directory; the <session-id>/ folder is still made under it. Without it and without HOME (a cloud session) no file is written. |
CLAUDE_CONFIG_DIR | ~/.claude | read, never set, by scripts/doctor.cjs: the Claude Code config directory whose plugins/installed_plugins.json and settings.json the base row reads |
FE_GQL_PROBE_CACHE | 21600 | seconds shopify-admin-gql.sh reuses its shopify version probe and its "store execute is unavailable for this store" fact; 0 re-probes on every call (right after a shopify store auth) |
FE_CPT_THROTTLE_WAITS | 20 60 | pauses, in seconds, between create-preview-theme.sh's push retries after Shopify answers Throttled; one retry per value, empty turns retrying off |
FE_CPT_OVERLAY_VERIFY | 1 | 0 skips create-preview-theme.sh's overlay read-back (overlay=skipped); with it, each overlaid *.json the theme silently dropped prints warn=overlay_file_dropped |
FE_CPT_OVERLAY_VERIFY_WAIT | 2 | seconds before the overlay read-back's one re-pull when a file comes back missing |
FE_THEME_JSON_VERIFY | 1 | 0 skips theme-json.sh set's read-back verify (verified=skipped); with it, a write the theme does not serve exits 6 with error=not_applied |
FE_THEME_JSON_VERIFY_WAIT | 2 | seconds theme-json.sh set waits before its one read-back retry |
SHOPIFY_ADMIN_GQL_QUIET | off | a non-0 value shortens shopify-admin-gql.sh's engine-fallback note to note=engine=token |
TOML_PATH | shopify.theme.toml | the config create-preview-theme.sh, theme-json.sh and shopify-admin-gql.sh read (store =, the dev theme's theme =, the Theme Access token), and the file create-preview-theme.sh's pin rewrites — point it at a copy when the real config must not change. In a toml with [environments.*] blocks every value comes from one block: --env <name> (create-preview-theme.sh only), else SHOPIFY_FLAG_ENVIRONMENT, else dev, else development, else the top-level keys |
SHOPIFY_CLI_THEME_TOKEN | unset | Theme Access token for the shopify CLI: create-preview-theme.sh's last resort after the toml's password = (and its only token for a store other than the toml's); theme-json.sh --engine themecli prefers it over the toml. Never printed |
SHOPIFY_STORE | unset | the store theme-json.sh, shopify-admin-gql.sh and create-preview-theme.sh use when --store is not passed, ahead of the toml's store =. A store other than the toml's refuses create-preview-theme.sh create and the pin; refresh then pushes with SHOPIFY_CLI_THEME_TOKEN |
SHOPIFY_FLAG_ENVIRONMENT | unset | read, never set, by fe: the Shopify CLI's own environment selector, taken by the three store scripts as the default [environments.<name>] block of shopify.theme.toml; a name no block carries is error=env_not_found |
SHOPIFY_ADMIN_TOKEN | unset | Admin API access token for shopify-admin-gql.sh's token engine, ahead of the --env dotenv file |
SHOPIFY_ADMIN_API_VERSION | 2026-04 | the Admin API version shopify-admin-gql.sh requests when --api-version is not passed |
claude plugin validate --strict plugins/fe and claude plugin test plugins/fe (the kit tests in plugins/fe/hooks/mods/tests/), both run by tests/mods-sim.sh with every other plugin (local only: CI has no claude).tests/fe-scripts-sim.sh — the store scripts, worktree-theme.sh and project-profile.sh against stub CLIs and synthetic configs.tests/fe-doctor-sim.sh — scripts/doctor.cjs's rows on planted installs.tests/team-refs-lint.sh — every qualified name and cited path resolves, no fnd name is left (the same checker for every team plugin on base).tests/base-qa-stores-sim.sh — base's QA store registry, plugins/base/scripts/qa-stores.cjs, which /fe:write-steps-to-test names.How the pieces fit: ARCHITECTURE.md.
MIT, as the repository (LICENSE).
hooks/mods/register.ts 12 lines1// fe hooks module (Claude Code only): the frontend team's conventions, project profile, store access and doctor
2// beside base. fe writes only fe.* atoms. Each feature file declares its own atoms and keeps its `$` code to
3// itself: the validator follows `$` only within one file.
4import type { Register } from 'claude-code'
5import { registerDoctor } from './doctor.ts'
6import { registerSession } from './session.ts'
7
8export const register: Register = (on) => {
9 registerSession(on)
10 registerDoctor(on)
11}
12hooks/mods/doctor.ts 199 lines1// /fe-doctor: runs scripts/doctor.cjs for what a node process sees and adds what only a session answers —
2// base loaded, slim's view tool registered, the profile this session decided — then the tail of fe.events.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5import type { FeEvent } from '../../types'
6import { logDir, logLine, pushEvent } from './events.ts'
7import type { Disk } from './events.ts'
8import { BASE_MISSING, profileText } from './session.ts'
9
10export const COMMAND = {
11 name: 'fe-doctor',
12 description: 'Check the fe install: node, manifest, scripts, base, slim, Shopify CLI, profile, store config, event log',
13 immediate: true,
14} as const
15export const SLIM_VIEW = 'mcp__slim__view'
16const TAIL = 10
17
18type Status = 'PASS' | 'FAIL' | 'SKIP' | 'WARN'
19export type Row = { status: Status; name: string; detail: string }
20
21const armed = atom({ plugin: 'fe', key: 'armed' } as const, null)
22const events = atom({ plugin: 'fe', key: 'events' } as const, [] as FeEvent[])
23const profile = atom({ plugin: 'fe', key: 'profile' } as const, null)
24
25type $ = EngineInterface
26
27/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
28function diskOf($: $): Disk {
29 return {
30 session: () => $.session.id(),
31 home: () => $.env.get('HOME'),
32 override: () => $.env.get('DOMAINE_LOG_DIR'),
33 manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
34 read: path => $.fs.read(path),
35 write: (path, text) => $.fs.write(path, text),
36 toast: text => $.ui.toast(text),
37 }
38}
39
40/** The command on every session start (a reload or a re-enable drops it) and at the first prompt of a new session id. */
41async function arm($: $, start: boolean): Promise<void> {
42 try {
43 const sid = String(await $.session.id())
44 const fresh = (await read($, armed)) !== sid
45 if (!start && !fresh) return
46 await $.command.register(COMMAND).catch(() => undefined)
47 if (fresh) await update($, armed, () => sid)
48 } catch {}
49}
50
51const STATUSES = new Set(['PASS', 'FAIL', 'SKIP', 'WARN'])
52
53/** doctor.cjs --json rows, or null when its stdout is not that shape. */
54export function parseStatic(stdout: string): Row[] | null {
55 try {
56 const rows = (JSON.parse(stdout) as { rows?: unknown }).rows
57 if (!Array.isArray(rows)) return null
58 return rows.filter(
59 (r): r is Row => !!r && STATUSES.has(r.status) && typeof r.name === 'string' && typeof r.detail === 'string',
60 )
61 } catch {
62 return null
63 }
64}
65
66async function staticRows($: $): Promise<Row[]> {
67 const root = $.plugin.root
68 let r
69 try {
70 const argv = ['node', `${root}/scripts/doctor.cjs`, '--json', '--root', root, '--project', await $.session.root()]
71 const dir = logDir(await $.env.get('HOME'), await $.env.get('DOMAINE_LOG_DIR'), await $.session.id())
72 if (dir) argv.push('--log-dir', dir)
73 r = await $.process.run(argv, { timeoutMs: 60_000 })
74 } catch (err) {
75 const why = err instanceof Error ? err.message : String(err)
76 return [{ status: 'SKIP', name: 'static', detail: `scripts/doctor.cjs did not run (${why}): the static checks need node` }]
77 }
78 const rows = parseStatic(r.stdout)
79 if (rows?.length) return rows
80 const why = (r.stderr.trim().split('\n')[0] ?? '') || 'no rows on stdout'
81 return [{ status: 'FAIL', name: 'static', detail: `scripts/doctor.cjs exited ${r.exitCode}: ${why}` }]
82}
83
84async function liveRows($: $): Promise<Row[]> {
85 const rows: Row[] = []
86 try {
87 rows.push((await $.command.list()).some(c => c.plugin === 'base')
88 ? { status: 'PASS', name: 'base-live', detail: "base's skills are loaded" }
89 : { status: 'FAIL', name: 'base-live', detail: `fe ${BASE_MISSING}` })
90 } catch {
91 rows.push({ status: 'SKIP', name: 'base-live', detail: 'the command list did not answer' })
92 }
93 try {
94 rows.push((await $.tool.list()).some(t => t.name === SLIM_VIEW)
95 ? { status: 'PASS', name: 'slim-live', detail: `${SLIM_VIEW} registered` }
96 : { status: 'FAIL', name: 'slim-live', detail: 'slim is not loaded — claude plugin install slim@domaine; base refuses its readers until it is' })
97 } catch {
98 rows.push({ status: 'SKIP', name: 'slim-live', detail: 'the tool list did not answer' })
99 }
100 return rows
101}
102
103/**
104 * The session's own profile replaces doctor.cjs's probe (the session decided it once, the prompt follows that
105 * one); a static `base` FAIL for a base that is loaded anyway (a `--plugin-dir` load has no install record)
106 * reads as a warning.
107 */
108export function reconcile(rows: Row[], session: { word: string; text: string; fallback: boolean } | null): Row[] {
109 const live = rows.find(r => r.name === 'base-live')?.status === 'PASS'
110 return rows.map(r => {
111 if (live && r.name === 'base' && r.status === 'FAIL' && r.detail.startsWith('not installed')) {
112 return { status: 'WARN', name: 'base', detail: 'not in installed_plugins.json, yet loaded this session (a --plugin-dir load?)' }
113 }
114 if (session && r.name === 'profile') {
115 return { status: session.fallback ? 'WARN' : 'PASS', name: 'profile', detail: `${session.text} — this session's` }
116 }
117 return r
118 })
119}
120
121export function age(ms: number): string {
122 const s = Math.max(0, Math.round(ms / 1000))
123 if (s < 60) return `${s}s`
124 if (s < 3600) return `${Math.floor(s / 60)}m`
125 if (s < 48 * 3600) return `${Math.floor(s / 3600)}h`
126 return `${Math.floor(s / 86400)}d`
127}
128
129export function summary(rows: Row[]): string {
130 const n = { PASS: 0, FAIL: 0, SKIP: 0, WARN: 0 }
131 for (const r of rows) n[r.status]++
132 return `${n.PASS} passed, ${n.FAIL} failed, ${n.SKIP} skipped${n.WARN ? `, ${n.WARN} warned` : ''}`
133}
134
135export function render(root: string, rows: Row[], tail: string[]): string {
136 const width = rows.reduce((w, r) => Math.max(w, r.name.length), 0)
137 return [
138 `fe doctor — plugin root: ${root}`,
139 ...rows.map(r => `${r.status} ${r.name.padEnd(width)} ${r.detail}`),
140 `doctor: ${summary(rows)}`,
141 '',
142 ...tail,
143 ].join('\n')
144}
145
146async function eventTail($: $): Promise<string[]> {
147 if ((await $.env.get('FE_EVENT_LOG')) === '0') return ['fe events: off (FE_EVENT_LOG=0)']
148 const list = await read($, events)
149 if (!list.length) return ['fe events: none yet']
150 const now = await $.clock.now()
151 const shown = list.slice(-TAIL)
152 return [
153 `fe events (last ${shown.length} of ${list.length}, newest last):`,
154 ...shown.map(ev => ` ${age(now - ev.atMs).padStart(4)} ${ev.kind.padEnd(9)} ${ev.text}`),
155 ]
156}
157
158async function logDoctor($: $, text: string): Promise<void> {
159 try {
160 if ((await $.env.get('FE_EVENT_LOG')) === '0') return
161 const ev: FeEvent = { atMs: await $.clock.now(), kind: 'doctor', text }
162 await update($, events, l => pushEvent(l, ev))
163 await logLine(diskOf($), ev)
164 } catch {}
165}
166
167async function sessionProfile($: $): Promise<{ word: string; text: string; fallback: boolean } | null> {
168 try {
169 const p = await read($, profile)
170 if (!p || p.session !== String(await $.session.id())) return null
171 return { word: p.word, text: profileText(p), fallback: p.via === 'fallback' }
172 } catch {
173 return null
174 }
175}
176
177export function registerDoctor(on: On): void {
178 // A matcher apart from session.ts's start hook: one unmatched hook per event per plugin.
179 on('session.start', { cwd: /$/ }, async ($, e, next) => {
180 const r = await next(e)
181 await arm($, true)
182 return r
183 })
184
185 on('prompt.submit', async ($, e, next) => {
186 const r = await next(e)
187 await arm($, false)
188 return r
189 })
190
191 on('command.run', { command: COMMAND.name }, async $ => {
192 const [fixed, live, mine] = await Promise.all([staticRows($), liveRows($), sessionProfile($)])
193 const rows = reconcile([...fixed, ...live], mine)
194 const tail = await eventTail($)
195 await logDoctor($, summary(rows))
196 return { text: render($.plugin.root, rows, tail) }
197 })
198}
199hooks/mods/session.ts 183 lines1// fe's session: the start line, the base check, the project profile (decided once per session id) and the
2// conventions that follow it — system-prompt sections for the main session, added context for subagents.
3// The profile lives here with both of its readers: the validator follows `$` only within one file.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On, PromptComposeSection } from 'claude-code'
6import type { FeEvent, FeEventKind, FeProfile, FeProfileInfo } from '../../types'
7import {
8 FOUNDATION,
9 NO_FE_AGENT,
10 PROGRESS_SERIES,
11 READ_ONLY_AGENT,
12 STORE_ACCESS,
13 WORKTREE,
14 profileLine,
15 rootLine,
16 withRoot,
17} from './conventions/text.ts'
18import { logLine, pushEvent } from './events.ts'
19import type { Disk } from './events.ts'
20
21export const BASE_MISSING = 'needs the base plugin — claude plugin install base@domaine'
22export const PROFILE_TIMEOUT_MS = 5_000
23/** The files the store runners read their store and credentials from, at the project root. */
24export const STORE_FILES = ['shopify.theme.toml', '.env'] as const
25
26const events = atom({ plugin: 'fe', key: 'events' } as const, [] as FeEvent[])
27const started = atom({ plugin: 'fe', key: 'started' } as const, null)
28const profile = atom({ plugin: 'fe', key: 'profile' } as const, null)
29
30type $ = EngineInterface
31
32/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
33function diskOf($: $): Disk {
34 return {
35 session: () => $.session.id(),
36 home: () => $.env.get('HOME'),
37 override: () => $.env.get('DOMAINE_LOG_DIR'),
38 manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
39 read: path => $.fs.read(path),
40 write: (path, text) => $.fs.write(path, text),
41 toast: text => $.ui.toast(text),
42 }
43}
44
45async function logEvent($: $, kind: FeEventKind, text: string): Promise<void> {
46 try {
47 if ((await $.env.get('FE_EVENT_LOG')) === '0') return
48 const ev: FeEvent = { atMs: await $.clock.now(), kind, text }
49 await update($, events, l => pushEvent(l, ev))
50 await logLine(diskOf($), ev)
51 } catch {}
52}
53
54async function version($: $): Promise<string> {
55 try {
56 const v = (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
57 return typeof v === 'string' && v ? v : 'unknown'
58 } catch {
59 return 'unknown'
60 }
61}
62
63/** base's skills carry `plugin: 'base'` in the command list from its manifest on; a list that fails says nothing. */
64async function baseLoaded($: $): Promise<boolean> {
65 try {
66 return (await $.command.list()).some(c => c.plugin === 'base')
67 } catch {
68 return true
69 }
70}
71
72export const isProfile = (w: string | undefined): w is FeProfile => w === 'foundation' || w === 'theme' || w === 'none'
73
74const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim().slice(0, 120)
75
76/** `theme (project-profile.sh)`, `foundation (FE_PROFILE)`, `none (fallback: <why>)`: the profile line's text. */
77export function profileText(p: FeProfileInfo): string {
78 return p.via === 'fallback' ? `none (fallback: ${p.why ?? 'unknown'})` : `${p.word} (${p.via})`
79}
80
81async function decide($: $, session: string): Promise<FeProfileInfo> {
82 let root = ''
83 let store = false
84 try {
85 root = await $.session.root()
86 for (const f of STORE_FILES) if (await $.fs.exists(`${root}/${f}`)) store = true
87 } catch {}
88 const forced = (await $.env.get('FE_PROFILE').catch(() => undefined))?.trim()
89 if (isProfile(forced)) return { session, word: forced, via: 'FE_PROFILE', why: null, store }
90 let why: string
91 try {
92 const argv = ['bash', `${$.plugin.root}/scripts/project-profile.sh`]
93 if (root) argv.push(root)
94 const r = await $.process.run(argv, { timeoutMs: PROFILE_TIMEOUT_MS })
95 const word = r.stdout.trim()
96 if (r.exitCode === 0 && isProfile(word)) return { session, word, via: 'project-profile.sh', why: null, store }
97 why = `project-profile.sh exited ${r.exitCode}: ${oneLine(r.stderr.split('\n')[0] || word) || 'no answer'}`
98 } catch (err) {
99 why = `project-profile.sh did not run: ${oneLine(err instanceof Error ? err.message : String(err))}`
100 }
101 return { session, word: 'none', via: 'fallback', why, store }
102}
103
104// The decision in flight for one session id: compose, a subagent and the start never run the probe twice.
105let pending: { session: string; info: Promise<FeProfileInfo> } | null = null
106
107/** This session's profile: the atom when it holds this id, else decided now, stored and logged once. */
108async function profileOf($: $): Promise<FeProfileInfo> {
109 const session = String(await $.session.id())
110 const held = await read($, profile)
111 if (held?.session === session) return held
112 if (pending?.session !== session) {
113 const info = decide($, session).then(async p => {
114 await update($, profile, () => p)
115 await logEvent($, 'profile', profileText(p))
116 return p
117 })
118 pending = { session, info: info.catch(() => ({ session, word: 'none', via: 'fallback', why: 'not stored', store: false }) as FeProfileInfo) }
119 }
120 return pending.info
121}
122
123/**
124 * The sections in session order, each `fe:<name>`: the profile is fixed per session id, so a render repeats
125 * the last one byte for byte (the prompt cache).
126 */
127export async function sections($: $): Promise<PromptComposeSection[]> {
128 const root = $.plugin.root
129 const p = await profileOf($)
130 const parts: [string, string][] = [
131 ['root', rootLine(root)],
132 ['profile', profileLine(p.word)],
133 ]
134 if (p.word === 'foundation') parts.push(['comment-discipline-foundation', FOUNDATION])
135 if (p.store) parts.push(['store-access', withRoot(STORE_ACCESS, root)])
136 parts.push(['worktree', WORKTREE], ['progress-series', PROGRESS_SERIES])
137 return parts.map(([name, text]) => ({ id: `fe:${name}`, text, scope: 'session' }))
138}
139
140/** null for base's readers and writer; the root, profile and Foundation rules for the rest; store access for a code writer. */
141export async function subagentContext($: $, agentType: string): Promise<string | null> {
142 if (NO_FE_AGENT.test(agentType)) return null
143 const root = $.plugin.root
144 const p = await profileOf($)
145 const parts = [rootLine(root), profileLine(p.word)]
146 if (p.word === 'foundation') parts.push(FOUNDATION)
147 if (p.store && !READ_ONLY_AGENT.test(agentType)) parts.push(withRoot(STORE_ACCESS, root))
148 return parts.join('\n\n')
149}
150
151export function registerSession(on: On): void {
152 // The engine allows one unmatched hook per event per plugin; this matcher takes every session.
153 on('session.start', { cwd: /^/ }, async ($, e, next) => {
154 try {
155 const sid = String(await $.session.id())
156 if ((await read($, started)) !== sid) {
157 await update($, started, () => sid)
158 await logEvent($, 'start', `fe ${await version($)}`)
159 if (!(await baseLoaded($))) {
160 await logEvent($, 'install', BASE_MISSING)
161 $.ui.toast(`fe: ${BASE_MISSING}`)
162 }
163 }
164 // Unawaited: the probe never holds the start; the first compose waits on the same decision.
165 void profileOf($).catch(() => undefined)
166 } catch {}
167 return next(e)
168 })
169
170 on('prompt.compose', async ($, e, next) => {
171 const r = await next(e)
172 const ours = await sections($).catch(() => [])
173 const taken = new Set(r.sections.map(s => s.id))
174 return { sections: [...r.sections, ...ours.filter(s => !taken.has(s.id))] }
175 })
176
177 on('classic.SubagentStart', async ($, e, next) => {
178 const r = await next(e)
179 const ctx = await subagentContext($, e.agent_type ?? '').catch(() => null)
180 return ctx ? { ...r, additionalContext: [...(r.additionalContext ?? []), ctx] } : r
181 })
182}
183hooks/mods/events.ts 157 lines1// fe's event list and its file on disk, `<log dir>/<session-id>/fe.jsonl`, under the contract base, band and
2// slim write theirs by. No `$` here: each writer file keeps its own wrapper, which pushes the line to fe.events
3// and then hands it to `logLine` with a `Disk` it built, as the validator follows `$` only within one file.
4// fe never sweeps old session directories: base owns that.
5import type { FeEvent } from '../../types'
6
7export const EVENT_CAP = 200
8export const FILE_LINES = 2000
9export const FILE_BYTES = 256 * 1024
10
11/** Appends `ev`, oldest first, at most EVENT_CAP: past the cap the oldest line goes. */
12export function pushEvent(list: readonly FeEvent[], ev: FeEvent): FeEvent[] {
13 return list.length < EVENT_CAP ? [...list, ev] : [...list.slice(1), ev]
14}
15
16const trimSlash = (p: string) => p.replace(/\/+$/, '')
17
18/** A name the engine could not have made a session id of never becomes a path segment. */
19export function isSessionName(name: string): boolean {
20 return /^[\w.-]+$/.test(name) && !/^\.+$/.test(name)
21}
22
23/** `<DOMAINE_LOG_DIR>/<session>` when the override is absolute, else `$HOME/.claude/domaine/log/<session>`; null with neither. */
24export function logDir(home: string | undefined, override: string | undefined, session: string): string | null {
25 if (!isSessionName(session)) return null
26 const o = override?.trim() ?? ''
27 if (o.startsWith('/')) return `${trimSlash(o)}/${session}`
28 const h = home?.trim() ?? ''
29 return h.startsWith('/') ? `${trimSlash(h)}/.claude/domaine/log/${session}` : null
30}
31
32/** One fe.jsonl line; `plugin` is fe's own name, never taken from another plugin's state. */
33export function fileLine(ev: FeEvent, version: string, session: string): string {
34 return JSON.stringify({ ts: new Date(ev.atMs).toISOString(), plugin: 'fe', version, session, kind: ev.kind, agent: 'main', text: ev.text })
35}
36
37export function utf8Bytes(s: string): number {
38 let n = 0
39 for (const ch of s) {
40 const cp = ch.codePointAt(0) ?? 0
41 n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
42 }
43 return n
44}
45
46/** Drops the oldest lines past FILE_LINES lines or FILE_BYTES bytes; the newest line always stays. */
47function trimFront(out: string[]): string[] {
48 let bytes = 0
49 for (const l of out) bytes += utf8Bytes(l) + 1
50 let cut = 0
51 while (out.length - cut > 1 && (out.length - cut > FILE_LINES || bytes > FILE_BYTES)) bytes -= utf8Bytes(out[cut++]!) + 1
52 return cut ? out.slice(cut) : out
53}
54
55/** Appends `line` within the cap. */
56export function capLines(lines: readonly string[], line: string): string[] {
57 return trimFront([...lines, line])
58}
59
60/** The lines of a fe.jsonl that belong to `session`, oldest first, within the cap: what a reload goes on from. */
61export function seedLines(text: string, session: string): string[] {
62 return trimFront(text.split('\n').filter(l => {
63 try {
64 return (JSON.parse(l) as { session?: unknown }).session === session
65 } catch {
66 return false
67 }
68 }))
69}
70
71const hasStart = (lines: readonly string[]) => lines.some(l => l.includes('"kind":"start"'))
72
73/**
74 * The file's lines after `ev`: the start line first in every session (a /clear's new id gets one before its
75 * first event, as no session.start announces it), and one start line per session.
76 */
77export function nextLines(lines: readonly string[], ev: FeEvent, version: string, session: string): string[] | null {
78 if (ev.kind === 'start') return hasStart(lines) ? null : capLines(lines, fileLine(ev, version, session))
79 const head = lines.length ? lines : [fileLine({ atMs: ev.atMs, kind: 'start', text: `fe ${version}` }, version, session)]
80 return capLines(head, fileLine(ev, version, session))
81}
82
83/** What the writer needs from `$`, built by each writer file's `diskOf`. */
84export type Disk = {
85 session: () => Promise<string>
86 home: () => Promise<string | undefined>
87 override: () => Promise<string | undefined>
88 manifest: () => Promise<string>
89 read: (path: string) => Promise<string>
90 write: (path: string, text: string) => Promise<void>
91 toast: (text: string) => void
92}
93
94type Sink = { path: string; lines: string[] }
95
96// Module-local, so a hot reload starts from the file: the lines already on disk for this session.
97let sink: Promise<Sink | null> | null = null
98let sinkSession = ''
99let version: Promise<string> | null = null
100let writes: Promise<void> = Promise.resolve()
101let toasted = ''
102
103async function readVersion(disk: Disk): Promise<string> {
104 try {
105 const v = (JSON.parse(await disk.manifest()) as { version?: unknown }).version
106 return typeof v === 'string' && v ? v : 'unknown'
107 } catch {
108 return 'unknown'
109 }
110}
111
112async function openSink(disk: Disk, session: string): Promise<Sink | null> {
113 const dir = logDir(await disk.home(), await disk.override(), session)
114 if (dir === null) return null
115 const path = `${dir}/fe.jsonl`
116 let lines: string[] = []
117 try {
118 lines = seedLines(await disk.read(path), session)
119 } catch {}
120 return { path, lines }
121}
122
123/**
124 * Rewrites this session's fe.jsonl with `ev` appended (`$.fs.write` has no append), after the line went to
125 * fe.events. Never throws; one toast per session when a write fails.
126 */
127export async function logLine(disk: Disk, ev: FeEvent): Promise<void> {
128 let session = ''
129 try {
130 session = await disk.session()
131 if (!sink || sinkSession !== session) {
132 sinkSession = session
133 sink = openSink(disk, session).catch(() => null)
134 }
135 version ??= readVersion(disk)
136 const v = await version
137 const s = await sink
138 if (!s) return
139 // Read, append and write inside one queue: two events in flight never build on the same snapshot.
140 const w = writes.then(async () => {
141 const lines = nextLines(s.lines, ev, v, session)
142 if (!lines) return
143 s.lines = lines
144 await disk.write(s.path, `${lines.join('\n')}\n`)
145 })
146 writes = w.catch(() => {})
147 await w
148 } catch (err) {
149 if (toasted === session) return
150 toasted = session
151 const reason = String((err as { message?: unknown } | null)?.message ?? err).replace(/\s+/g, ' ').slice(0, 120)
152 try {
153 disk.toast(`fe: event log not written: ${reason}`)
154 } catch {}
155 }
156}
157hooks/mods/conventions/text.ts 65 lines1// fe's conventions, the text the main session's system prompt and the code-writing subagents read. Pure:
2// `<fe root>` stands for the plugin root until `withRoot` fills it in.
3import type { FeProfile } from '../../../types'
4
5export const rootLine = (root: string) => `fe plugin root: ${root}`
6
7export const profileLine = (word: FeProfile) => `fe project profile: ${word}`
8
9/** `<fe root>` → the plugin's own directory. */
10export const withRoot = (text: string, root: string) => text.split('<fe root>').join(root)
11
12export const FOUNDATION = `## fe convention — LiquidDoc and core
13
14This checkout is detected as a Foundation theme (profile \`foundation\`). Every snippet ships a
15LiquidDoc \`{% doc %}\` block with a default on each param. The JS/TS core, \`src/entry/core/*\`, is
16protected: extend or compose it, never edit it in place. The Liquid core — \`snippets/@*\`,
17\`sections/core-*\`, \`blocks/core-*\` — may be edited, but every edit has to be hand-synced from the
18foundation repo, so prefer a copy under a new name.`
19
20export const STORE_ACCESS = `## fe capability — live store access, any time
21
22Two runners under \`<fe root>/scripts/\` — use them whenever real store state would answer a
23question; don't guess store state.
24
25- \`<fe root>/scripts/shopify-admin-gql.sh --query <file.graphql> [--operation <Name>] [--variables <json>
26 | --variables-file <file>] [--store <domain>] [--out <file>]\` (\`--help\` prints the full call
27 shape) — Admin GraphQL. Read-only queries are always fair game (big reads: \`--out\` + \`jq\`);
28 mutations follow \`<fe root>/references/metafield-metaobject-setup.md\`. Both paths: every query or
29 mutation you wrote clears a \`validate_graphql_codeblocks\` pass first (base's Shopify Dev MCP
30 server; needs a \`learn_shopify_api\` conversationId).
31- \`<fe root>/scripts/theme-json.sh themes|get|set\` — customizer state. \`themes\`/\`get\` freely, any
32 theme incl. live; \`set\` only per the snapshot → mutate → verify → restore protocol in
33 \`<fe root>/references/theme-customizer-state.md\` (live theme refused). Works without Admin
34 credentials via the Theme Access token.
35
36Auth is handled inside; on failure they print the setup fix to relay. Never \`Read\` \`.env\` or
37\`shopify.theme.toml\` — the runners consume secrets without exposing them.`
38
39/** The one line base's /base:worktree reads its copy list from, and the step that follows it. */
40export const WORKTREE = `worktree copy list: shopify.theme.toml
41After \`/base:worktree\` makes a new worktree, run \`/fe:preview-theme\` inside it: the copied \`shopify.theme.toml\` is unpinned there and the worktree gets a theme of its own.`
42
43export const PROGRESS_SERIES = `## fe convention — the progress series
44
45A ticket's \`progress.md\` lists fe's series, one row per step, in this order. The row text is the
46step name; the skill after it ticks the row:
47
481. \`write-technical-approach\` — \`/fe:write-technical-approach\`
492. \`develop-feature-or-fix\` — \`/fe:develop-feature-or-fix\`
503. \`qa-feature-or-fix\` — \`/fe:qa-feature-or-fix\`
514. \`pre-commit-review\` — \`/base:pre-commit-review\` (pass it the profile word above)
525. \`commit\` — \`/base:commit\`
536. \`write-steps-to-test\` — \`/fe:write-steps-to-test\`
547. \`create-pull-request\` — \`/fe:create-pull-request\`
55
56A batch (\`<work-id>\` = branch slug) lists one row per ticket, each ticked as its bug is fixed with
57its root cause, then the same tail from \`pre-commit-review\` to \`create-pull-request\`.
58\`/fe:ship\` runs the whole series and ticks the same rows.`
59
60/** base's readers and writer, and Claude Code's own helpers: no fe context at all. */
61export const NO_FE_AGENT = /(^|:)(jira-reader|jira-writer|figma-reader|doc-reader)$|^(claude-code-guide|statusline-setup)$/
62
63/** Agents that read the theme without writing it: the root, the profile and the Foundation rules, no store access. */
64export const READ_ONLY_AGENT = /(^|:)(theme-explorer|change-reviewer|bug-hunter)$|^(Explore|Plan)$/
65types/index.d.ts 44 lines1// fe's $.state contract. Values are JSON, so an absent value is null, never undefined. base's and slim's keys
2// come from the dependency contracts the engine lays beside the module (base, and slim through base); they
3// are never redeclared here.
4
5/**
6 * At most 9 characters each (band's kind cell). `start` fe's version at session start, `install` base is not
7 * loaded, `profile` the project profile fe decided and how, `doctor` the counts of a /fe-doctor run.
8 */
9export type FeEventKind = 'start' | 'install' | 'profile' | 'doctor'
10
11/** atMs = $.clock.now() when written; text is one line. */
12export type FeEvent = { atMs: number; kind: FeEventKind; text: string }
13
14/** The project profile fe's conventions follow; `none` outside a Shopify theme. */
15export type FeProfile = 'foundation' | 'theme' | 'none'
16
17/**
18 * The profile of one session's project. `via`: `FE_PROFILE` forced it, `project-profile.sh` answered it
19 * (detection or a domaine env file), `fallback` the script failed and fe took `none` (`why` says how).
20 * `store`: the project root holds `shopify.theme.toml` or `.env`, so the store-access section applies.
21 */
22export type FeProfileInfo = {
23 session: string
24 word: FeProfile
25 via: 'FE_PROFILE' | 'project-profile.sh' | 'fallback'
26 why: string | null
27 store: boolean
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 fe: {
33 /** Oldest first, at most 200; any plugin reads, fe writes. Stays [] under FE_EVENT_LOG=0. */
34 events: FeEvent[]
35 /** Decided once per session id; null before the first decision. */
36 profile: FeProfileInfo | null
37 /** The session id whose start line was written and whose base check ran: a module reload does neither again. */
38 started: string | null
39 /** The session id whose /fe-doctor command is registered. */
40 armed: string | null
41 }
42 }
43}
44