From ed0ded79d3282a65f7cf3c4f3041eb2061e9e919 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 16:58:27 +0000 Subject: [PATCH 01/10] build: copier update from copier-base-template v0.1.17 Also applies the same removal-only uv sync detection to the backend hook in the generated template. Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/bash-chain-guard.js | 57 ------------------- .claude/settings/hooks.jsonc | 13 ----- .claude/settings/permissions/bash.jsonc | 10 ++-- .config/.copier-answers.yml | 2 +- .config/.copier-managed-files.json | 2 - .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 4 +- AGENTS.md | 1 - template/.claude/hooks/bash-chain-guard.js | 57 ------------------- template/.claude/settings/hooks.jsonc | 13 ----- .../.claude/settings/permissions/bash.jsonc | 10 ++-- template/.pre-commit-config.yaml.jinja | 4 +- template/AGENTS.md | 1 - 13 files changed, 16 insertions(+), 160 deletions(-) delete mode 100644 .claude/hooks/bash-chain-guard.js delete mode 100644 template/.claude/hooks/bash-chain-guard.js diff --git a/.claude/hooks/bash-chain-guard.js b/.claude/hooks/bash-chain-guard.js deleted file mode 100644 index 0e13f9a22..000000000 --- a/.claude/hooks/bash-chain-guard.js +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node -// PreToolUse hook: blocks command chaining in Bash tool calls -// Enforces AGENTS.md: "execute exactly one command per tool call" -// Quoted spans and backslash-escapes are replaced with a space before scanning, so a `;` inside a -// string literal (or the trailing `\;` of `find -exec`) is not mistaken for a chain. Replacing with -// a space rather than deleting keeps tokens separate, so `printf hi >\x&` cannot collapse to -// `printf hi >&` and hide the backgrounding `&` behind the redirection lookbehind. -// Once https://github.com/anthropics/claude-code/issues/16561 is resolved, this should no longer be necessary -// Improvements based on https://github.com/iandunn/dotfiles/blob/main/claude/hooks/block-chained-commands.js - -const CHAIN_OPERATORS = /&&|\|\||;/; // a single `|` is a pipe, which is allowed -const BACKGROUND_AMPERSAND = /(?])&(?!>)/; // a lone `&` backgrounds the command, but `2>&1` and `&>file` are redirections - -function stripQuotedAndEscaped(command) { - return command.replace(/\\.|'[^']*'|"(?:\\.|[^"\\])*"/g, " "); -} - -let input = ""; -process.stdin.on("data", (chunk) => { - input += chunk; -}); -process.stdin.on("end", () => { - try { - const data = JSON.parse(input); - const command = (data.tool_input?.command || "").trim(); - const scannable = stripQuotedAndEscaped(command); - - if (CHAIN_OPERATORS.test(scannable)) { - process.stderr.write( - "AGENTS.md violation: never chain commands with &&, ||, or ;. " + - "Run one command per tool call. " + - "Use cd as a separate prior tool call instead of cd && ...", - ); - process.exit(2); - } - - if (BACKGROUND_AMPERSAND.test(scannable)) { - process.stderr.write( - "AGENTS.md violation: never background a command with &. " + - "Use the Bash tool's run_in_background option instead.", - ); - process.exit(2); - } - } catch (e) { - // Silent fail — never block on hook errors - } -}); - -/* - * ============== WARNING ============================================================================== - * File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git - * See .config/.copier-managed-files.json for details. - * - * You are welcome to make changes to this file in your repo if they are custom to your project, - * but if the change should be shared with other projects, please backport it to the template repo. - * ===================================================================================================== - */ diff --git a/.claude/settings/hooks.jsonc b/.claude/settings/hooks.jsonc index b8016b1a0..44bd22905 100644 --- a/.claude/settings/hooks.jsonc +++ b/.claude/settings/hooks.jsonc @@ -40,18 +40,5 @@ ], }, ], - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "sh -c 'INPUT=$(cat); SESSION=$(printf \"%s\" \"$INPUT\" | jq -r \".session_id\" 2>/dev/null); ROOT=$(cat \"/tmp/claude-$(id -u)/.claude_project_root_${SESSION}\"); printf \"%s\" \"$INPUT\" | node \"$ROOT/.claude/hooks/bash-chain-guard.js\"'", - "timeout": 5, - "statusMessage": "Checking for command chaining...", - }, - ], - }, - ], }, } diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index 6e59e03df..3505f4c49 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -140,17 +140,17 @@ "Bash(gh secret *)", "Bash(gh ruleset *)", "Bash(aws *)", // let's hold off before we let it use AWS CLI in any free running allow mode. We need to be very sure we don't have any access to staging or production credentials in our dev environment (...which we shouldn't...but we need to double check that or consider any other safeguards first) - "WebFetch", + // start to give a little more free rein for non-permanent PR activity + "Bash(gh pr close *)", + "Bash(gh pr comment *)", + "Bash(gh pr create *)", + "Bash(gh pr edit *)", ], "deny": [ // Exceptions to generally allowed AI tooling "Bash(bd init*)", // we need to control the init process, don't let AI do that in the background // Github // Claude should not ever interfere with the PR process, that is how we gate AI's work - "Bash(gh pr close *)", - "Bash(gh pr comment *)", - "Bash(gh pr create *)", - "Bash(gh pr edit *)", "Bash(gh pr merge *)", "Bash(gh pr ready *)", "Bash(gh pr review *)", diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 9581b528b..5ea8bc42b 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.16 +_commit: v0.1.17 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.config/.copier-managed-files.json b/.config/.copier-managed-files.json index dc65e8260..b10fcbebf 100644 --- a/.config/.copier-managed-files.json +++ b/.config/.copier-managed-files.json @@ -23,7 +23,6 @@ ".claude/commands/tdd-review.md", ".claude/commands/tdd.md", ".claude/helpers/merge-claude-settings.sh", - ".claude/hooks/bash-chain-guard.js", ".claude/hooks/caveman-activate.js", ".claude/hooks/caveman-config.js", ".claude/hooks/caveman-mode-tracker.js", @@ -135,7 +134,6 @@ "template/.claude/commands/tdd-review.md", "template/.claude/commands/tdd.md", "template/.claude/helpers/merge-claude-settings.sh", - "template/.claude/hooks/bash-chain-guard.js", "template/.claude/hooks/caveman-activate.js", "template/.claude/hooks/caveman-config.js", "template/.claude/hooks/caveman-mode-tracker.js", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a50069fef..6f52bc89e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -67,5 +67,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): dfaffa0d # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 5539254b # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7dbebde39..1e4134a80 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,8 +30,8 @@ repos: name: ensure python dependencies are up to date during commits stages: [pre-commit] - # When the install does change things, it exits 0, so needed to check for the phrase 'Installed' in stderr (where uv writes its progress) in order to cause things pre-commit command to fail - entry: python -c "import subprocess,sys; results=subprocess.run(['uv', 'sync', '--frozen'],timeout=360,capture_output=True); print ('stdout- ' + results.stdout.decode('utf-8', 'strict')); print ('stderr- ' + results.stderr.decode('utf-8', 'strict')); sys.exit(int(results.returncode or int('Installed' in results.stderr.decode('utf-8', 'strict'))));" + # When the install does change things, it exits 0, so needed to check for the phrases 'Installed' or 'Uninstalled' in stderr (where uv writes its progress) in order to cause things pre-commit command to fail. 'Uninstalled' is checked separately because a removal-only sync never prints 'Installed' + entry: python -c "import subprocess,sys; results=subprocess.run(['uv', 'sync', '--frozen'],timeout=360,capture_output=True); stderr=results.stderr.decode('utf-8', 'strict'); print ('stdout- ' + results.stdout.decode('utf-8', 'strict')); print ('stderr- ' + stderr); sys.exit(int(results.returncode or int('Installed' in stderr or 'Uninstalled' in stderr)));" # don't pass filenames else the command line sees them twice pass_filenames: false language: system diff --git a/AGENTS.md b/AGENTS.md index d3ef2ab63..368da962e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,6 @@ This project is a Copier template used to generate applications that are able to ## Tooling -- ❌ Never chain commands (`&&`, `||`, `;`, `&`) — breaks permission allow-list matcher. ✅ One command per tool call. `cd` as separate prior call. Pipes (`|`) OK. - Before hand-assembling a multi-step workflow, run `task --list` — it is probably already a task. Definitions live in `.config/taskfiles/`; the root `Taskfile.yaml` only includes them. - The bans on `pnpm --prefix`, `uv --directory` and direct tool invocation apply to commands you type, not to task definitions: a task's commands run with the repo root as their working directory by default. Prefer adding or extending a task over typing the long form. - Linting and type-checking stay with `pre-commit run ` rather than a task, so that what you run is exactly what CI runs. diff --git a/template/.claude/hooks/bash-chain-guard.js b/template/.claude/hooks/bash-chain-guard.js deleted file mode 100644 index 0e13f9a22..000000000 --- a/template/.claude/hooks/bash-chain-guard.js +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node -// PreToolUse hook: blocks command chaining in Bash tool calls -// Enforces AGENTS.md: "execute exactly one command per tool call" -// Quoted spans and backslash-escapes are replaced with a space before scanning, so a `;` inside a -// string literal (or the trailing `\;` of `find -exec`) is not mistaken for a chain. Replacing with -// a space rather than deleting keeps tokens separate, so `printf hi >\x&` cannot collapse to -// `printf hi >&` and hide the backgrounding `&` behind the redirection lookbehind. -// Once https://github.com/anthropics/claude-code/issues/16561 is resolved, this should no longer be necessary -// Improvements based on https://github.com/iandunn/dotfiles/blob/main/claude/hooks/block-chained-commands.js - -const CHAIN_OPERATORS = /&&|\|\||;/; // a single `|` is a pipe, which is allowed -const BACKGROUND_AMPERSAND = /(?])&(?!>)/; // a lone `&` backgrounds the command, but `2>&1` and `&>file` are redirections - -function stripQuotedAndEscaped(command) { - return command.replace(/\\.|'[^']*'|"(?:\\.|[^"\\])*"/g, " "); -} - -let input = ""; -process.stdin.on("data", (chunk) => { - input += chunk; -}); -process.stdin.on("end", () => { - try { - const data = JSON.parse(input); - const command = (data.tool_input?.command || "").trim(); - const scannable = stripQuotedAndEscaped(command); - - if (CHAIN_OPERATORS.test(scannable)) { - process.stderr.write( - "AGENTS.md violation: never chain commands with &&, ||, or ;. " + - "Run one command per tool call. " + - "Use cd as a separate prior tool call instead of cd && ...", - ); - process.exit(2); - } - - if (BACKGROUND_AMPERSAND.test(scannable)) { - process.stderr.write( - "AGENTS.md violation: never background a command with &. " + - "Use the Bash tool's run_in_background option instead.", - ); - process.exit(2); - } - } catch (e) { - // Silent fail — never block on hook errors - } -}); - -/* - * ============== WARNING ============================================================================== - * File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git - * See .config/.copier-managed-files.json for details. - * - * You are welcome to make changes to this file in your repo if they are custom to your project, - * but if the change should be shared with other projects, please backport it to the template repo. - * ===================================================================================================== - */ diff --git a/template/.claude/settings/hooks.jsonc b/template/.claude/settings/hooks.jsonc index b8016b1a0..44bd22905 100644 --- a/template/.claude/settings/hooks.jsonc +++ b/template/.claude/settings/hooks.jsonc @@ -40,18 +40,5 @@ ], }, ], - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "sh -c 'INPUT=$(cat); SESSION=$(printf \"%s\" \"$INPUT\" | jq -r \".session_id\" 2>/dev/null); ROOT=$(cat \"/tmp/claude-$(id -u)/.claude_project_root_${SESSION}\"); printf \"%s\" \"$INPUT\" | node \"$ROOT/.claude/hooks/bash-chain-guard.js\"'", - "timeout": 5, - "statusMessage": "Checking for command chaining...", - }, - ], - }, - ], }, } diff --git a/template/.claude/settings/permissions/bash.jsonc b/template/.claude/settings/permissions/bash.jsonc index 6e59e03df..3505f4c49 100644 --- a/template/.claude/settings/permissions/bash.jsonc +++ b/template/.claude/settings/permissions/bash.jsonc @@ -140,17 +140,17 @@ "Bash(gh secret *)", "Bash(gh ruleset *)", "Bash(aws *)", // let's hold off before we let it use AWS CLI in any free running allow mode. We need to be very sure we don't have any access to staging or production credentials in our dev environment (...which we shouldn't...but we need to double check that or consider any other safeguards first) - "WebFetch", + // start to give a little more free rein for non-permanent PR activity + "Bash(gh pr close *)", + "Bash(gh pr comment *)", + "Bash(gh pr create *)", + "Bash(gh pr edit *)", ], "deny": [ // Exceptions to generally allowed AI tooling "Bash(bd init*)", // we need to control the init process, don't let AI do that in the background // Github // Claude should not ever interfere with the PR process, that is how we gate AI's work - "Bash(gh pr close *)", - "Bash(gh pr comment *)", - "Bash(gh pr create *)", - "Bash(gh pr edit *)", "Bash(gh pr merge *)", "Bash(gh pr ready *)", "Bash(gh pr review *)", diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 2adf472d5..19cf8b44f 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -22,8 +22,8 @@ repos: - id: sync-dependencies-on-commit-backend name: ensure backend python dependencies are up to date during commits stages: [pre-commit] - # When the install does change things, it exits 0, so needed to check for the phrase 'Installed' in stderr (where uv writes its progress) in order to cause things pre-commit command to fail - entry: python -c "import subprocess,sys; results=subprocess.run(['uv', 'sync', '--frozen', '--directory=./backend'],timeout=360,capture_output=True); print ('stdout- ' + results.stdout.decode('utf-8', 'strict')); print ('stderr- ' + results.stderr.decode('utf-8', 'strict')); sys.exit(int(results.returncode or int('Installed' in results.stderr.decode('utf-8', 'strict'))));" + # When the install does change things, it exits 0, so needed to check for the phrases 'Installed' or 'Uninstalled' in stderr (where uv writes its progress) in order to cause things pre-commit command to fail. 'Uninstalled' is checked separately because a removal-only sync never prints 'Installed' + entry: python -c "import subprocess,sys; results=subprocess.run(['uv', 'sync', '--frozen', '--directory=./backend'],timeout=360,capture_output=True); stderr=results.stderr.decode('utf-8', 'strict'); print ('stdout- ' + results.stdout.decode('utf-8', 'strict')); print ('stderr- ' + stderr); sys.exit(int(results.returncode or int('Installed' in stderr or 'Uninstalled' in stderr)));" # don't pass filenames else the command line sees them twice pass_filenames: false language: system diff --git a/template/AGENTS.md b/template/AGENTS.md index 498a8ae5d..bb17c64a3 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -90,7 +90,6 @@ ## Tooling -- ❌ Never chain commands (`&&`, `||`, `;`, `&`) — breaks permission allow-list matcher. ✅ One command per tool call. `cd` as separate prior call. Pipes (`|`) OK. - Before hand-assembling a multi-step workflow, run `task --list` — it is probably already a task. Definitions live in `.config/taskfiles/`; the root `Taskfile.yaml` only includes them. - The bans on `pnpm --prefix`, `uv --directory` and direct tool invocation apply to commands you type, not to task definitions: a task's commands run with the repo root as their working directory by default. Prefer adding or extending a task over typing the long form. - Linting and type-checking stay with `pre-commit run ` rather than a task, so that what you run is exactly what CI runs. From e566ce6c3ba87f07932ea2773cd81ac4525d9f42 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 18:05:42 +0000 Subject: [PATCH 02/10] build: copier update from copier-base-template biome-formatter-hybrid Pulls in the switch to Biome for formatting, with Prettier kept for Vue, HTML, SCSS and Less. Applies the same hook changes to the generated repos' own .pre-commit-config.yaml.jinja, and replaces the two prettier-ignore comments in nuxt.config.ts.jinja with biome-ignore format suppressions so Biome leaves the template-conditional lists alone. --- .claude/hooks/caveman-config.js | 51 +++++---- .claude/hooks/caveman-mode-tracker.js | 101 ++++++++++-------- .claude/settings/hooks.jsonc | 22 ++-- .claude/settings/models.jsonc | 20 ++-- .../permissions/additional-dirs.jsonc | 4 +- .claude/settings/permissions/bash.jsonc | 10 +- .claude/settings/permissions/edit.jsonc | 4 +- .claude/settings/permissions/read.jsonc | 4 +- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 9 +- .../on-create-command-boilerplate.sh | 3 + .pre-commit-config.yaml | 19 +--- biome.jsonc | 34 ++++-- .../eslint/istanbul-ignore-if-must-throw.mjs | 3 +- template/.claude/hooks/caveman-config.js | 51 +++++---- .../.claude/hooks/caveman-mode-tracker.js | 101 ++++++++++-------- template/.claude/settings/hooks.jsonc | 22 ++-- template/.claude/settings/models.jsonc | 20 ++-- .../permissions/additional-dirs.jsonc | 4 +- .../.claude/settings/permissions/bash.jsonc | 10 +- .../.claude/settings/permissions/edit.jsonc | 4 +- .../.claude/settings/permissions/read.jsonc | 4 +- .../vacuum-functions/resourcesPlural.js | 44 ++++++-- .../.devcontainer/devcontainer.json.jinja | 7 +- .../on-create-command-boilerplate.sh | 3 + template/.pre-commit-config.yaml.jinja | 19 +--- template/biome.jsonc | 34 ++++-- template/frontend/nuxt.config.ts.jinja | 6 +- 28 files changed, 363 insertions(+), 252 deletions(-) diff --git a/.claude/hooks/caveman-config.js b/.claude/hooks/caveman-config.js index c71053766..3735a0e93 100644 --- a/.claude/hooks/caveman-config.js +++ b/.claude/hooks/caveman-config.js @@ -9,31 +9,36 @@ // - %APPDATA%\caveman\config.json (Windows fallback) // 3. 'full' -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); const VALID_MODES = [ - 'off', 'lite', 'full', 'ultra', - 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra', - 'commit', 'review', 'compress' + "off", + "lite", + "full", + "ultra", + "wenyan-lite", + "wenyan", + "wenyan-full", + "wenyan-ultra", + "commit", + "review", + "compress", ]; function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { - return path.join(process.env.XDG_CONFIG_HOME, 'caveman'); + return path.join(process.env.XDG_CONFIG_HOME, "caveman"); } - if (process.platform === 'win32') { - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - 'caveman' - ); + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "caveman"); } - return path.join(os.homedir(), '.config', 'caveman'); + return path.join(os.homedir(), ".config", "caveman"); } function getConfigPath() { - return path.join(getConfigDir(), 'config.json'); + return path.join(getConfigDir(), "config.json"); } function getDefaultMode() { @@ -46,7 +51,7 @@ function getDefaultMode() { // 2. Config file try { const configPath = getConfigPath(); - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) { return config.defaultMode.toLowerCase(); } @@ -55,7 +60,7 @@ function getDefaultMode() { } // 3. Default - return 'full'; + return "full"; } // Symlink-safe flag file write. @@ -86,17 +91,21 @@ function safeWriteFlag(flagPath, content) { try { if (fs.lstatSync(flagPath).isSymbolicLink()) return; } catch (e) { - if (e.code !== 'ENOENT') return; + if (e.code !== "ENOENT") return; } const tempPath = path.join(flagDir, `.caveman-active.${process.pid}.${Date.now()}`); - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; let fd; try { fd = fs.openSync(tempPath, flags, 0o600); fs.writeSync(fd, String(content)); - try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + try { + fs.fchmodSync(fd, 0o600); + } catch (e) { + /* best-effort on Windows */ + } } finally { if (fd !== undefined) fs.closeSync(fd); } @@ -130,7 +139,7 @@ function readFlag(flagPath) { if (st.isSymbolicLink() || !st.isFile()) return null; if (st.size > MAX_FLAG_BYTES) return null; - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_RDONLY | O_NOFOLLOW; let fd; let out; @@ -138,7 +147,7 @@ function readFlag(flagPath) { fd = fs.openSync(flagPath, flags); const buf = Buffer.alloc(MAX_FLAG_BYTES); const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0); - out = buf.slice(0, n).toString('utf8'); + out = buf.slice(0, n).toString("utf8"); } finally { if (fd !== undefined) fs.closeSync(fd); } diff --git a/.claude/hooks/caveman-mode-tracker.js b/.claude/hooks/caveman-mode-tracker.js index 1427e46cf..c04e7b903 100644 --- a/.claude/hooks/caveman-mode-tracker.js +++ b/.claude/hooks/caveman-mode-tracker.js @@ -2,69 +2,79 @@ // caveman — UserPromptSubmit hook to track which caveman mode is active // Inspects user input for /caveman commands and writes mode to flag file -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { getDefaultMode, safeWriteFlag, readFlag } = require('./caveman-config'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { getDefaultMode, safeWriteFlag, readFlag } = require("./caveman-config"); -const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), '.claude'); -const flagPath = path.join(claudeDir, '.caveman-active'); +const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), ".claude"); +const flagPath = path.join(claudeDir, ".caveman-active"); -let input = ''; -process.stdin.on('data', chunk => { input += chunk; }); -process.stdin.on('end', () => { +let input = ""; +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { try { const data = JSON.parse(input); - const prompt = (data.prompt || '').trim().toLowerCase(); + const prompt = (data.prompt || "").trim().toLowerCase(); // Natural language activation (e.g. "activate caveman", "turn on caveman mode", // "talk like caveman"). README tells users they can say these, but the hook // only matched /caveman commands — flag file and statusline stayed out of sync. - if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) { + if ( + /\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt) + ) { if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) { const mode = getDefaultMode(); - if (mode !== 'off') { + if (mode !== "off") { safeWriteFlag(flagPath, mode); } } } // Match /caveman commands - if (prompt.startsWith('/caveman')) { + if (prompt.startsWith("/caveman")) { const parts = prompt.split(/\s+/); const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc. - const arg = parts[1] || ''; + const arg = parts[1] || ""; let mode = null; - if (cmd === '/caveman-commit') { - mode = 'commit'; - } else if (cmd === '/caveman-review') { - mode = 'review'; - } else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') { - mode = 'compress'; - } else if (cmd === '/caveman' || cmd === '/caveman:caveman') { - if (arg === 'lite') mode = 'lite'; - else if (arg === 'ultra') mode = 'ultra'; - else if (arg === 'wenyan-lite') mode = 'wenyan-lite'; - else if (arg === 'wenyan' || arg === 'wenyan-full') mode = 'wenyan'; - else if (arg === 'wenyan-ultra') mode = 'wenyan-ultra'; + if (cmd === "/caveman-commit") { + mode = "commit"; + } else if (cmd === "/caveman-review") { + mode = "review"; + } else if (cmd === "/caveman-compress" || cmd === "/caveman:caveman-compress") { + mode = "compress"; + } else if (cmd === "/caveman" || cmd === "/caveman:caveman") { + if (arg === "lite") mode = "lite"; + else if (arg === "ultra") mode = "ultra"; + else if (arg === "wenyan-lite") mode = "wenyan-lite"; + else if (arg === "wenyan" || arg === "wenyan-full") mode = "wenyan"; + else if (arg === "wenyan-ultra") mode = "wenyan-ultra"; else mode = getDefaultMode(); } - if (mode && mode !== 'off') { + if (mode && mode !== "off") { safeWriteFlag(flagPath, mode); - } else if (mode === 'off') { - try { fs.unlinkSync(flagPath); } catch (e) {} + } else if (mode === "off") { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } } // Detect deactivation — natural language and slash commands - if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || - /\bnormal mode\b/i.test(prompt)) { - try { fs.unlinkSync(flagPath); } catch (e) {} + if ( + /\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || + /\bnormal mode\b/i.test(prompt) + ) { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } // Per-turn reinforcement: emit a structured reminder when caveman is active. @@ -78,17 +88,22 @@ process.stdin.on('end', () => { // If the flag is missing, corrupted, oversized, or a symlink pointing at // something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing // — never inject untrusted bytes into model context. - const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + const INDEPENDENT_MODES = new Set(["commit", "review", "compress"]); const activeMode = readFlag(flagPath); if (activeMode && !INDEPENDENT_MODES.has(activeMode)) { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " + - "Drop articles/filler/pleasantries/hedging. Fragments OK. " + - "Code/commits/security: write normal." - } - })); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: + "CAVEMAN MODE ACTIVE (" + + activeMode + + "). " + + "Drop articles/filler/pleasantries/hedging. Fragments OK. " + + "Code/commits/security: write normal.", + }, + }), + ); } } catch (e) { // Silent fail diff --git a/.claude/settings/hooks.jsonc b/.claude/settings/hooks.jsonc index 44bd22905..225e8111c 100644 --- a/.claude/settings/hooks.jsonc +++ b/.claude/settings/hooks.jsonc @@ -17,16 +17,16 @@ "type": "command", "command": "sh -c 'SESSION=$(jq -r \".session_id\" 2>/dev/null); DIR=\"/tmp/claude-$(id -u)\"; mkdir -p \"$DIR\"; printf \"%s\" \"$(git rev-parse --show-toplevel)\" > \"$DIR/.claude_project_root_${SESSION}\"'", "timeout": 5, - "statusMessage": "Caching project root...", + "statusMessage": "Caching project root..." }, { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/caveman-activate.js\"", "timeout": 5, - "statusMessage": "Loading caveman mode...", - }, - ], - }, + "statusMessage": "Loading caveman mode..." + } + ] + } ], "UserPromptSubmit": [ { @@ -35,10 +35,10 @@ "type": "command", "command": "sh -c 'INPUT=$(cat); SESSION=$(printf \"%s\" \"$INPUT\" | jq -r \".session_id\" 2>/dev/null); ROOT=$(cat \"/tmp/claude-$(id -u)/.claude_project_root_${SESSION}\"); printf \"%s\" \"$INPUT\" | PROJECT_ROOT_DIR=\"$ROOT\" node \"$ROOT/.claude/hooks/caveman-mode-tracker.js\"'", "timeout": 5, - "statusMessage": "Tracking caveman mode...", - }, - ], - }, - ], - }, + "statusMessage": "Tracking caveman mode..." + } + ] + } + ] + } } diff --git a/.claude/settings/models.jsonc b/.claude/settings/models.jsonc index 25546fe0c..d5dda3bea 100644 --- a/.claude/settings/models.jsonc +++ b/.claude/settings/models.jsonc @@ -8,14 +8,14 @@ * ===================================================================================================== */ { - "availableModels": [ - "fable", - "sonnet", - "opus", - "haiku", - "claude-opus-5", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-sonnet-4-6" - ] + "availableModels": [ + "fable", + "sonnet", + "opus", + "haiku", + "claude-opus-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-sonnet-4-6" + ] } diff --git a/.claude/settings/permissions/additional-dirs.jsonc b/.claude/settings/permissions/additional-dirs.jsonc index 7311ff561..889e5190e 100644 --- a/.claude/settings/permissions/additional-dirs.jsonc +++ b/.claude/settings/permissions/additional-dirs.jsonc @@ -11,6 +11,6 @@ // Additional directories that Claude Code can access "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { - "additionalDirectories": ["/tmp", "/workspaces"], - }, + "additionalDirectories": ["/tmp", "/workspaces"] + } } diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index 3505f4c49..667852398 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -130,7 +130,7 @@ "Bash(sort *)", "Bash(tail *)", // Search - "Bash(rg *)", + "Bash(rg *)" // Research ], "ask": [ @@ -144,7 +144,7 @@ "Bash(gh pr close *)", "Bash(gh pr comment *)", "Bash(gh pr create *)", - "Bash(gh pr edit *)", + "Bash(gh pr edit *)" ], "deny": [ // Exceptions to generally allowed AI tooling @@ -206,7 +206,7 @@ "Bash(usermod *)", // System Control "Bash(reboot *)", - "Bash(shutdown *)", - ], - }, + "Bash(shutdown *)" + ] + } } diff --git a/.claude/settings/permissions/edit.jsonc b/.claude/settings/permissions/edit.jsonc index dc1600d89..64e1d43d2 100644 --- a/.claude/settings/permissions/edit.jsonc +++ b/.claude/settings/permissions/edit.jsonc @@ -15,6 +15,6 @@ // A single leading slash anchors at the settings source, so absolute paths need the `//` prefix "allow": ["Edit(//tmp/**)", "Edit(//workspaces/**/tmp/**)", "Edit(tmp/**)"], // Agents are allowed to run any task, but this provides some basic protection against editing a task to do something nefarious that's normally forbidden. - "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], - }, + "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"] + } } diff --git a/.claude/settings/permissions/read.jsonc b/.claude/settings/permissions/read.jsonc index fc262732e..d9f401183 100644 --- a/.claude/settings/permissions/read.jsonc +++ b/.claude/settings/permissions/read.jsonc @@ -13,6 +13,6 @@ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": ["Read(~/.zshrc)"], - "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"], - }, + "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"] + } } diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 5ea8bc42b..d449e3ac6 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.17 +_commit: v0.1.17-1-g32aee466 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6f52bc89e..8f7d04d1b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -39,6 +39,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", + "biomejs.biome@2026.8.190716", "esbenp.prettier-vscode@12.4.0" ], "settings": { @@ -57,7 +58,11 @@ "ruff.configuration": "/workspaces/copier-nuxt-python-intranet-app/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/copier-nuxt-python-intranet-app/.config/pyrefly.toml", "prettier.configPath": "/workspaces/copier-nuxt-python-intranet-app/.config/.prettierrc", - "[jsonc][json][javascript][typescript][graphql][css][scss][html][vue]": { + "[jsonc][json][javascript][typescript][graphql][css]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[vue][html][scss][less]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true } @@ -67,5 +72,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 5539254b # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 8f85044b # spellchecker:disable-line } diff --git a/.devcontainer/on-create-command-boilerplate.sh b/.devcontainer/on-create-command-boilerplate.sh index 57d647c85..4991cd54d 100644 --- a/.devcontainer/on-create-command-boilerplate.sh +++ b/.devcontainer/on-create-command-boilerplate.sh @@ -3,6 +3,9 @@ set -ex python .devcontainer/install-ci-tooling.py +# The Biome VS Code extension finds this global install; keep the version matching the biomejs/pre-commit rev in .pre-commit-config.yaml +npm install -g @biomejs/biome@2.5.15 + git config --global --add --bool push.autoSetupRemote true git config --local core.symlinks true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e4134a80..2944519f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -86,7 +86,7 @@ repos: )$ - repo: https://github.com/biomejs/pre-commit - rev: a450e9c04887b1305951c298db5999cd30155f74 # frozen: v2.1.1 + rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: - id: biome-check # exclude generated files from codegen and strawberry @@ -102,28 +102,15 @@ repos: hooks: - id: prettier args: [--config, .config/.prettierrc] + # Biome formats everything else. These are the types it cannot format yet (SCSS, Less) or only formats experimentally (Vue, HTML) # TODO: get template YAML and MD files more in line with prettier expectations so we can start using prettier on those too exclude: | (?x)^( - .devcontainer/devcontainer.json| - .devcontainer/devcontainer-lock.json| - .*devcontainer.json| - .*tsconfig.json| .*/__snapshots__/.*| - .*.min.js| - .*.min.css| - .*.umd.js| - .*.yaml| - .*.yml| - .*.md| - .*.jsonc| .*/vendor_files/.*| - .*/schema.graphql| - .*/generated/open[-_]api/.*| - .*generated/graphql.ts| template/.*| )$ - files: (.*.json)|(.*.ts)|(.*.jsx)|(.*.tsx)|(.*.yaml)|(.*.yml)|(.*.md)|(.*.html)|(.*.css)|(.*.scss)|(.*.less)|(.*.vue)|(.*.graphql)|(.*.gql) + files: \.(vue|html|scss|less)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/biome.jsonc b/biome.jsonc index b16c2aea2..8a829ad41 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -9,17 +9,39 @@ */ { "formatter": { - "enabled": false, + "indentStyle": "space", + "lineWidth": 120 }, "linter": { - "enabled": false, + "enabled": false + }, + "css": { + "parser": { + "tailwindDirectives": true + } }, "files": { "includes": [ "**", - "!template/template/biome.jsonc", - "!template/biome.jsonc", - "!**/*.css", // still too many false positives, so we exclude CSS files - ], + // force-ignored (!!) so the scanner does not reject the generated repos' own root configs as nested root configs + "!!template/template/biome.jsonc", + "!!template/biome.jsonc", + "!**/__snapshots__", + "!**/vendor_files", + "!**/*.min.js", + "!**/*.min.css", + "!**/*.umd.js", + "!**/devcontainer.json", // rendered from the copier template, then stamped by the devcontainer context hash hook + "!**/devcontainer-lock.json" // written by the devcontainer CLI + ] }, + "overrides": [ + { + // Prettier formats Vue files until Biome's Vue support (html.experimentalFullSupportEnabled) is stable + "includes": ["**/*.vue"], + "formatter": { + "enabled": false + } + } + ] } diff --git a/copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs b/copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs index 917c5e22a..1e523a45a 100644 --- a/copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs +++ b/copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs @@ -55,8 +55,7 @@ export default { meta: { type: "problem", docs: { - description: - "Require `istanbul ignore if` branches to throw a defensive assertion naming the violated invariant", + description: "Require `istanbul ignore if` branches to throw a defensive assertion naming the violated invariant", }, schema: [], messages: { diff --git a/template/.claude/hooks/caveman-config.js b/template/.claude/hooks/caveman-config.js index c71053766..3735a0e93 100644 --- a/template/.claude/hooks/caveman-config.js +++ b/template/.claude/hooks/caveman-config.js @@ -9,31 +9,36 @@ // - %APPDATA%\caveman\config.json (Windows fallback) // 3. 'full' -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); const VALID_MODES = [ - 'off', 'lite', 'full', 'ultra', - 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra', - 'commit', 'review', 'compress' + "off", + "lite", + "full", + "ultra", + "wenyan-lite", + "wenyan", + "wenyan-full", + "wenyan-ultra", + "commit", + "review", + "compress", ]; function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { - return path.join(process.env.XDG_CONFIG_HOME, 'caveman'); + return path.join(process.env.XDG_CONFIG_HOME, "caveman"); } - if (process.platform === 'win32') { - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - 'caveman' - ); + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "caveman"); } - return path.join(os.homedir(), '.config', 'caveman'); + return path.join(os.homedir(), ".config", "caveman"); } function getConfigPath() { - return path.join(getConfigDir(), 'config.json'); + return path.join(getConfigDir(), "config.json"); } function getDefaultMode() { @@ -46,7 +51,7 @@ function getDefaultMode() { // 2. Config file try { const configPath = getConfigPath(); - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) { return config.defaultMode.toLowerCase(); } @@ -55,7 +60,7 @@ function getDefaultMode() { } // 3. Default - return 'full'; + return "full"; } // Symlink-safe flag file write. @@ -86,17 +91,21 @@ function safeWriteFlag(flagPath, content) { try { if (fs.lstatSync(flagPath).isSymbolicLink()) return; } catch (e) { - if (e.code !== 'ENOENT') return; + if (e.code !== "ENOENT") return; } const tempPath = path.join(flagDir, `.caveman-active.${process.pid}.${Date.now()}`); - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; let fd; try { fd = fs.openSync(tempPath, flags, 0o600); fs.writeSync(fd, String(content)); - try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + try { + fs.fchmodSync(fd, 0o600); + } catch (e) { + /* best-effort on Windows */ + } } finally { if (fd !== undefined) fs.closeSync(fd); } @@ -130,7 +139,7 @@ function readFlag(flagPath) { if (st.isSymbolicLink() || !st.isFile()) return null; if (st.size > MAX_FLAG_BYTES) return null; - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_RDONLY | O_NOFOLLOW; let fd; let out; @@ -138,7 +147,7 @@ function readFlag(flagPath) { fd = fs.openSync(flagPath, flags); const buf = Buffer.alloc(MAX_FLAG_BYTES); const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0); - out = buf.slice(0, n).toString('utf8'); + out = buf.slice(0, n).toString("utf8"); } finally { if (fd !== undefined) fs.closeSync(fd); } diff --git a/template/.claude/hooks/caveman-mode-tracker.js b/template/.claude/hooks/caveman-mode-tracker.js index 1427e46cf..c04e7b903 100644 --- a/template/.claude/hooks/caveman-mode-tracker.js +++ b/template/.claude/hooks/caveman-mode-tracker.js @@ -2,69 +2,79 @@ // caveman — UserPromptSubmit hook to track which caveman mode is active // Inspects user input for /caveman commands and writes mode to flag file -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { getDefaultMode, safeWriteFlag, readFlag } = require('./caveman-config'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { getDefaultMode, safeWriteFlag, readFlag } = require("./caveman-config"); -const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), '.claude'); -const flagPath = path.join(claudeDir, '.caveman-active'); +const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), ".claude"); +const flagPath = path.join(claudeDir, ".caveman-active"); -let input = ''; -process.stdin.on('data', chunk => { input += chunk; }); -process.stdin.on('end', () => { +let input = ""; +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { try { const data = JSON.parse(input); - const prompt = (data.prompt || '').trim().toLowerCase(); + const prompt = (data.prompt || "").trim().toLowerCase(); // Natural language activation (e.g. "activate caveman", "turn on caveman mode", // "talk like caveman"). README tells users they can say these, but the hook // only matched /caveman commands — flag file and statusline stayed out of sync. - if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) { + if ( + /\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt) + ) { if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) { const mode = getDefaultMode(); - if (mode !== 'off') { + if (mode !== "off") { safeWriteFlag(flagPath, mode); } } } // Match /caveman commands - if (prompt.startsWith('/caveman')) { + if (prompt.startsWith("/caveman")) { const parts = prompt.split(/\s+/); const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc. - const arg = parts[1] || ''; + const arg = parts[1] || ""; let mode = null; - if (cmd === '/caveman-commit') { - mode = 'commit'; - } else if (cmd === '/caveman-review') { - mode = 'review'; - } else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') { - mode = 'compress'; - } else if (cmd === '/caveman' || cmd === '/caveman:caveman') { - if (arg === 'lite') mode = 'lite'; - else if (arg === 'ultra') mode = 'ultra'; - else if (arg === 'wenyan-lite') mode = 'wenyan-lite'; - else if (arg === 'wenyan' || arg === 'wenyan-full') mode = 'wenyan'; - else if (arg === 'wenyan-ultra') mode = 'wenyan-ultra'; + if (cmd === "/caveman-commit") { + mode = "commit"; + } else if (cmd === "/caveman-review") { + mode = "review"; + } else if (cmd === "/caveman-compress" || cmd === "/caveman:caveman-compress") { + mode = "compress"; + } else if (cmd === "/caveman" || cmd === "/caveman:caveman") { + if (arg === "lite") mode = "lite"; + else if (arg === "ultra") mode = "ultra"; + else if (arg === "wenyan-lite") mode = "wenyan-lite"; + else if (arg === "wenyan" || arg === "wenyan-full") mode = "wenyan"; + else if (arg === "wenyan-ultra") mode = "wenyan-ultra"; else mode = getDefaultMode(); } - if (mode && mode !== 'off') { + if (mode && mode !== "off") { safeWriteFlag(flagPath, mode); - } else if (mode === 'off') { - try { fs.unlinkSync(flagPath); } catch (e) {} + } else if (mode === "off") { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } } // Detect deactivation — natural language and slash commands - if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || - /\bnormal mode\b/i.test(prompt)) { - try { fs.unlinkSync(flagPath); } catch (e) {} + if ( + /\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || + /\bnormal mode\b/i.test(prompt) + ) { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } // Per-turn reinforcement: emit a structured reminder when caveman is active. @@ -78,17 +88,22 @@ process.stdin.on('end', () => { // If the flag is missing, corrupted, oversized, or a symlink pointing at // something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing // — never inject untrusted bytes into model context. - const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + const INDEPENDENT_MODES = new Set(["commit", "review", "compress"]); const activeMode = readFlag(flagPath); if (activeMode && !INDEPENDENT_MODES.has(activeMode)) { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " + - "Drop articles/filler/pleasantries/hedging. Fragments OK. " + - "Code/commits/security: write normal." - } - })); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: + "CAVEMAN MODE ACTIVE (" + + activeMode + + "). " + + "Drop articles/filler/pleasantries/hedging. Fragments OK. " + + "Code/commits/security: write normal.", + }, + }), + ); } } catch (e) { // Silent fail diff --git a/template/.claude/settings/hooks.jsonc b/template/.claude/settings/hooks.jsonc index 44bd22905..225e8111c 100644 --- a/template/.claude/settings/hooks.jsonc +++ b/template/.claude/settings/hooks.jsonc @@ -17,16 +17,16 @@ "type": "command", "command": "sh -c 'SESSION=$(jq -r \".session_id\" 2>/dev/null); DIR=\"/tmp/claude-$(id -u)\"; mkdir -p \"$DIR\"; printf \"%s\" \"$(git rev-parse --show-toplevel)\" > \"$DIR/.claude_project_root_${SESSION}\"'", "timeout": 5, - "statusMessage": "Caching project root...", + "statusMessage": "Caching project root..." }, { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/caveman-activate.js\"", "timeout": 5, - "statusMessage": "Loading caveman mode...", - }, - ], - }, + "statusMessage": "Loading caveman mode..." + } + ] + } ], "UserPromptSubmit": [ { @@ -35,10 +35,10 @@ "type": "command", "command": "sh -c 'INPUT=$(cat); SESSION=$(printf \"%s\" \"$INPUT\" | jq -r \".session_id\" 2>/dev/null); ROOT=$(cat \"/tmp/claude-$(id -u)/.claude_project_root_${SESSION}\"); printf \"%s\" \"$INPUT\" | PROJECT_ROOT_DIR=\"$ROOT\" node \"$ROOT/.claude/hooks/caveman-mode-tracker.js\"'", "timeout": 5, - "statusMessage": "Tracking caveman mode...", - }, - ], - }, - ], - }, + "statusMessage": "Tracking caveman mode..." + } + ] + } + ] + } } diff --git a/template/.claude/settings/models.jsonc b/template/.claude/settings/models.jsonc index 25546fe0c..d5dda3bea 100644 --- a/template/.claude/settings/models.jsonc +++ b/template/.claude/settings/models.jsonc @@ -8,14 +8,14 @@ * ===================================================================================================== */ { - "availableModels": [ - "fable", - "sonnet", - "opus", - "haiku", - "claude-opus-5", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-sonnet-4-6" - ] + "availableModels": [ + "fable", + "sonnet", + "opus", + "haiku", + "claude-opus-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-sonnet-4-6" + ] } diff --git a/template/.claude/settings/permissions/additional-dirs.jsonc b/template/.claude/settings/permissions/additional-dirs.jsonc index 7311ff561..889e5190e 100644 --- a/template/.claude/settings/permissions/additional-dirs.jsonc +++ b/template/.claude/settings/permissions/additional-dirs.jsonc @@ -11,6 +11,6 @@ // Additional directories that Claude Code can access "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { - "additionalDirectories": ["/tmp", "/workspaces"], - }, + "additionalDirectories": ["/tmp", "/workspaces"] + } } diff --git a/template/.claude/settings/permissions/bash.jsonc b/template/.claude/settings/permissions/bash.jsonc index 3505f4c49..667852398 100644 --- a/template/.claude/settings/permissions/bash.jsonc +++ b/template/.claude/settings/permissions/bash.jsonc @@ -130,7 +130,7 @@ "Bash(sort *)", "Bash(tail *)", // Search - "Bash(rg *)", + "Bash(rg *)" // Research ], "ask": [ @@ -144,7 +144,7 @@ "Bash(gh pr close *)", "Bash(gh pr comment *)", "Bash(gh pr create *)", - "Bash(gh pr edit *)", + "Bash(gh pr edit *)" ], "deny": [ // Exceptions to generally allowed AI tooling @@ -206,7 +206,7 @@ "Bash(usermod *)", // System Control "Bash(reboot *)", - "Bash(shutdown *)", - ], - }, + "Bash(shutdown *)" + ] + } } diff --git a/template/.claude/settings/permissions/edit.jsonc b/template/.claude/settings/permissions/edit.jsonc index dc1600d89..64e1d43d2 100644 --- a/template/.claude/settings/permissions/edit.jsonc +++ b/template/.claude/settings/permissions/edit.jsonc @@ -15,6 +15,6 @@ // A single leading slash anchors at the settings source, so absolute paths need the `//` prefix "allow": ["Edit(//tmp/**)", "Edit(//workspaces/**/tmp/**)", "Edit(tmp/**)"], // Agents are allowed to run any task, but this provides some basic protection against editing a task to do something nefarious that's normally forbidden. - "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], - }, + "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"] + } } diff --git a/template/.claude/settings/permissions/read.jsonc b/template/.claude/settings/permissions/read.jsonc index fc262732e..d9f401183 100644 --- a/template/.claude/settings/permissions/read.jsonc +++ b/template/.claude/settings/permissions/read.jsonc @@ -13,6 +13,6 @@ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": ["Read(~/.zshrc)"], - "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"], - }, + "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"] + } } diff --git a/template/.config/vacuum-functions/resourcesPlural.js b/template/.config/vacuum-functions/resourcesPlural.js index cc652b88d..cbef416b9 100644 --- a/template/.config/vacuum-functions/resourcesPlural.js +++ b/template/.config/vacuum-functions/resourcesPlural.js @@ -12,15 +12,44 @@ // Segments that are legitimately not plural collections. var ALLOWLIST = [ // namespace / versioning - "api", "v1", "v2", "v3", + "api", + "v1", + "v2", + "v3", // uncountable / mass nouns - "data", "series", "telemetry", "equipment", "info", "metadata", "media", "config", "configuration", + "data", + "series", + "telemetry", + "equipment", + "info", + "metadata", + "media", + "config", + "configuration", // health / status / lifecycle singletons - "health", "healthcheck", "healthz", "readyz", "livez", "status", "shutdown", "ping", "version", + "health", + "healthcheck", + "healthz", + "readyz", + "livez", + "status", + "shutdown", + "ping", + "version", // common actions, filters and singleton sub-resources - "me", "self", "search", "online", "offline", "push", "polling", "dashboard", "summary", "latest", "current", + "me", + "self", + "search", + "online", + "offline", + "push", + "polling", + "dashboard", + "summary", + "latest", + "current", // non-REST endpoints (not resource collections) - "graphql" + "graphql", ]; function getSchema() { @@ -61,7 +90,10 @@ function runRule(input) { continue; } results.push({ - message: "path segment '" + seg + "' should be a plural resource noun (or added to the allowlist if it is not a collection)" + message: + "path segment '" + + seg + + "' should be a plural resource noun (or added to the allowlist if it is not a collection)", }); } return results; diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index 2094667b0..c0345dda2 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -59,6 +59,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", + "biomejs.biome@2026.8.190716", "esbenp.prettier-vscode@12.4.0" ], "settings": { @@ -77,7 +78,11 @@ "ruff.configuration": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/pyrefly.toml",{% endraw %}{% endif %}{% raw %} "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", - "[jsonc][json][javascript][typescript][graphql][css][scss][html][vue]": { + "[jsonc][json][javascript][typescript][graphql][css]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[vue][html][scss][less]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true } diff --git a/template/.devcontainer/on-create-command-boilerplate.sh b/template/.devcontainer/on-create-command-boilerplate.sh index 57d647c85..4991cd54d 100644 --- a/template/.devcontainer/on-create-command-boilerplate.sh +++ b/template/.devcontainer/on-create-command-boilerplate.sh @@ -3,6 +3,9 @@ set -ex python .devcontainer/install-ci-tooling.py +# The Biome VS Code extension finds this global install; keep the version matching the biomejs/pre-commit rev in .pre-commit-config.yaml +npm install -g @biomejs/biome@2.5.15 + git config --global --add --bool push.autoSetupRemote true git config --local core.symlinks true diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 19cf8b44f..5feea46d0 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -79,7 +79,7 @@ repos: )$ - repo: https://github.com/biomejs/pre-commit - rev: a450e9c04887b1305951c298db5999cd30155f74 # frozen: v2.1.1 + rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: - id: biome-check # exclude generated files from codegen and strawberry @@ -95,27 +95,14 @@ repos: hooks: - id: prettier args: [--config, .config/.prettierrc] + # Biome formats everything else. These are the types it cannot format yet (SCSS, Less) or only formats experimentally (Vue, HTML) # TODO: get template YAML and MD files more in line with prettier expectations so we can start using prettier on those too exclude: | (?x)^( - .devcontainer/devcontainer.json| - .devcontainer/devcontainer-lock.json| - .*devcontainer.json| - .*tsconfig.json| .*/__snapshots__/.*| - .*.min.js| - .*.min.css| - .*.umd.js| - .*.yaml| - .*.yml| - .*.md| - .*.jsonc| .*/vendor_files/.*| - .*/schema.graphql| - .*/generated/open[-_]api/.*| - .*generated/graphql.ts| )$ - files: (.*.json)|(.*.ts)|(.*.jsx)|(.*.tsx)|(.*.yaml)|(.*.yml)|(.*.md)|(.*.html)|(.*.css)|(.*.scss)|(.*.less)|(.*.vue)|(.*.graphql)|(.*.gql) + files: \.(vue|html|scss|less)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/template/biome.jsonc b/template/biome.jsonc index b16c2aea2..8a829ad41 100644 --- a/template/biome.jsonc +++ b/template/biome.jsonc @@ -9,17 +9,39 @@ */ { "formatter": { - "enabled": false, + "indentStyle": "space", + "lineWidth": 120 }, "linter": { - "enabled": false, + "enabled": false + }, + "css": { + "parser": { + "tailwindDirectives": true + } }, "files": { "includes": [ "**", - "!template/template/biome.jsonc", - "!template/biome.jsonc", - "!**/*.css", // still too many false positives, so we exclude CSS files - ], + // force-ignored (!!) so the scanner does not reject the generated repos' own root configs as nested root configs + "!!template/template/biome.jsonc", + "!!template/biome.jsonc", + "!**/__snapshots__", + "!**/vendor_files", + "!**/*.min.js", + "!**/*.min.css", + "!**/*.umd.js", + "!**/devcontainer.json", // rendered from the copier template, then stamped by the devcontainer context hash hook + "!**/devcontainer-lock.json" // written by the devcontainer CLI + ] }, + "overrides": [ + { + // Prettier formats Vue files until Biome's Vue support (html.experimentalFullSupportEnabled) is stable + "includes": ["**/*.vue"], + "formatter": { + "enabled": false + } + } + ] } diff --git a/template/frontend/nuxt.config.ts.jinja b/template/frontend/nuxt.config.ts.jinja index c47cb8ed7..09164028c 100644 --- a/template/frontend/nuxt.config.ts.jinja +++ b/template/frontend/nuxt.config.ts.jinja @@ -10,8 +10,7 @@ export default defineNuxtConfig({ }, devtools: { enabled: process.env.NODE_ENV !== "test" }, telemetry: process.env.NODE_ENV !== "test", - // the conditional modules added in by the template make it complicated to format consistently...at least with only 3 'always included' modules - // prettier-ignore + // biome-ignore format: the conditional modules added in by the template make it complicated to format consistently...at least with only 3 'always included' modules modules: [ {% endraw %}{% if is_circuit_python_driver %}{% raw %}"@lab-sync/nuxt-common", {% endraw %}{% endif %}{% raw %}"@nuxt/ui"{% endraw %}{% if frontend_uses_graphql %}{% raw %}, @@ -69,8 +68,7 @@ export default defineNuxtConfig({ ...(useOptimizeDeps ? { optimizeDeps: { - // the conditional modules added in by the template make it complicated to format consistently - // prettier-ignore + // biome-ignore format: the conditional modules added in by the template make it complicated to format consistently include: [ "@microsoft/kiota-abstractions", "@microsoft/kiota-http-fetchlibrary",{% endraw %}{% if frontend_uses_zod %}{% raw %} From f231f70217a31e41d6d0866cdd44a687d05aa71b Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 18:41:41 +0000 Subject: [PATCH 03/10] build: copier update from copier-base-template biome-formatter-hybrid (Biome version reminder) --- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index d449e3ac6..725f5a58a 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.17-1-g32aee466 +_commit: v0.1.17-2-g1ad3d0fb _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8f7d04d1b..94adb755c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -72,5 +72,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 8f85044b # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 213e37ed # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2944519f6..a6a278ed5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -85,6 +85,7 @@ repos: .*/vendor_files/.*| )$ + # Keep the global @biomejs/biome install in .devcontainer/on-create-command-boilerplate.sh on the same version (the VS Code extension uses it) - repo: https://github.com/biomejs/pre-commit rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: From 652a36773ba5e17624b7d612cf244fac3e149154 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 19:27:02 +0000 Subject: [PATCH 04/10] build: keep the vacuum ALLOWLIST one category per line and drop the Biome hook excludes Biome expanded the resourcesPlural.js ALLOWLIST to one segment per line, losing its one-category-per-line grouping; a biome-ignore format suppression keeps the original layout. The generated repos' biome-check hook no longer needs its own exclude patterns: copier-base-template's biome.jsonc now skips generated/ folders and the Strawberry schema export. --- .../vacuum-functions/resourcesPlural.js | 40 +++---------------- template/.pre-commit-config.yaml.jinja | 7 ---- 2 files changed, 6 insertions(+), 41 deletions(-) diff --git a/template/.config/vacuum-functions/resourcesPlural.js b/template/.config/vacuum-functions/resourcesPlural.js index cbef416b9..2664bc9b9 100644 --- a/template/.config/vacuum-functions/resourcesPlural.js +++ b/template/.config/vacuum-functions/resourcesPlural.js @@ -10,46 +10,18 @@ // nouns). Projects can edit ALLOWLIST below — this file is copier-managed but locally customizable. // Segments that are legitimately not plural collections. +// biome-ignore format: one category of segments per line keeps the allowlist scannable var ALLOWLIST = [ // namespace / versioning - "api", - "v1", - "v2", - "v3", + "api", "v1", "v2", "v3", // uncountable / mass nouns - "data", - "series", - "telemetry", - "equipment", - "info", - "metadata", - "media", - "config", - "configuration", + "data", "series", "telemetry", "equipment", "info", "metadata", "media", "config", "configuration", // health / status / lifecycle singletons - "health", - "healthcheck", - "healthz", - "readyz", - "livez", - "status", - "shutdown", - "ping", - "version", + "health", "healthcheck", "healthz", "readyz", "livez", "status", "shutdown", "ping", "version", // common actions, filters and singleton sub-resources - "me", - "self", - "search", - "online", - "offline", - "push", - "polling", - "dashboard", - "summary", - "latest", - "current", + "me", "self", "search", "online", "offline", "push", "polling", "dashboard", "summary", "latest", "current", // non-REST endpoints (not resource collections) - "graphql", + "graphql" ]; function getSchema() { diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 5feea46d0..fda9a305d 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -82,13 +82,6 @@ repos: rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: - id: biome-check - # exclude generated files from codegen and strawberry - exclude: | - (?x)^( - .*generated/graphql.ts| - .*/generated/open[-_]api/.*| - .*/schema.graphql| - )$ - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 From e0b2e8abef16bd4cf83211bad3892f9ba6af6002 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 19:40:47 +0000 Subject: [PATCH 05/10] build: copier update from copier-base-template biome-formatter-hybrid (devcontainer.json without trailing commas, biome.jsonc exclusions) --- .claude/hooks/caveman-config.js | 15 ++------------- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 6 +++--- .pre-commit-config.yaml | 7 ------- biome.jsonc | 5 +++-- template/.claude/hooks/caveman-config.js | 15 ++------------- template/.devcontainer/devcontainer.json.jinja | 16 ++++++++-------- template/biome.jsonc | 5 +++-- 8 files changed, 22 insertions(+), 49 deletions(-) diff --git a/.claude/hooks/caveman-config.js b/.claude/hooks/caveman-config.js index 3735a0e93..be247630b 100644 --- a/.claude/hooks/caveman-config.js +++ b/.claude/hooks/caveman-config.js @@ -13,19 +13,8 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const VALID_MODES = [ - "off", - "lite", - "full", - "ultra", - "wenyan-lite", - "wenyan", - "wenyan-full", - "wenyan-ultra", - "commit", - "review", - "compress", -]; +// biome-ignore format: the whole mode list reads best on one line +const VALID_MODES = ["off", "lite", "full", "ultra", "wenyan-lite", "wenyan", "wenyan-full", "wenyan-ultra", "commit", "review", "compress"]; function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 725f5a58a..d8689e834 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.17-2-g1ad3d0fb +_commit: v0.1.17-5-g8ed59920 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 94adb755c..d0c147b8a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,7 +14,7 @@ "autoPull": false }, // https://github.com/anthropics/devcontainer-features/blob/main/src/claude-code/devcontainer-feature.json - "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {}, + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {} }, "customizations": { "vscode": { @@ -67,10 +67,10 @@ "editor.formatOnSave": true } } - }, + } }, "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 213e37ed # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): d8d2fb9e # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a6a278ed5..b8673e377 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -90,13 +90,6 @@ repos: rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: - id: biome-check - # exclude generated files from codegen and strawberry - exclude: | - (?x)^( - .*generated/graphql.ts| - .*/generated/open[-_]api/.*| - .*/schema.graphql| - )$ - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 diff --git a/biome.jsonc b/biome.jsonc index 8a829ad41..71c0f72dd 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -28,11 +28,12 @@ "!!template/biome.jsonc", "!**/__snapshots__", "!**/vendor_files", + "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs + "!**/schema.graphql", // exported by Strawberry from the Python schema "!**/*.min.js", "!**/*.min.css", "!**/*.umd.js", - "!**/devcontainer.json", // rendered from the copier template, then stamped by the devcontainer context hash hook - "!**/devcontainer-lock.json" // written by the devcontainer CLI + "!**/devcontainer-lock.json" // written by the devcontainer CLI, including in Dependabot's devcontainers updates ] }, "overrides": [ diff --git a/template/.claude/hooks/caveman-config.js b/template/.claude/hooks/caveman-config.js index 3735a0e93..be247630b 100644 --- a/template/.claude/hooks/caveman-config.js +++ b/template/.claude/hooks/caveman-config.js @@ -13,19 +13,8 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const VALID_MODES = [ - "off", - "lite", - "full", - "ultra", - "wenyan-lite", - "wenyan", - "wenyan-full", - "wenyan-ultra", - "commit", - "review", - "compress", -]; +// biome-ignore format: the whole mode list reads best on one line +const VALID_MODES = ["off", "lite", "full", "ultra", "wenyan-lite", "wenyan", "wenyan-full", "wenyan-ultra", "commit", "review", "compress"]; function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index c0345dda2..0104051be 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -13,20 +13,20 @@ // and it breaks creation of the devcontainer since pre-commit hooks are already in place. Probably a better way to fix this but until we use git-lfs we can leave it like this. // https://github.com/devcontainers/features/blob/main/src/git-lfs/README.md "autoPull": false - },{% endraw %}{% if is_child_of_copier_base_template is not defined %}{% raw %} + }{% endraw %}{% if is_child_of_copier_base_template is not defined %}{% raw %}, "ghcr.io/devcontainers/features/aws-cli:1.1.4": { // https://github.com/devcontainers/features/blob/main/src/aws-cli/devcontainer-feature.json // view latest version https://raw.githubusercontent.com/aws/aws-cli/v2/CHANGELOG.rst "version": "2.36.49" - },{% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} + }{% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %}, "ghcr.io/devcontainers/features/node:2.1.0": { // https://github.com/devcontainers/features/blob/main/src/node/devcontainer-feature.json "version": "{% endraw %}{{ node_version }}{% raw %}", "pnpmVersion": "{% endraw %}{{ pnpm_version }}{% raw %}", - "nvmVersion": "{% endraw %}{{ nvm_version }}{% raw %}", - },{% endraw %}{% endif %}{% raw %}{% endraw %}{% if install_claude_cli %}{% raw %} + "nvmVersion": "{% endraw %}{{ nvm_version }}{% raw %}" + }{% endraw %}{% endif %}{% raw %}{% endraw %}{% if install_claude_cli %}{% raw %}, // https://github.com/anthropics/devcontainer-features/blob/main/src/claude-code/devcontainer-feature.json - "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {},{% endraw %}{% endif %}{% raw %} + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {}{% endraw %}{% endif %}{% raw %} }, "customizations": { "vscode": { @@ -87,14 +87,14 @@ "editor.formatOnSave": true } } - },{% endraw %}{% if repo_names_for_codespaces_read_permissions is defined and repo_names_for_codespaces_read_permissions.strip() %}{% set _cs_repos = repo_names_for_codespaces_read_permissions.split(',') | map('trim') | reject('equalto', '') | list %}{% if _cs_repos %} + }{% endraw %}{% if repo_names_for_codespaces_read_permissions is defined and repo_names_for_codespaces_read_permissions.strip() %}{% set _cs_repos = repo_names_for_codespaces_read_permissions.split(',') | map('trim') | reject('equalto', '') | list %}{% if _cs_repos %}, "codespaces": { "repositories": { {% for cs_repo_item in _cs_repos %} {{ (repo_org_name ~ "/" ~ cs_repo_item) | tojson }}: { "permissions": { "contents": "read" } - }, + }{% if not loop.last %},{% endif %} {% endfor %} } - },{% endif %}{% endif %}{% raw %} + }{% endif %}{% endif %}{% raw %} }, "forwardPorts": [3000{% endraw %}{% if has_backend %}{% raw %}, 4000{% endraw %}{% endif %}{% if frontend_deployed_port_number != 3000 %}{% raw %}, {% endraw %}{{ frontend_deployed_port_number }}{% endif %}{% if has_backend and backend_deployed_port_number != 4000 %}{% raw %}, {% endraw %}{{ backend_deployed_port_number }}{% endif %}{% raw %}], "portsAttributes": { diff --git a/template/biome.jsonc b/template/biome.jsonc index 8a829ad41..71c0f72dd 100644 --- a/template/biome.jsonc +++ b/template/biome.jsonc @@ -28,11 +28,12 @@ "!!template/biome.jsonc", "!**/__snapshots__", "!**/vendor_files", + "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs + "!**/schema.graphql", // exported by Strawberry from the Python schema "!**/*.min.js", "!**/*.min.css", "!**/*.umd.js", - "!**/devcontainer.json", // rendered from the copier template, then stamped by the devcontainer context hash hook - "!**/devcontainer-lock.json" // written by the devcontainer CLI + "!**/devcontainer-lock.json" // written by the devcontainer CLI, including in Dependabot's devcontainers updates ] }, "overrides": [ From 57120cf6c1f6fd6597308ef633b7e09c459f944b Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 10:41:57 +0000 Subject: [PATCH 06/10] build: copier update from copier-base-template biome-formatter-hybrid (Biome config in .config/, standalone Biome binary, install flags, Prettier extension only for Vue) --- .claude/settings/permissions/bash.jsonc | 59 +++---- .coderabbit.yaml | 2 + .config/.copier-answers.yml | 2 +- .config/.copier-managed-files.json | 4 +- biome.jsonc => .config/biome.jsonc | 5 +- .devcontainer/devcontainer.json | 11 +- .devcontainer/install-ci-tooling.py | 149 ++++++++++++++---- .../on-create-command-boilerplate.sh | 3 - .github/actions/install_deps/action.yml | 10 +- .github/workflows/ci.yaml | 2 + .github/workflows/pre-commit.yaml | 1 + .pre-commit-config.yaml | 19 ++- AGENTS.md | 4 + extensions/context.py | 1 + .../.claude/settings/permissions/bash.jsonc | 59 +++---- template/.coderabbit.yaml | 2 + template/{ => .config}/biome.jsonc | 5 +- .../.devcontainer/devcontainer.json.jinja | 11 +- .../.devcontainer/install-ci-tooling.py.jinja | 149 ++++++++++++++---- .../on-create-command-boilerplate.sh | 3 - .../.github/actions/install_deps/action.yml | 10 +- template/.github/workflows/pre-commit.yaml | 1 + template/AGENTS.md | 4 + 23 files changed, 337 insertions(+), 179 deletions(-) rename biome.jsonc => .config/biome.jsonc (86%) rename template/{ => .config}/biome.jsonc (86%) diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index 667852398..86f6ba242 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -63,44 +63,8 @@ "Bash(gh search *)", "Bash(gh pr view *)", "Bash(gh pr diff *)", - // Local app interaction - // Claude currently has terrible support for this: https://github.com/anthropics/claude-code/issues/338 using `:*` as a pattern is not allowed (doesn't error, just doesn't really work), so have to specify _something_ between the colon and asterisk. - "Bash(curl http://localhost:1*)", - "Bash(curl http://localhost:2*)", - "Bash(curl http://localhost:3*)", - "Bash(curl http://localhost:4*)", - "Bash(curl http://localhost:5*)", - "Bash(curl http://localhost:6*)", - "Bash(curl http://localhost:7*)", - "Bash(curl http://localhost:8*)", - "Bash(curl http://localhost:9*)", - "Bash(curl -s http://localhost:1*)", - "Bash(curl -s http://localhost:2*)", - "Bash(curl -s http://localhost:3*)", - "Bash(curl -s http://localhost:4*)", - "Bash(curl -s http://localhost:5*)", - "Bash(curl -s http://localhost:6*)", - "Bash(curl -s http://localhost:7*)", - "Bash(curl -s http://localhost:8*)", - "Bash(curl -s http://localhost:9*)", - "Bash(curl \"http://localhost:1*)", - "Bash(curl \"http://localhost:2*)", - "Bash(curl \"http://localhost:3*)", - "Bash(curl \"http://localhost:4*)", - "Bash(curl \"http://localhost:5*)", - "Bash(curl \"http://localhost:6*)", - "Bash(curl \"http://localhost:7*)", - "Bash(curl \"http://localhost:8*)", - "Bash(curl \"http://localhost:9*)", - "Bash(curl -s \"http://localhost:1*)", - "Bash(curl -s \"http://localhost:2*)", - "Bash(curl -s \"http://localhost:3*)", - "Bash(curl -s \"http://localhost:4*)", - "Bash(curl -s \"http://localhost:5*)", - "Bash(curl -s \"http://localhost:6*)", - "Bash(curl -s \"http://localhost:7*)", - "Bash(curl -s \"http://localhost:8*)", - "Bash(curl -s \"http://localhost:9*)", + "Bash(gh repo view *)", + "Bash(gh repo clone *)", // Misc "Bash(amp *)", "Bash(atuin *)", @@ -135,7 +99,7 @@ ], "ask": [ // let's hold off before we let it use the github CLI in any free running allow mode...I don't want it somehow approving PRs with the user's credentials - "Bash(gh repo *)", + "Bash(gh repo fork *)", "Bash(gh release *)", "Bash(gh secret *)", "Bash(gh ruleset *)", @@ -155,6 +119,23 @@ "Bash(gh pr ready *)", "Bash(gh pr review *)", "Bash(gh pr update-branch *)", + // Raw API access and the token itself would let Claude get around every gh rule in this file. Scripts that need the API (like the address-pr-comments skill) are reviewed and run gh api themselves. + "Bash(gh api *)", + "Bash(gh auth token*)", + "Bash(gh auth status *-t*)", // -t / --show-token prints the token + // Creating or changing repositories should be a deliberate human step, so run these yourself with `!` + "Bash(gh repo archive *)", + "Bash(gh repo autolink create *)", + "Bash(gh repo autolink new *)", + "Bash(gh repo autolink delete *)", + "Bash(gh repo create *)", + "Bash(gh repo new *)", + "Bash(gh repo delete *)", + "Bash(gh repo deploy-key *)", + "Bash(gh repo edit *)", + "Bash(gh repo rename *)", + "Bash(gh repo sync *)", + "Bash(gh repo unarchive *)", // Task must run the repo's own taskfiles, which are the reviewed ones. These flags are the only ways to point it elsewhere (there is no environment variable equivalent -- https://github.com/go-task/task/issues/1721 was closed as a duplicate with nothing implemented), and `/tmp` is writable without a prompt, so without these denials `Bash(task *)` would be an allow-anything entry. "Bash(task -t *)", "Bash(task --taskfile *)", diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a29328579..8dc9ad981 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -32,6 +32,8 @@ reviews: enabled: false pylint: # when the code contains python, pylint will be run in the CI pipeline, and coderabbit often generates false positives enabled: false + biome: # biome runs in the CI pipeline through pre-commit, and coderabbit would not find the config in .config/ and fall back to its own defaults + enabled: false flake8: # we use ruff instead (when we use Python) enabled: false poem: false diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index d8689e834..938e228a0 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.17-5-g8ed59920 +_commit: v0.1.17-11-g994f9d56 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.config/.copier-managed-files.json b/.config/.copier-managed-files.json index b10fcbebf..2f498f6e9 100644 --- a/.config/.copier-managed-files.json +++ b/.config/.copier-managed-files.json @@ -49,6 +49,7 @@ ".config/.coveragerc", ".config/.prettierrc", ".config/_typos.toml", + ".config/biome.jsonc", ".config/pylintrc.toml", ".config/pyrefly.toml", ".config/ruff-non-src.toml", @@ -95,7 +96,6 @@ "LICENSE", "README.md", "Taskfile.yaml", - "biome.jsonc", "copier.yml", "copier_template_resources/eslint/eslint.shared.mjs", "copier_template_resources/eslint/istanbul-ignore-if-must-throw.mjs", @@ -167,6 +167,7 @@ "template/.config/.coveragerc", "template/.config/.prettierrc", "template/.config/_typos.toml", + "template/.config/biome.jsonc", "template/.config/pylintrc.toml", "template/.config/pyrefly.mutmut.toml", "template/.config/pyrefly.toml", @@ -198,7 +199,6 @@ "template/AGENTS.md", "template/CHANGELOG.md", "template/README.md.jinja", - "template/biome.jsonc", "template/pnpm-workspace.yaml", "template/pytest.ini", "template/scripts/delete_false_positive_rej.py", diff --git a/biome.jsonc b/.config/biome.jsonc similarity index 86% rename from biome.jsonc rename to .config/biome.jsonc index 71c0f72dd..e4392f3ba 100644 --- a/biome.jsonc +++ b/.config/biome.jsonc @@ -23,9 +23,8 @@ "files": { "includes": [ "**", - // force-ignored (!!) so the scanner does not reject the generated repos' own root configs as nested root configs - "!!template/template/biome.jsonc", - "!!template/biome.jsonc", + // force-ignored (!!) because Biome treats any biome.jsonc it is handed as a config, so this file and the generated repos' copies of it would otherwise be rejected as a second root config + "!!**/.config/biome.jsonc", "!**/__snapshots__", "!**/vendor_files", "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d0c147b8a..6743c4fa4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -39,8 +39,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "biomejs.biome@2026.8.190716", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -57,14 +56,10 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/copier-nuxt-python-intranet-app/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/copier-nuxt-python-intranet-app/.config/pyrefly.toml", - "prettier.configPath": "/workspaces/copier-nuxt-python-intranet-app/.config/.prettierrc", + "biome.configurationPath": "/workspaces/copier-nuxt-python-intranet-app/.config/biome.jsonc", "[jsonc][json][javascript][typescript][graphql][css]": { "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true - }, - "[vue][html][scss][less]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true } } } @@ -72,5 +67,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): d8d2fb9e # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 1c1be0f3 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index c15425b17..14705616e 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -25,7 +25,11 @@ IDENTIFY_VERSION = "==2.6.20" PREK_VERSION = "==0.5.5" TASK_VERSION = "==3.53.1" +BIOME_VERSION = "2.5.15" DOWNLOAD_TIMEOUT_SECONDS = 90 +# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection +BIOME_CURL_MAX_TIME_SECONDS = 300 +BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -79,6 +83,27 @@ default=False, help="Skip installing the SSM plugin for AWS CLI", ) +_ = parser.add_argument( + "--install-pre-commit-tooling", + action="store_true", + default=False, + help="Install pre-commit, identify, prek and Biome. Always installed outside CI.", +) +_ = parser.add_argument( + "--install-copier", + action="store_true", + default=False, + help="Install copier. Always installed outside CI.", +) + + +def should_install(*, requested: bool) -> bool: + if requested: + return True + # GitHub Actions always sets CI, so its absence means a developer's machine or devcontainer, which gets every tool + if "CI" not in os.environ: + return True + return False def pwsh_cmd(cmd: str) -> list[str]: @@ -213,6 +238,57 @@ def install_task(*, uv_path: str, uv_env: dict[str, str], is_windows: bool) -> N _ = github_path_file.write(f"{LOCAL_BIN_DIR}\n") +def install_biome(*, is_windows: bool) -> None: + """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + + The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on + PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm + global install would end up off PATH in CI (see `install_task`). + + The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an + atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + """ + machine = platform.machine().lower() + if machine == "x86_64": + architecture = "x64" + elif machine == "amd64": + architecture = "x64" + elif machine == "aarch64": + architecture = "arm64" + elif machine == "arm64": + architecture = "arm64" + else: + raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") + if is_windows: + asset_name = f"biome-win32-{architecture}.exe" + biome_path = LOCAL_BIN_DIR / "biome.exe" + elif sys.platform == "darwin": + asset_name = f"biome-darwin-{architecture}" + biome_path = LOCAL_BIN_DIR / "biome" + else: + asset_name = f"biome-linux-{architecture}" + biome_path = LOCAL_BIN_DIR / "biome" + LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: + download_path = Path(tmp_dir) / biome_path.name + run_with_retries( + [ + "curl", + *CURL_DOWNLOAD_ARGS, + "--max-time", + str(BIOME_CURL_MAX_TIME_SECONDS), + "--output", + str(download_path), + f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/{asset_name}", + ], + description="Downloading Biome", + timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, + ) + download_path.chmod(0o755) + _ = download_path.replace(biome_path) + _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + + def node_cmd(cmd: str, *, is_windows: bool) -> list[str]: if is_windows: return pwsh_cmd(cmd) @@ -358,41 +434,46 @@ def main(): else: uv_path = "uv" install_uv(uv_path=uv_path, uv_env=uv_env, is_windows=is_windows) + install_pre_commit_tooling = should_install(requested=args.install_pre_commit_tooling) if not args.no_python: - run_with_retries( - [ - uv_path, - "tool", - "install", - f"copier{COPIER_VERSION}", - "--with", - f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", - ], - description="Installing copier", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"pre-commit{PRE_COMMIT_VERSION}", - "--with", - f"identify{IDENTIFY_VERSION}", - ], - description="Installing pre-commit", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"prek{PREK_VERSION}", - ], - description="Installing prek", - env=uv_env, - ) + if should_install(requested=args.install_copier): + run_with_retries( + [ + uv_path, + "tool", + "install", + f"copier{COPIER_VERSION}", + "--with", + f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", + ], + description="Installing copier", + env=uv_env, + ) + if install_pre_commit_tooling: + run_with_retries( + [ + uv_path, + "tool", + "install", + f"pre-commit{PRE_COMMIT_VERSION}", + "--with", + f"identify{IDENTIFY_VERSION}", + ], + description="Installing pre-commit", + env=uv_env, + ) + run_with_retries( + [ + uv_path, + "tool", + "install", + f"prek{PREK_VERSION}", + ], + description="Installing prek", + env=uv_env, + ) + if install_pre_commit_tooling: + install_biome(is_windows=is_windows) if not args.no_node: install_pnpm(is_windows=is_windows) # Task is installed outside the --no-node branch because CI always passes --no-node (pnpm/setup handles pnpm there), and every job still needs the task runner diff --git a/.devcontainer/on-create-command-boilerplate.sh b/.devcontainer/on-create-command-boilerplate.sh index 4991cd54d..57d647c85 100644 --- a/.devcontainer/on-create-command-boilerplate.sh +++ b/.devcontainer/on-create-command-boilerplate.sh @@ -3,9 +3,6 @@ set -ex python .devcontainer/install-ci-tooling.py -# The Biome VS Code extension finds this global install; keep the version matching the biomejs/pre-commit rev in .pre-commit-config.yaml -npm install -g @biomejs/biome@2.5.15 - git config --global --add --bool push.autoSetupRemote true git config --local core.symlinks true diff --git a/.github/actions/install_deps/action.yml b/.github/actions/install_deps/action.yml index 8f684782a..b332683ef 100644 --- a/.github/actions/install_deps/action.yml +++ b/.github/actions/install_deps/action.yml @@ -28,6 +28,14 @@ inputs: required: false default: false description: Whether to explicitly skip installing the SSM Plugin manager when setting up basic CI tooling + install-pre-commit-tooling: + required: false + default: false + description: Whether to install the tooling the pre-commit hooks need (pre-commit, identify, prek and Biome), for jobs that run pre-commit + install-copier: + required: false + default: false + description: Whether to install copier, for jobs that instantiate a copier template project-dir: description: What's the relative path to the project? required: false @@ -102,7 +110,7 @@ runs: - name: Install tooling # the funky syntax is github action ternary # --no-node is always passed because the only node tooling this script installs is pnpm, which pnpm/setup above already installed - run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} + run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} ${{ inputs.install-pre-commit-tooling == 'true' && '--install-pre-commit-tooling' || '' }} ${{ inputs.install-copier == 'true' && '--install-copier' || '' }} shell: pwsh - name: OIDC Auth for CodeArtifact diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3be86a911..013d0bcc6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -138,6 +138,8 @@ jobs: python-version: ${{ matrix.python-version }} node-version: 24.11.1 install-deps: false + install-copier: true + install-pre-commit-tooling: true - name: Instantiate copier template run: | diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index e8fa0729b..95fefb80f 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -58,6 +58,7 @@ jobs: node-version: ${{ inputs.node-version }} skip-installing-ssm-plugin-manager: true skip-installing-pulumi-cli: true + install-pre-commit-tooling: true - name: Compute Pre-commit cache key # must run after checkout, since hashFiles needs the file on disk run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8673e377..2930a6660 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -85,11 +85,24 @@ repos: .*/vendor_files/.*| )$ - # Keep the global @biomejs/biome install in .devcontainer/on-create-command-boilerplate.sh on the same version (the VS Code extension uses it) - - repo: https://github.com/biomejs/pre-commit - rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 + # `language: system` so the hooks run the one Biome that .devcontainer/install-ci-tooling.py installs, which the VS Code + # extension uses too, instead of a second copy whose version has to be kept in sync (https://biomejs.dev/recipes/git-hooks/#pre-commit) + - repo: local hooks: - id: biome-check + name: biome check + entry: biome check --write --files-ignore-unknown=true --no-errors-on-unmatched --config-path .config/biome.jsonc + language: system + types: [text] + files: '\.(jsx?|tsx?|c(js|ts)|m(js|ts)|d\.(ts|cts|mts)|jsonc?|css|svelte|vue|astro|graphql|gql)$' + require_serial: true + - id: biome-format-config + name: format the biome config itself + # run from .config/ so Biome treats the file as its own root config instead of rejecting it as a second one + entry: bash -c 'cd .config && biome format --write biome.jsonc' + language: system + files: ^\.config/biome\.jsonc$ + pass_filenames: false - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 diff --git a/AGENTS.md b/AGENTS.md index 368da962e..cfe2c3974 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,10 @@ This project is a Copier template used to generate applications that are able to - Before saving any memory or adding any rule, explicitly ask the user whether the concept should be: (1) added to AGENTS.md as a general rule applicable across all projects, (2) added to AGENTS.md as a rule specific to this project, or (3) stored as a temporary local memory only relevant to the current active work. The devcontainer environment is ephemeral, so local memory files are rarely the right choice. +## Public Repositories + +- Before posting to a repository (PR or issue text, comments, commit messages, file contents), check its visibility (`gh repo view / --json visibility`). In a public repository, never name, link to, or quote a private repository. Still say when relevant work happened in one, such as testing in a private repo that depends on this one, but anonymize it ("tested in a private repo") and give only the details needed to make the point. + ## Tooling - Before hand-assembling a multi-step workflow, run `task --list` — it is probably already a task. Definitions live in `.config/taskfiles/`; the root `Taskfile.yaml` only includes them. diff --git a/extensions/context.py b/extensions/context.py index f88fa588b..7e31db287 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -27,6 +27,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["identify_version"] = "==2.6.20" context["prek_version"] = "==0.5.5" context["task_version"] = "==3.53.1" + context["biome_version"] = "2.5.15" context["pytest_version"] = ">=9.1.1" context["pytest_randomly_version"] = ">=5.0.0" context["pytest_cov_version"] = ">=7.1.0" diff --git a/template/.claude/settings/permissions/bash.jsonc b/template/.claude/settings/permissions/bash.jsonc index 667852398..86f6ba242 100644 --- a/template/.claude/settings/permissions/bash.jsonc +++ b/template/.claude/settings/permissions/bash.jsonc @@ -63,44 +63,8 @@ "Bash(gh search *)", "Bash(gh pr view *)", "Bash(gh pr diff *)", - // Local app interaction - // Claude currently has terrible support for this: https://github.com/anthropics/claude-code/issues/338 using `:*` as a pattern is not allowed (doesn't error, just doesn't really work), so have to specify _something_ between the colon and asterisk. - "Bash(curl http://localhost:1*)", - "Bash(curl http://localhost:2*)", - "Bash(curl http://localhost:3*)", - "Bash(curl http://localhost:4*)", - "Bash(curl http://localhost:5*)", - "Bash(curl http://localhost:6*)", - "Bash(curl http://localhost:7*)", - "Bash(curl http://localhost:8*)", - "Bash(curl http://localhost:9*)", - "Bash(curl -s http://localhost:1*)", - "Bash(curl -s http://localhost:2*)", - "Bash(curl -s http://localhost:3*)", - "Bash(curl -s http://localhost:4*)", - "Bash(curl -s http://localhost:5*)", - "Bash(curl -s http://localhost:6*)", - "Bash(curl -s http://localhost:7*)", - "Bash(curl -s http://localhost:8*)", - "Bash(curl -s http://localhost:9*)", - "Bash(curl \"http://localhost:1*)", - "Bash(curl \"http://localhost:2*)", - "Bash(curl \"http://localhost:3*)", - "Bash(curl \"http://localhost:4*)", - "Bash(curl \"http://localhost:5*)", - "Bash(curl \"http://localhost:6*)", - "Bash(curl \"http://localhost:7*)", - "Bash(curl \"http://localhost:8*)", - "Bash(curl \"http://localhost:9*)", - "Bash(curl -s \"http://localhost:1*)", - "Bash(curl -s \"http://localhost:2*)", - "Bash(curl -s \"http://localhost:3*)", - "Bash(curl -s \"http://localhost:4*)", - "Bash(curl -s \"http://localhost:5*)", - "Bash(curl -s \"http://localhost:6*)", - "Bash(curl -s \"http://localhost:7*)", - "Bash(curl -s \"http://localhost:8*)", - "Bash(curl -s \"http://localhost:9*)", + "Bash(gh repo view *)", + "Bash(gh repo clone *)", // Misc "Bash(amp *)", "Bash(atuin *)", @@ -135,7 +99,7 @@ ], "ask": [ // let's hold off before we let it use the github CLI in any free running allow mode...I don't want it somehow approving PRs with the user's credentials - "Bash(gh repo *)", + "Bash(gh repo fork *)", "Bash(gh release *)", "Bash(gh secret *)", "Bash(gh ruleset *)", @@ -155,6 +119,23 @@ "Bash(gh pr ready *)", "Bash(gh pr review *)", "Bash(gh pr update-branch *)", + // Raw API access and the token itself would let Claude get around every gh rule in this file. Scripts that need the API (like the address-pr-comments skill) are reviewed and run gh api themselves. + "Bash(gh api *)", + "Bash(gh auth token*)", + "Bash(gh auth status *-t*)", // -t / --show-token prints the token + // Creating or changing repositories should be a deliberate human step, so run these yourself with `!` + "Bash(gh repo archive *)", + "Bash(gh repo autolink create *)", + "Bash(gh repo autolink new *)", + "Bash(gh repo autolink delete *)", + "Bash(gh repo create *)", + "Bash(gh repo new *)", + "Bash(gh repo delete *)", + "Bash(gh repo deploy-key *)", + "Bash(gh repo edit *)", + "Bash(gh repo rename *)", + "Bash(gh repo sync *)", + "Bash(gh repo unarchive *)", // Task must run the repo's own taskfiles, which are the reviewed ones. These flags are the only ways to point it elsewhere (there is no environment variable equivalent -- https://github.com/go-task/task/issues/1721 was closed as a duplicate with nothing implemented), and `/tmp` is writable without a prompt, so without these denials `Bash(task *)` would be an allow-anything entry. "Bash(task -t *)", "Bash(task --taskfile *)", diff --git a/template/.coderabbit.yaml b/template/.coderabbit.yaml index 18d3eb5fe..eeede6a2c 100644 --- a/template/.coderabbit.yaml +++ b/template/.coderabbit.yaml @@ -38,6 +38,8 @@ reviews: enabled: false pylint: # when the code contains python, pylint will be run in the CI pipeline, and coderabbit often generates false positives enabled: false + biome: # biome runs in the CI pipeline through pre-commit, and coderabbit would not find the config in .config/ and fall back to its own defaults + enabled: false flake8: # we use ruff instead (when we use Python) enabled: false poem: false diff --git a/template/biome.jsonc b/template/.config/biome.jsonc similarity index 86% rename from template/biome.jsonc rename to template/.config/biome.jsonc index 71c0f72dd..e4392f3ba 100644 --- a/template/biome.jsonc +++ b/template/.config/biome.jsonc @@ -23,9 +23,8 @@ "files": { "includes": [ "**", - // force-ignored (!!) so the scanner does not reject the generated repos' own root configs as nested root configs - "!!template/template/biome.jsonc", - "!!template/biome.jsonc", + // force-ignored (!!) because Biome treats any biome.jsonc it is handed as a config, so this file and the generated repos' copies of it would otherwise be rejected as a second root config + "!!**/.config/biome.jsonc", "!**/__snapshots__", "!**/vendor_files", "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index 0104051be..46821a304 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -50,6 +50,7 @@ // VueJS "vue.volar@3.2.5", "vitest.explorer@1.36.0", + "esbenp.prettier-vscode@12.4.0", {% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} // All javascript "dbaeumer.vscode-eslint@3.0.24", @@ -59,8 +60,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "biomejs.biome@2026.8.190716", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -77,15 +77,16 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/pyrefly.toml",{% endraw %}{% endif %}{% raw %} - "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", + "biome.configurationPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/biome.jsonc", "[jsonc][json][javascript][typescript][graphql][css]": { "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true - }, + }{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %}, + "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", "[vue][html][scss][less]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true - } + }{% endraw %}{% endif %}{% raw %} } }{% endraw %}{% if repo_names_for_codespaces_read_permissions is defined and repo_names_for_codespaces_read_permissions.strip() %}{% set _cs_repos = repo_names_for_codespaces_read_permissions.split(',') | map('trim') | reject('equalto', '') | list %}{% if _cs_repos %}, "codespaces": { diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index 544c4f899..7752f5219 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -18,7 +18,11 @@ PRE_COMMIT_VERSION = "{% endraw %}{{ pre_commit_version }}{% raw %}" IDENTIFY_VERSION = "{% endraw %}{{ identify_version }}{% raw %}" PREK_VERSION = "{% endraw %}{{ prek_version }}{% raw %}" TASK_VERSION = "{% endraw %}{{ task_version }}{% raw %}" +BIOME_VERSION = "{% endraw %}{{ biome_version }}{% raw %}" DOWNLOAD_TIMEOUT_SECONDS = 90 +# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection +BIOME_CURL_MAX_TIME_SECONDS = 300 +BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -72,6 +76,27 @@ _ = parser.add_argument( default=False, help="Skip installing the SSM plugin for AWS CLI", ) +_ = parser.add_argument( + "--install-pre-commit-tooling", + action="store_true", + default=False, + help="Install pre-commit, identify, prek and Biome. Always installed outside CI.", +) +_ = parser.add_argument( + "--install-copier", + action="store_true", + default=False, + help="Install copier. Always installed outside CI.", +) + + +def should_install(*, requested: bool) -> bool: + if requested: + return True + # GitHub Actions always sets CI, so its absence means a developer's machine or devcontainer, which gets every tool + if "CI" not in os.environ: + return True + return False def pwsh_cmd(cmd: str) -> list[str]: @@ -206,6 +231,57 @@ def install_task(*, uv_path: str, uv_env: dict[str, str], is_windows: bool) -> N _ = github_path_file.write(f"{LOCAL_BIN_DIR}\n") +def install_biome(*, is_windows: bool) -> None: + """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + + The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on + PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm + global install would end up off PATH in CI (see `install_task`). + + The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an + atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + """ + machine = platform.machine().lower() + if machine == "x86_64": + architecture = "x64" + elif machine == "amd64": + architecture = "x64" + elif machine == "aarch64": + architecture = "arm64" + elif machine == "arm64": + architecture = "arm64" + else: + raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") + if is_windows: + asset_name = f"biome-win32-{architecture}.exe" + biome_path = LOCAL_BIN_DIR / "biome.exe" + elif sys.platform == "darwin": + asset_name = f"biome-darwin-{architecture}" + biome_path = LOCAL_BIN_DIR / "biome" + else: + asset_name = f"biome-linux-{architecture}" + biome_path = LOCAL_BIN_DIR / "biome" + LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: + download_path = Path(tmp_dir) / biome_path.name + run_with_retries( + [ + "curl", + *CURL_DOWNLOAD_ARGS, + "--max-time", + str(BIOME_CURL_MAX_TIME_SECONDS), + "--output", + str(download_path), + f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/{asset_name}", + ], + description="Downloading Biome", + timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, + ) + download_path.chmod(0o755) + _ = download_path.replace(biome_path) + _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + + def node_cmd(cmd: str, *, is_windows: bool) -> list[str]: if is_windows: return pwsh_cmd(cmd) @@ -351,41 +427,46 @@ def main(): else: uv_path = "uv" install_uv(uv_path=uv_path, uv_env=uv_env, is_windows=is_windows) + install_pre_commit_tooling = should_install(requested=args.install_pre_commit_tooling) if not args.no_python: - run_with_retries( - [ - uv_path, - "tool", - "install", - f"copier{COPIER_VERSION}", - "--with", - f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", - ], - description="Installing copier", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"pre-commit{PRE_COMMIT_VERSION}", - "--with", - f"identify{IDENTIFY_VERSION}", - ], - description="Installing pre-commit", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"prek{PREK_VERSION}", - ], - description="Installing prek", - env=uv_env, - ) + if should_install(requested=args.install_copier): + run_with_retries( + [ + uv_path, + "tool", + "install", + f"copier{COPIER_VERSION}", + "--with", + f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", + ], + description="Installing copier", + env=uv_env, + ) + if install_pre_commit_tooling: + run_with_retries( + [ + uv_path, + "tool", + "install", + f"pre-commit{PRE_COMMIT_VERSION}", + "--with", + f"identify{IDENTIFY_VERSION}", + ], + description="Installing pre-commit", + env=uv_env, + ) + run_with_retries( + [ + uv_path, + "tool", + "install", + f"prek{PREK_VERSION}", + ], + description="Installing prek", + env=uv_env, + ) + if install_pre_commit_tooling: + install_biome(is_windows=is_windows) if not args.no_node: install_pnpm(is_windows=is_windows) # Task is installed outside the --no-node branch because CI always passes --no-node (pnpm/setup handles pnpm there), and every job still needs the task runner diff --git a/template/.devcontainer/on-create-command-boilerplate.sh b/template/.devcontainer/on-create-command-boilerplate.sh index 4991cd54d..57d647c85 100644 --- a/template/.devcontainer/on-create-command-boilerplate.sh +++ b/template/.devcontainer/on-create-command-boilerplate.sh @@ -3,9 +3,6 @@ set -ex python .devcontainer/install-ci-tooling.py -# The Biome VS Code extension finds this global install; keep the version matching the biomejs/pre-commit rev in .pre-commit-config.yaml -npm install -g @biomejs/biome@2.5.15 - git config --global --add --bool push.autoSetupRemote true git config --local core.symlinks true diff --git a/template/.github/actions/install_deps/action.yml b/template/.github/actions/install_deps/action.yml index 8f684782a..b332683ef 100644 --- a/template/.github/actions/install_deps/action.yml +++ b/template/.github/actions/install_deps/action.yml @@ -28,6 +28,14 @@ inputs: required: false default: false description: Whether to explicitly skip installing the SSM Plugin manager when setting up basic CI tooling + install-pre-commit-tooling: + required: false + default: false + description: Whether to install the tooling the pre-commit hooks need (pre-commit, identify, prek and Biome), for jobs that run pre-commit + install-copier: + required: false + default: false + description: Whether to install copier, for jobs that instantiate a copier template project-dir: description: What's the relative path to the project? required: false @@ -102,7 +110,7 @@ runs: - name: Install tooling # the funky syntax is github action ternary # --no-node is always passed because the only node tooling this script installs is pnpm, which pnpm/setup above already installed - run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} + run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} ${{ inputs.install-pre-commit-tooling == 'true' && '--install-pre-commit-tooling' || '' }} ${{ inputs.install-copier == 'true' && '--install-copier' || '' }} shell: pwsh - name: OIDC Auth for CodeArtifact diff --git a/template/.github/workflows/pre-commit.yaml b/template/.github/workflows/pre-commit.yaml index e8fa0729b..95fefb80f 100644 --- a/template/.github/workflows/pre-commit.yaml +++ b/template/.github/workflows/pre-commit.yaml @@ -58,6 +58,7 @@ jobs: node-version: ${{ inputs.node-version }} skip-installing-ssm-plugin-manager: true skip-installing-pulumi-cli: true + install-pre-commit-tooling: true - name: Compute Pre-commit cache key # must run after checkout, since hashFiles needs the file on disk run: | diff --git a/template/AGENTS.md b/template/AGENTS.md index bb17c64a3..f2b5bf403 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -88,6 +88,10 @@ - Before saving any memory or adding any rule, explicitly ask the user whether the concept should be: (1) added to AGENTS.md as a general rule applicable across all projects, (2) added to AGENTS.md as a rule specific to this project, or (3) stored as a temporary local memory only relevant to the current active work. The devcontainer environment is ephemeral, so local memory files are rarely the right choice. +## Public Repositories + +- Before posting to a repository (PR or issue text, comments, commit messages, file contents), check its visibility (`gh repo view / --json visibility`). In a public repository, never name, link to, or quote a private repository. Still say when relevant work happened in one, such as testing in a private repo that depends on this one, but anonymize it ("tested in a private repo") and give only the details needed to make the point. + ## Tooling - Before hand-assembling a multi-step workflow, run `task --list` — it is probably already a task. Definitions live in `.config/taskfiles/`; the root `Taskfile.yaml` only includes them. From 45c05bb87f596124c7832d545bdd32d4c808567d Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 10:42:00 +0000 Subject: [PATCH 07/10] build: run the generated apps' Biome hooks on the installed Biome binary --- template/.pre-commit-config.yaml.jinja | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index fda9a305d..53223b72c 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -78,10 +78,24 @@ repos: .*/vendor_files/.*| )$ - - repo: https://github.com/biomejs/pre-commit - rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 + # `language: system` so the hooks run the one Biome that .devcontainer/install-ci-tooling.py installs, which the VS Code + # extension uses too, instead of a second copy whose version has to be kept in sync (https://biomejs.dev/recipes/git-hooks/#pre-commit) + - repo: local hooks: - id: biome-check + name: biome check + entry: biome check --write --files-ignore-unknown=true --no-errors-on-unmatched --config-path .config/biome.jsonc + language: system + types: [text] + files: '\.(jsx?|tsx?|c(js|ts)|m(js|ts)|d\.(ts|cts|mts)|jsonc?|css|svelte|vue|astro|graphql|gql)$' + require_serial: true + - id: biome-format-config + name: format the biome config itself + # run from .config/ so Biome treats the file as its own root config instead of rejecting it as a second one + entry: bash -c 'cd .config && biome format --write biome.jsonc' + language: system + files: ^\.config/biome\.jsonc$ + pass_filenames: false - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 From 9b9878971e3dd5689ac90ce61df505c6e0ddf892 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 12:32:20 +0000 Subject: [PATCH 08/10] build: copier update from copier-base-template biome-formatter-hybrid (install flag help text) --- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 4 ++-- template/.devcontainer/install-ci-tooling.py.jinja | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 938e228a0..5108c7d7c 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.17-11-g994f9d56 +_commit: v0.1.20-12-g89a3b332 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6743c4fa4..345f61e9f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -67,5 +67,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 1c1be0f3 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): df85efca # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 14705616e..82858cb7f 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -87,13 +87,13 @@ "--install-pre-commit-tooling", action="store_true", default=False, - help="Install pre-commit, identify, prek and Biome. Always installed outside CI.", + help="Install pre-commit, identify, prek and Biome in CI. Outside CI, this script always installs them.", ) _ = parser.add_argument( "--install-copier", action="store_true", default=False, - help="Install copier. Always installed outside CI.", + help="Install copier in CI. Outside CI, this script always installs it.", ) diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index 7752f5219..feb296de6 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -80,13 +80,13 @@ _ = parser.add_argument( "--install-pre-commit-tooling", action="store_true", default=False, - help="Install pre-commit, identify, prek and Biome. Always installed outside CI.", + help="Install pre-commit, identify, prek and Biome in CI. Outside CI, this script always installs them.", ) _ = parser.add_argument( "--install-copier", action="store_true", default=False, - help="Install copier. Always installed outside CI.", + help="Install copier in CI. Outside CI, this script always installs it.", ) From 193d51745538cac9d51bcd6ceddc5b05c0b9513c Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 13:09:42 +0000 Subject: [PATCH 09/10] build: copier update from copier-base-template biome-formatter-hybrid (Biome installed through npm) --- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 68 ++++++------------- .../.devcontainer/install-ci-tooling.py.jinja | 68 ++++++------------- 4 files changed, 44 insertions(+), 96 deletions(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 5108c7d7c..763b7a8c4 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.20-12-g89a3b332 +_commit: v0.1.20-13-g90936d13 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 345f61e9f..42a5ceacf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -67,5 +67,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): df85efca # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 994f3283 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 82858cb7f..c39c15468 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -27,9 +27,6 @@ TASK_VERSION = "==3.53.1" BIOME_VERSION = "2.5.15" DOWNLOAD_TIMEOUT_SECONDS = 90 -# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection -BIOME_CURL_MAX_TIME_SECONDS = 300 -BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -239,54 +236,31 @@ def install_task(*, uv_path: str, uv_env: dict[str, str], is_windows: bool) -> N def install_biome(*, is_windows: bool) -> None: - """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + """Install the pinned Biome globally through npm. - The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on - PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm - global install would end up off PATH in CI (see `install_task`). - - The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an - atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + The Biome pre-commit hooks are `language: system` hooks that run this install, and the VS Code extension finds it in + the global npm root, so the hooks and the editor always use the same version. When node comes from `pnpm/setup` in + CI, npm's global bin directory is not on PATH (see `install_task`), so it is appended to `GITHUB_PATH`. """ - machine = platform.machine().lower() - if machine == "x86_64": - architecture = "x64" - elif machine == "amd64": - architecture = "x64" - elif machine == "aarch64": - architecture = "arm64" - elif machine == "arm64": - architecture = "arm64" - else: - raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") + run_with_retries( # noqa: S604 # we need shell=True for npm commands, and this is all our own input + node_cmd(f"npm install -g @biomejs/biome@{BIOME_VERSION}", is_windows=is_windows), + description="Installing Biome", + shell=True, + ) + npm_prefix = subprocess.run( # noqa: S602 # we need shell=True for npm commands, and this is all our own input + node_cmd("npm prefix -g", is_windows=is_windows), shell=True, check=True, capture_output=True, text=True + ).stdout.strip() + # npm links global executables straight into its prefix on Windows, and into the prefix's bin directory elsewhere if is_windows: - asset_name = f"biome-win32-{architecture}.exe" - biome_path = LOCAL_BIN_DIR / "biome.exe" - elif sys.platform == "darwin": - asset_name = f"biome-darwin-{architecture}" - biome_path = LOCAL_BIN_DIR / "biome" + global_bin_dir = Path(npm_prefix) else: - asset_name = f"biome-linux-{architecture}" - biome_path = LOCAL_BIN_DIR / "biome" - LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: - download_path = Path(tmp_dir) / biome_path.name - run_with_retries( - [ - "curl", - *CURL_DOWNLOAD_ARGS, - "--max-time", - str(BIOME_CURL_MAX_TIME_SECONDS), - "--output", - str(download_path), - f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/{asset_name}", - ], - description="Downloading Biome", - timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, - ) - download_path.chmod(0o755) - _ = download_path.replace(biome_path) - _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + global_bin_dir = Path(npm_prefix) / "bin" + biome_env = dict(os.environ) + biome_env["PATH"] = f"{global_bin_dir}{os.pathsep}{biome_env['PATH']}" + _ = subprocess.run(node_cmd("biome --version", is_windows=is_windows), shell=True, check=True, env=biome_env) # noqa: S602 # we need shell=True for npm-installed commands, and this is all our own input + if "GITHUB_PATH" in os.environ: + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as github_path_file: + _ = github_path_file.write(f"{global_bin_dir}\n") def node_cmd(cmd: str, *, is_windows: bool) -> list[str]: diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index feb296de6..02fdcb608 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -20,9 +20,6 @@ PREK_VERSION = "{% endraw %}{{ prek_version }}{% raw %}" TASK_VERSION = "{% endraw %}{{ task_version }}{% raw %}" BIOME_VERSION = "{% endraw %}{{ biome_version }}{% raw %}" DOWNLOAD_TIMEOUT_SECONDS = 90 -# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection -BIOME_CURL_MAX_TIME_SECONDS = 300 -BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -232,54 +229,31 @@ def install_task(*, uv_path: str, uv_env: dict[str, str], is_windows: bool) -> N def install_biome(*, is_windows: bool) -> None: - """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + """Install the pinned Biome globally through npm. - The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on - PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm - global install would end up off PATH in CI (see `install_task`). - - The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an - atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + The Biome pre-commit hooks are `language: system` hooks that run this install, and the VS Code extension finds it in + the global npm root, so the hooks and the editor always use the same version. When node comes from `pnpm/setup` in + CI, npm's global bin directory is not on PATH (see `install_task`), so it is appended to `GITHUB_PATH`. """ - machine = platform.machine().lower() - if machine == "x86_64": - architecture = "x64" - elif machine == "amd64": - architecture = "x64" - elif machine == "aarch64": - architecture = "arm64" - elif machine == "arm64": - architecture = "arm64" - else: - raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") + run_with_retries( # noqa: S604 # we need shell=True for npm commands, and this is all our own input + node_cmd(f"npm install -g @biomejs/biome@{BIOME_VERSION}", is_windows=is_windows), + description="Installing Biome", + shell=True, + ) + npm_prefix = subprocess.run( # noqa: S602 # we need shell=True for npm commands, and this is all our own input + node_cmd("npm prefix -g", is_windows=is_windows), shell=True, check=True, capture_output=True, text=True + ).stdout.strip() + # npm links global executables straight into its prefix on Windows, and into the prefix's bin directory elsewhere if is_windows: - asset_name = f"biome-win32-{architecture}.exe" - biome_path = LOCAL_BIN_DIR / "biome.exe" - elif sys.platform == "darwin": - asset_name = f"biome-darwin-{architecture}" - biome_path = LOCAL_BIN_DIR / "biome" + global_bin_dir = Path(npm_prefix) else: - asset_name = f"biome-linux-{architecture}" - biome_path = LOCAL_BIN_DIR / "biome" - LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: - download_path = Path(tmp_dir) / biome_path.name - run_with_retries( - [ - "curl", - *CURL_DOWNLOAD_ARGS, - "--max-time", - str(BIOME_CURL_MAX_TIME_SECONDS), - "--output", - str(download_path), - f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/{asset_name}", - ], - description="Downloading Biome", - timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, - ) - download_path.chmod(0o755) - _ = download_path.replace(biome_path) - _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + global_bin_dir = Path(npm_prefix) / "bin" + biome_env = dict(os.environ) + biome_env["PATH"] = f"{global_bin_dir}{os.pathsep}{biome_env['PATH']}" + _ = subprocess.run(node_cmd("biome --version", is_windows=is_windows), shell=True, check=True, env=biome_env) # noqa: S602 # we need shell=True for npm-installed commands, and this is all our own input + if "GITHUB_PATH" in os.environ: + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as github_path_file: + _ = github_path_file.write(f"{global_bin_dir}\n") def node_cmd(cmd: str, *, is_windows: bool) -> list[str]: From 7641dbf3bc462136ccf40a39315bd3ad8a4d39fc Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 14:17:02 +0000 Subject: [PATCH 10/10] build: copier update from copier-base-template v0.1.21 --- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 4 ++-- template/.devcontainer/devcontainer.json.jinja | 2 +- template/.pre-commit-config.yaml.jinja | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 763b7a8c4..0a1b146eb 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.20-13-g90936d13 +_commit: v0.1.21 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: A web app that is hosted within a local intranet. Nuxt frontend, python diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 42a5ceacf..ce0139d60 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -67,5 +67,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 994f3283 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): a45d1e87 # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2930a6660..e46574341 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -109,7 +109,7 @@ repos: hooks: - id: prettier args: [--config, .config/.prettierrc] - # Biome formats everything else. These are the types it cannot format yet (SCSS, Less) or only formats experimentally (Vue, HTML) + # Biome formats everything else. Its Vue and HTML formatting is still experimental # TODO: get template YAML and MD files more in line with prettier expectations so we can start using prettier on those too exclude: | (?x)^( @@ -117,7 +117,7 @@ repos: .*/vendor_files/.*| template/.*| )$ - files: \.(vue|html|scss|less)$ + files: \.(vue|html)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index 46821a304..26fc1d0f1 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -83,7 +83,7 @@ "editor.formatOnSave": true }{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %}, "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", - "[vue][html][scss][less]": { + "[vue][html]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true }{% endraw %}{% endif %}{% raw %} diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja index 53223b72c..7d64a68c9 100644 --- a/template/.pre-commit-config.yaml.jinja +++ b/template/.pre-commit-config.yaml.jinja @@ -102,14 +102,14 @@ repos: hooks: - id: prettier args: [--config, .config/.prettierrc] - # Biome formats everything else. These are the types it cannot format yet (SCSS, Less) or only formats experimentally (Vue, HTML) + # Biome formats everything else. Its Vue and HTML formatting is still experimental # TODO: get template YAML and MD files more in line with prettier expectations so we can start using prettier on those too exclude: | (?x)^( .*/__snapshots__/.*| .*/vendor_files/.*| )$ - files: \.(vue|html|scss|less)$ + files: \.(vue|html)$ - repo: https://github.com/pamoller/xmlformatter