From 252d9c90748c67303a235520277ee84e5f7f9e73 Mon Sep 17 00:00:00 2001 From: Eli Fine <5436635+ejfine@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:00:11 +0000 Subject: [PATCH 1/2] Copier update: v0.2.0 (run Biome as a local hook) Pull copier-base-template v0.2.0: Biome now runs as a language: system pre-commit hook using the CI-installed binary, its config moves to .config/biome.jsonc, and Prettier is limited to Vue and HTML. Allow comments in pyrightconfig.json via a Biome override, since pyright only discovers its config under the .json name but parses it as JSONC. This replaces the biome-check exclude that the old pre-commit config had for the file. --- .claude/hooks/bash-chain-guard.js | 57 -------- .claude/hooks/caveman-config.js | 44 +++---- .claude/hooks/caveman-mode-tracker.js | 101 ++++++++------ .claude/settings/hooks.jsonc | 35 ++--- .claude/settings/models.jsonc | 20 +-- .../permissions/additional-dirs.jsonc | 4 +- .claude/settings/permissions/bash.jsonc | 77 +++++------ .claude/settings/permissions/edit.jsonc | 4 +- .claude/settings/permissions/read.jsonc | 4 +- .../skills/address-pr-comments/post-reply.py | 35 +---- .../post-review-comment.py | 91 +++++++++++++ .claude/skills/address-pr-comments/utils.py | 55 ++++++++ .coderabbit.yaml | 2 + .config/.copier-answers.yml | 2 +- .config/.copier-managed-files.json | 8 +- .config/biome.jsonc | 56 ++++++++ .devcontainer/devcontainer.json | 14 +- .devcontainer/install-ci-tooling.py | 123 +++++++++++++----- .../on-create-command-boilerplate.sh | 22 ++++ .github/actions/install_deps/action.yml | 10 +- .github/pull_request_template.md | 12 +- .github/workflows/ci.yaml | 2 + .github/workflows/pre-commit.yaml | 1 + .pre-commit-config.yaml | 47 +++---- AGENTS.md | 5 +- biome.jsonc | 25 ---- copier.yml | 2 +- extensions/context.py | 1 + template/.claude/hooks/bash-chain-guard.js | 57 -------- template/.claude/hooks/caveman-config.js | 44 +++---- .../.claude/hooks/caveman-mode-tracker.js | 101 ++++++++------ template/.claude/settings/hooks.jsonc | 35 ++--- template/.claude/settings/models.jsonc | 20 +-- .../permissions/additional-dirs.jsonc | 4 +- .../.claude/settings/permissions/bash.jsonc | 77 +++++------ .../.claude/settings/permissions/edit.jsonc | 4 +- .../.claude/settings/permissions/read.jsonc | 4 +- .../skills/address-pr-comments/post-reply.py | 35 +---- .../post-review-comment.py | 91 +++++++++++++ .../skills/address-pr-comments/utils.py | 55 ++++++++ template/.coderabbit.yaml | 2 + template/.config/biome.jsonc | 56 ++++++++ .../.devcontainer/devcontainer.json.jinja | 30 +++-- .../.devcontainer/install-ci-tooling.py.jinja | 123 +++++++++++++----- .../on-create-command-boilerplate.sh | 22 ++++ .../.github/actions/install_deps/action.yml | 10 +- template/.github/pull_request_template.md | 12 +- template/.github/workflows/pre-commit.yaml | 1 + template/.pre-commit-config.yaml | 47 +++---- template/AGENTS.md | 5 +- template/biome.jsonc | 25 ---- 51 files changed, 1018 insertions(+), 701 deletions(-) delete mode 100644 .claude/hooks/bash-chain-guard.js create mode 100755 .claude/skills/address-pr-comments/post-review-comment.py create mode 100644 .config/biome.jsonc delete mode 100644 biome.jsonc delete mode 100644 template/.claude/hooks/bash-chain-guard.js create mode 100755 template/.claude/skills/address-pr-comments/post-review-comment.py create mode 100644 template/.config/biome.jsonc delete mode 100644 template/biome.jsonc diff --git a/.claude/hooks/bash-chain-guard.js b/.claude/hooks/bash-chain-guard.js deleted file mode 100644 index 0e13f9a2..00000000 --- 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/hooks/caveman-config.js b/.claude/hooks/caveman-config.js index c7105376..be247630 100644 --- a/.claude/hooks/caveman-config.js +++ b/.claude/hooks/caveman-config.js @@ -9,31 +9,25 @@ // - %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' -]; +// 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) { - 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 +40,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 +49,7 @@ function getDefaultMode() { } // 3. Default - return 'full'; + return "full"; } // Symlink-safe flag file write. @@ -86,17 +80,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 +128,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 +136,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 1427e46c..c04e7b90 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 b8016b1a..225e8111 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,23 +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...", - }, - ], - }, - ], - "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...", - }, - ], - }, - ], - }, + "statusMessage": "Tracking caveman mode..." + } + ] + } + ] + } } diff --git a/.claude/settings/models.jsonc b/.claude/settings/models.jsonc index 25546fe0..d5dda3be 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 7311ff56..889e5190 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 6e59e03d..86f6ba24 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 *)", @@ -130,31 +94,48 @@ "Bash(sort *)", "Bash(tail *)", // Search - "Bash(rg *)", + "Bash(rg *)" // Research ], "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 *)", "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 *)", "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 *)", @@ -206,7 +187,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 dc1600d8..64e1d43d 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 fc262732..d9f40118 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/.claude/skills/address-pr-comments/post-reply.py b/.claude/skills/address-pr-comments/post-reply.py index 25673149..4779ae98 100755 --- a/.claude/skills/address-pr-comments/post-reply.py +++ b/.claude/skills/address-pr-comments/post-reply.py @@ -15,14 +15,12 @@ """ import argparse -import json -import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from utils import owner_repo_from_remote -from utils import run_cmd +from utils import post_via_gh_api def main() -> None: @@ -38,11 +36,6 @@ def main() -> None: args = parser.parse_args() owner, repo = owner_repo_from_remote() - try: - body = args.body_file.read_text(encoding="utf-8") - except OSError as e: - _ = sys.stderr.write(f"Unable to read body file {args.body_file}: {e}\n") - sys.exit(1) if args.comment_type == "pulls/comments": # Reply directly into the inline review comment thread. @@ -51,31 +44,7 @@ def main() -> None: # GitHub has no "reply" concept for issue comments — post a new top-level comment. endpoint = f"repos/{owner}/{repo}/issues/{args.pr}/comments" - try: - result = run_cmd( - [ - "gh", - "api", - endpoint, - "--method", - "POST", - "--raw-field", - f"body={body}", - ], - timeout=30, - timeout_msg="gh api timed out while posting reply.", - ) - except subprocess.CalledProcessError as e: - _ = sys.stderr.write(f"gh api error: {e.stderr}\n") - sys.exit(1) - - response = json.loads(result.stdout) - _ = sys.stdout.write(f"Posted: {response.get('html_url', '(no URL in response)')}\n") - - try: - args.body_file.unlink() - except OSError as e: - _ = sys.stderr.write(f"Warning: post succeeded but could not delete body file {args.body_file}: {e}\n") + post_via_gh_api(endpoint=endpoint, body_file=args.body_file, timeout_msg="gh api timed out while posting reply.") if __name__ == "__main__": diff --git a/.claude/skills/address-pr-comments/post-review-comment.py b/.claude/skills/address-pr-comments/post-review-comment.py new file mode 100755 index 00000000..f5813f88 --- /dev/null +++ b/.claude/skills/address-pr-comments/post-review-comment.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Post a new inline review comment on a PR diff via the GitHub API. + +Usage: + # Comment on one line of the new file: + post-review-comment.py --pr --path --line --body-file + + # Comment spanning a range of lines (start and end must be in the same diff hunk): + post-review-comment.py --pr --path --start-line --line --body-file + + # Comment on a removed line (the old file's numbering): + post-review-comment.py --pr --path --line --side LEFT --body-file + +Line numbers are those of the file at --commit-id (default: the PR's current head) for --side RIGHT, or of the base +file for --side LEFT. Owner and repo are derived from the git remote automatically. + +This script exists so that gh api calls are covered by the skills allow-list +rather than requiring manual approval each time. +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from utils import owner_repo_from_remote +from utils import post_via_gh_api +from utils import run_cmd + + +def pr_head_sha(*, owner: str, repo: str, pr: int) -> str: + try: + result = run_cmd( + ["gh", "api", f"repos/{owner}/{repo}/pulls/{pr}", "--jq", ".head.sha"], + timeout=30, + timeout_msg="gh api timed out while reading the PR head commit.", + ) + except subprocess.CalledProcessError as e: + _ = sys.stderr.write(f"gh api error reading PR #{pr}: {e.stderr}\n") + sys.exit(1) + return result.stdout.strip() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + _ = parser.add_argument("--pr", type=int, required=True, help="PR number") + _ = parser.add_argument("--path", required=True, help="File path relative to the repo root") + _ = parser.add_argument("--line", type=int, required=True, help="Line the comment ends on") + _ = parser.add_argument("--start-line", type=int, help="First line of a multi-line comment") + _ = parser.add_argument("--side", choices=["RIGHT", "LEFT"], default="RIGHT", help="RIGHT = new file, LEFT = old") + _ = parser.add_argument("--start-side", choices=["RIGHT", "LEFT"], help="Defaults to --side") + _ = parser.add_argument("--commit-id", help="Commit the line numbers refer to (default: the PR's head)") + _ = parser.add_argument("--body-file", required=True, type=Path) + args = parser.parse_args() + + if args.start_line is not None and args.start_line >= args.line: + _ = sys.stderr.write(f"--start-line ({args.start_line}) must be less than --line ({args.line})\n") + sys.exit(1) + + owner, repo = owner_repo_from_remote() + + commit_id = args.commit_id + if commit_id is None: + commit_id = pr_head_sha(owner=owner, repo=repo, pr=args.pr) + + string_fields = {"commit_id": commit_id, "path": args.path, "side": args.side} + int_fields = {"line": args.line} + if args.start_line is not None: + string_fields["start_side"] = args.side if args.start_side is None else args.start_side + int_fields["start_line"] = args.start_line + + post_via_gh_api( + endpoint=f"repos/{owner}/{repo}/pulls/{args.pr}/comments", + body_file=args.body_file, + string_fields=string_fields, + int_fields=int_fields, + timeout_msg="gh api timed out while posting the review comment.", + ) + + +if __name__ == "__main__": + main() + +# ============== 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/skills/address-pr-comments/utils.py b/.claude/skills/address-pr-comments/utils.py index d4bf7ca0..e0a7212c 100644 --- a/.claude/skills/address-pr-comments/utils.py +++ b/.claude/skills/address-pr-comments/utils.py @@ -5,9 +5,12 @@ # 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. # ===================================================================================================== +import json import re import subprocess import sys +from collections.abc import Mapping +from pathlib import Path def run_cmd( @@ -48,3 +51,55 @@ def owner_repo_from_remote() -> tuple[str, str]: _ = sys.stderr.write(f"Cannot parse GitHub owner/repo from remote: {url}\n") sys.exit(1) return match.group(1), match.group(2) + + +def _read_body_file(body_file: Path) -> str: + try: + return body_file.read_text(encoding="utf-8") + except OSError as e: + _ = sys.stderr.write(f"Unable to read body file {body_file}: {e}\n") + sys.exit(1) + + +def post_via_gh_api( + *, + endpoint: str, + body_file: Path, + string_fields: Mapping[str, str] | None = None, + int_fields: Mapping[str, int] | None = None, + timeout_msg: str, +) -> None: + """POST a comment to a GitHub API endpoint with gh api, print its URL, then delete the body file it came from. + + The comment's `body` field is read from body_file. string_fields are sent as strings (--raw-field) and int_fields as + JSON numbers (--field), which is what GitHub expects for values like line numbers. + + Exits with status 1 if gh api fails. Once gh api succeeds the comment exists, so an unparsable response still counts + as posted and the body file is still deleted (otherwise a retry would post a duplicate). A failure to delete the body + file only warns. + """ + body = _read_body_file(body_file) + cmd = ["gh", "api", endpoint, "--method", "POST", "--raw-field", f"body={body}"] + if string_fields is not None: + for key, value in string_fields.items(): + cmd += ["--raw-field", f"{key}={value}"] + if int_fields is not None: + for key, value in int_fields.items(): + cmd += ["--field", f"{key}={value}"] + + try: + result = run_cmd(cmd, timeout=30, timeout_msg=timeout_msg) + except subprocess.CalledProcessError as e: + _ = sys.stderr.write(f"gh api error: {e.stderr}\n") + sys.exit(1) + + try: + url = json.loads(result.stdout).get("html_url", "(no URL in response)") + except json.JSONDecodeError: + url = "(unparsable response)" + _ = sys.stdout.write(f"Posted: {url}\n") + + try: + body_file.unlink() + except OSError as e: + _ = sys.stderr.write(f"Warning: post succeeded but could not delete body file {body_file}: {e}\n") diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a2932857..8dc9ad98 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 b3bf8094..a12d7b12 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.2.0 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Copier template for creating Python libraries and executables diff --git a/.config/.copier-managed-files.json b/.config/.copier-managed-files.json index 9866582b..122fc655 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", @@ -42,6 +41,7 @@ ".claude/skills/address-pr-comments/commit-link.py", ".claude/skills/address-pr-comments/fetch-pr-comments.py", ".claude/skills/address-pr-comments/post-reply.py", + ".claude/skills/address-pr-comments/post-review-comment.py", ".claude/skills/address-pr-comments/utils.py", ".claude/skills/address-pr-comments/verify-env.py", ".claude/skills/caveman/SKILL.md", @@ -50,6 +50,7 @@ ".config/.coveragerc", ".config/.prettierrc", ".config/_typos.toml", + ".config/biome.jsonc", ".config/pylintrc.toml", ".config/pyrefly.toml", ".config/ruff-non-src.toml", @@ -96,7 +97,6 @@ "LICENSE", "README.md", "Taskfile.yaml", - "biome.jsonc", "copier.yml", "copier_template_resources/openapi_schema_simplifier.py", "copier_template_resources/python_asyncio/asyncio_fixtures.py", @@ -130,7 +130,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", @@ -149,6 +148,7 @@ "template/.claude/skills/address-pr-comments/commit-link.py", "template/.claude/skills/address-pr-comments/fetch-pr-comments.py", "template/.claude/skills/address-pr-comments/post-reply.py", + "template/.claude/skills/address-pr-comments/post-review-comment.py", "template/.claude/skills/address-pr-comments/utils.py", "template/.claude/skills/address-pr-comments/verify-env.py", "template/.claude/skills/caveman/SKILL.md", @@ -164,6 +164,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", @@ -197,7 +198,6 @@ "template/AGENTS.md", "template/CHANGELOG.md", "template/README.md.jinja", - "template/biome.jsonc", "template/pytest.ini", "template/scripts/delete_false_positive_rej.py", "template/sh.bat", diff --git a/.config/biome.jsonc b/.config/biome.jsonc new file mode 100644 index 00000000..a9ce85ea --- /dev/null +++ b/.config/biome.jsonc @@ -0,0 +1,56 @@ +/* + * ============== 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. + * ===================================================================================================== + */ +{ + "formatter": { + "indentStyle": "space", + "lineWidth": 120 + }, + "linter": { + "enabled": false + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "files": { + "includes": [ + "**", + // 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 + "!**/schema.graphql", // exported by Strawberry from the Python schema + "!**/*.min.js", + "!**/*.min.css", + "!**/*.umd.js", + "!**/devcontainer-lock.json" // written by the devcontainer CLI, including in Dependabot's devcontainers updates + ] + }, + "overrides": [ + { + // Prettier formats Vue files until Biome's Vue support (html.experimentalFullSupportEnabled) is stable + "includes": ["**/*.vue"], + "formatter": { + "enabled": false + } + }, + { + // pyright only looks for its config under this .json name, but reads it as JSONC + "includes": ["**/pyrightconfig.json"], + "json": { + "parser": { + "allowComments": true + } + } + } + ] +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 330cf286..a189c473 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": { @@ -22,7 +22,7 @@ "extensions": [ // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.21.9", + "coderabbit.coderabbit-vscode@0.23.0", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905", "anthropic.claude-code@2.1.283", @@ -39,7 +39,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -56,13 +56,13 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/copier-python-package-template/.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-python-package-template/.config/pyrefly.toml", - "prettier.configPath": "/workspaces/copier-python-package-template/.config/.prettierrc", - "[jsonc][json][javascript][typescript][graphql][css][scss][html][vue]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "biome.configurationPath": "/workspaces/copier-python-package-template/.config/biome.jsonc", + "[jsonc][json][javascript][typescript][graphql][css]": { + "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true } } - }, + } }, "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index c15425b1..c39c1546 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -25,6 +25,7 @@ IDENTIFY_VERSION = "==2.6.20" PREK_VERSION = "==0.5.5" TASK_VERSION = "==3.53.1" +BIOME_VERSION = "2.5.15" DOWNLOAD_TIMEOUT_SECONDS = 90 # 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. @@ -79,6 +80,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 in CI. Outside CI, this script always installs them.", +) +_ = parser.add_argument( + "--install-copier", + action="store_true", + default=False, + help="Install copier in CI. Outside CI, this script always installs it.", +) + + +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 +235,34 @@ 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 globally through npm. + + 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`. + """ + 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: + global_bin_dir = Path(npm_prefix) + else: + 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]: if is_windows: return pwsh_cmd(cmd) @@ -358,41 +408,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 57d647c8..9f00ac95 100644 --- a/.devcontainer/on-create-command-boilerplate.sh +++ b/.devcontainer/on-create-command-boilerplate.sh @@ -8,6 +8,28 @@ git config --local core.symlinks true sh .devcontainer/create-aws-profile.sh +# The Biome VS Code extension starts a session per workspace folder and warns when folders nest. The root folder's +# session already covers the nested ones, so Biome is switched off in their (gitignored) folder settings. +root_folder_path="." +for workspace_file in *.code-workspace; do + if [ ! -f "$workspace_file" ]; then + continue + fi + folders=$(sed -n 's/^[[:space:]]*"path":[[:space:]]*"\([^"]*\)".*/\1/p' "$workspace_file") + if ! echo "$folders" | grep -qxF "$root_folder_path"; then + continue + fi + printf '%s\n' "$folders" | while IFS= read -r folder; do + case "$folder" in + .. | ../* | /*) continue ;; + esac + if [ "$folder" != "$root_folder_path" ] && [ -d "$folder" ] && [ ! -e "$folder/.vscode/settings.json" ]; then + mkdir -p "$folder/.vscode" + echo '{ "biome.enabled": false }' >"$folder/.vscode/settings.json" + fi + done +done + # ============== WARNING ============================================================================== # File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git # See .config/.copier-managed-files.json for details. diff --git a/.github/actions/install_deps/action.yml b/.github/actions/install_deps/action.yml index 8f684782..b332683e 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/pull_request_template.md b/.github/pull_request_template.md index 395fda8b..ac50221e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,13 +14,13 @@ -## Test plan +## How do we know this works? - + ## Other diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f003c823..e9ed3e2e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -135,6 +135,8 @@ jobs: with: python-version: ${{ matrix.python-version }} 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 e8fa0729..95fefb80 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 9ce4af65..85ffea23 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 @@ -85,46 +85,39 @@ repos: .*/vendor_files/.*| )$ - - repo: https://github.com/biomejs/pre-commit - rev: a450e9c04887b1305951c298db5999cd30155f74 # frozen: v2.1.1 + # `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 - # exclude generated files from codegen and strawberry - exclude: | - (?x)^( - .*generated/graphql.ts| - .*/generated/open[-_]api/.*| - .*/schema.graphql| - .*pyrightconfig\.json| - )$ + 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 hooks: - id: prettier args: [--config, .config/.prettierrc] + # 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)^( - .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)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/AGENTS.md b/AGENTS.md index 7feb1443..61c00ca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,9 +75,12 @@ This project is a Copier template used to generate other copier templates. It is - 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 -- ❌ 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/biome.jsonc b/biome.jsonc deleted file mode 100644 index b16c2aea..00000000 --- a/biome.jsonc +++ /dev/null @@ -1,25 +0,0 @@ -/* - * ============== 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. - * ===================================================================================================== - */ -{ - "formatter": { - "enabled": false, - }, - "linter": { - "enabled": false, - }, - "files": { - "includes": [ - "**", - "!template/template/biome.jsonc", - "!template/biome.jsonc", - "!**/*.css", // still too many false positives, so we exclude CSS files - ], - }, -} diff --git a/copier.yml b/copier.yml index 17f398c2..3c2e84c9 100644 --- a/copier.yml +++ b/copier.yml @@ -176,7 +176,7 @@ _tasks: fi fi - command: | - for script in .claude/skills/address-pr-comments/check-footer.py .claude/skills/address-pr-comments/commit-link.py .claude/skills/address-pr-comments/fetch-pr-comments.py .claude/skills/address-pr-comments/post-reply.py .claude/skills/address-pr-comments/verify-env.py; do + for script in .claude/skills/address-pr-comments/check-footer.py .claude/skills/address-pr-comments/commit-link.py .claude/skills/address-pr-comments/fetch-pr-comments.py .claude/skills/address-pr-comments/post-reply.py .claude/skills/address-pr-comments/post-review-comment.py .claude/skills/address-pr-comments/verify-env.py; do if [ -f "$script" ]; then chmod +x "$script" if git ls-files --error-unmatch "$script" > /dev/null 2>&1; then diff --git a/extensions/context.py b/extensions/context.py index a186b515..2f028a3f 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/hooks/bash-chain-guard.js b/template/.claude/hooks/bash-chain-guard.js deleted file mode 100644 index 0e13f9a2..00000000 --- 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/hooks/caveman-config.js b/template/.claude/hooks/caveman-config.js index c7105376..be247630 100644 --- a/template/.claude/hooks/caveman-config.js +++ b/template/.claude/hooks/caveman-config.js @@ -9,31 +9,25 @@ // - %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' -]; +// 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) { - 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 +40,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 +49,7 @@ function getDefaultMode() { } // 3. Default - return 'full'; + return "full"; } // Symlink-safe flag file write. @@ -86,17 +80,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 +128,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 +136,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 1427e46c..c04e7b90 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 b8016b1a..225e8111 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,23 +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...", - }, - ], - }, - ], - "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...", - }, - ], - }, - ], - }, + "statusMessage": "Tracking caveman mode..." + } + ] + } + ] + } } diff --git a/template/.claude/settings/models.jsonc b/template/.claude/settings/models.jsonc index 25546fe0..d5dda3be 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 7311ff56..889e5190 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 6e59e03d..86f6ba24 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 *)", @@ -130,31 +94,48 @@ "Bash(sort *)", "Bash(tail *)", // Search - "Bash(rg *)", + "Bash(rg *)" // Research ], "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 *)", "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 *)", "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 *)", @@ -206,7 +187,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 dc1600d8..64e1d43d 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 fc262732..d9f40118 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/.claude/skills/address-pr-comments/post-reply.py b/template/.claude/skills/address-pr-comments/post-reply.py index 25673149..4779ae98 100755 --- a/template/.claude/skills/address-pr-comments/post-reply.py +++ b/template/.claude/skills/address-pr-comments/post-reply.py @@ -15,14 +15,12 @@ """ import argparse -import json -import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from utils import owner_repo_from_remote -from utils import run_cmd +from utils import post_via_gh_api def main() -> None: @@ -38,11 +36,6 @@ def main() -> None: args = parser.parse_args() owner, repo = owner_repo_from_remote() - try: - body = args.body_file.read_text(encoding="utf-8") - except OSError as e: - _ = sys.stderr.write(f"Unable to read body file {args.body_file}: {e}\n") - sys.exit(1) if args.comment_type == "pulls/comments": # Reply directly into the inline review comment thread. @@ -51,31 +44,7 @@ def main() -> None: # GitHub has no "reply" concept for issue comments — post a new top-level comment. endpoint = f"repos/{owner}/{repo}/issues/{args.pr}/comments" - try: - result = run_cmd( - [ - "gh", - "api", - endpoint, - "--method", - "POST", - "--raw-field", - f"body={body}", - ], - timeout=30, - timeout_msg="gh api timed out while posting reply.", - ) - except subprocess.CalledProcessError as e: - _ = sys.stderr.write(f"gh api error: {e.stderr}\n") - sys.exit(1) - - response = json.loads(result.stdout) - _ = sys.stdout.write(f"Posted: {response.get('html_url', '(no URL in response)')}\n") - - try: - args.body_file.unlink() - except OSError as e: - _ = sys.stderr.write(f"Warning: post succeeded but could not delete body file {args.body_file}: {e}\n") + post_via_gh_api(endpoint=endpoint, body_file=args.body_file, timeout_msg="gh api timed out while posting reply.") if __name__ == "__main__": diff --git a/template/.claude/skills/address-pr-comments/post-review-comment.py b/template/.claude/skills/address-pr-comments/post-review-comment.py new file mode 100755 index 00000000..f5813f88 --- /dev/null +++ b/template/.claude/skills/address-pr-comments/post-review-comment.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Post a new inline review comment on a PR diff via the GitHub API. + +Usage: + # Comment on one line of the new file: + post-review-comment.py --pr --path --line --body-file + + # Comment spanning a range of lines (start and end must be in the same diff hunk): + post-review-comment.py --pr --path --start-line --line --body-file + + # Comment on a removed line (the old file's numbering): + post-review-comment.py --pr --path --line --side LEFT --body-file + +Line numbers are those of the file at --commit-id (default: the PR's current head) for --side RIGHT, or of the base +file for --side LEFT. Owner and repo are derived from the git remote automatically. + +This script exists so that gh api calls are covered by the skills allow-list +rather than requiring manual approval each time. +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from utils import owner_repo_from_remote +from utils import post_via_gh_api +from utils import run_cmd + + +def pr_head_sha(*, owner: str, repo: str, pr: int) -> str: + try: + result = run_cmd( + ["gh", "api", f"repos/{owner}/{repo}/pulls/{pr}", "--jq", ".head.sha"], + timeout=30, + timeout_msg="gh api timed out while reading the PR head commit.", + ) + except subprocess.CalledProcessError as e: + _ = sys.stderr.write(f"gh api error reading PR #{pr}: {e.stderr}\n") + sys.exit(1) + return result.stdout.strip() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + _ = parser.add_argument("--pr", type=int, required=True, help="PR number") + _ = parser.add_argument("--path", required=True, help="File path relative to the repo root") + _ = parser.add_argument("--line", type=int, required=True, help="Line the comment ends on") + _ = parser.add_argument("--start-line", type=int, help="First line of a multi-line comment") + _ = parser.add_argument("--side", choices=["RIGHT", "LEFT"], default="RIGHT", help="RIGHT = new file, LEFT = old") + _ = parser.add_argument("--start-side", choices=["RIGHT", "LEFT"], help="Defaults to --side") + _ = parser.add_argument("--commit-id", help="Commit the line numbers refer to (default: the PR's head)") + _ = parser.add_argument("--body-file", required=True, type=Path) + args = parser.parse_args() + + if args.start_line is not None and args.start_line >= args.line: + _ = sys.stderr.write(f"--start-line ({args.start_line}) must be less than --line ({args.line})\n") + sys.exit(1) + + owner, repo = owner_repo_from_remote() + + commit_id = args.commit_id + if commit_id is None: + commit_id = pr_head_sha(owner=owner, repo=repo, pr=args.pr) + + string_fields = {"commit_id": commit_id, "path": args.path, "side": args.side} + int_fields = {"line": args.line} + if args.start_line is not None: + string_fields["start_side"] = args.side if args.start_side is None else args.start_side + int_fields["start_line"] = args.start_line + + post_via_gh_api( + endpoint=f"repos/{owner}/{repo}/pulls/{args.pr}/comments", + body_file=args.body_file, + string_fields=string_fields, + int_fields=int_fields, + timeout_msg="gh api timed out while posting the review comment.", + ) + + +if __name__ == "__main__": + main() + +# ============== 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/skills/address-pr-comments/utils.py b/template/.claude/skills/address-pr-comments/utils.py index d4bf7ca0..e0a7212c 100644 --- a/template/.claude/skills/address-pr-comments/utils.py +++ b/template/.claude/skills/address-pr-comments/utils.py @@ -5,9 +5,12 @@ # 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. # ===================================================================================================== +import json import re import subprocess import sys +from collections.abc import Mapping +from pathlib import Path def run_cmd( @@ -48,3 +51,55 @@ def owner_repo_from_remote() -> tuple[str, str]: _ = sys.stderr.write(f"Cannot parse GitHub owner/repo from remote: {url}\n") sys.exit(1) return match.group(1), match.group(2) + + +def _read_body_file(body_file: Path) -> str: + try: + return body_file.read_text(encoding="utf-8") + except OSError as e: + _ = sys.stderr.write(f"Unable to read body file {body_file}: {e}\n") + sys.exit(1) + + +def post_via_gh_api( + *, + endpoint: str, + body_file: Path, + string_fields: Mapping[str, str] | None = None, + int_fields: Mapping[str, int] | None = None, + timeout_msg: str, +) -> None: + """POST a comment to a GitHub API endpoint with gh api, print its URL, then delete the body file it came from. + + The comment's `body` field is read from body_file. string_fields are sent as strings (--raw-field) and int_fields as + JSON numbers (--field), which is what GitHub expects for values like line numbers. + + Exits with status 1 if gh api fails. Once gh api succeeds the comment exists, so an unparsable response still counts + as posted and the body file is still deleted (otherwise a retry would post a duplicate). A failure to delete the body + file only warns. + """ + body = _read_body_file(body_file) + cmd = ["gh", "api", endpoint, "--method", "POST", "--raw-field", f"body={body}"] + if string_fields is not None: + for key, value in string_fields.items(): + cmd += ["--raw-field", f"{key}={value}"] + if int_fields is not None: + for key, value in int_fields.items(): + cmd += ["--field", f"{key}={value}"] + + try: + result = run_cmd(cmd, timeout=30, timeout_msg=timeout_msg) + except subprocess.CalledProcessError as e: + _ = sys.stderr.write(f"gh api error: {e.stderr}\n") + sys.exit(1) + + try: + url = json.loads(result.stdout).get("html_url", "(no URL in response)") + except json.JSONDecodeError: + url = "(unparsable response)" + _ = sys.stdout.write(f"Posted: {url}\n") + + try: + body_file.unlink() + except OSError as e: + _ = sys.stderr.write(f"Warning: post succeeded but could not delete body file {body_file}: {e}\n") diff --git a/template/.coderabbit.yaml b/template/.coderabbit.yaml index a2932857..8dc9ad98 100644 --- a/template/.coderabbit.yaml +++ b/template/.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/template/.config/biome.jsonc b/template/.config/biome.jsonc new file mode 100644 index 00000000..a9ce85ea --- /dev/null +++ b/template/.config/biome.jsonc @@ -0,0 +1,56 @@ +/* + * ============== 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. + * ===================================================================================================== + */ +{ + "formatter": { + "indentStyle": "space", + "lineWidth": 120 + }, + "linter": { + "enabled": false + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "files": { + "includes": [ + "**", + // 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 + "!**/schema.graphql", // exported by Strawberry from the Python schema + "!**/*.min.js", + "!**/*.min.css", + "!**/*.umd.js", + "!**/devcontainer-lock.json" // written by the devcontainer CLI, including in Dependabot's devcontainers updates + ] + }, + "overrides": [ + { + // Prettier formats Vue files until Biome's Vue support (html.experimentalFullSupportEnabled) is stable + "includes": ["**/*.vue"], + "formatter": { + "enabled": false + } + }, + { + // pyright only looks for its config under this .json name, but reads it as JSONC + "includes": ["**/pyrightconfig.json"], + "json": { + "parser": { + "allowComments": true + } + } + } + ] +} diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index 209ff26b..f2e64b45 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -12,20 +12,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": { @@ -34,7 +34,7 @@ "-AmazonWebServices.aws-toolkit-vscode", // the AWS CLI feature installs this automatically, but it's causing problems in VS Code{% endraw %}{% endif %}{% raw %} // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.21.9", + "coderabbit.coderabbit-vscode@0.23.0", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905",{% endraw %}{% if install_claude_cli %}{% raw %} "anthropic.claude-code@2.1.283",{% endraw %}{% endif %}{% raw %}{% endraw %}{% if (is_child_of_copier_base_template is defined and is_child_of_copier_base_template is sameas(true)) or (is_child_of_copier_base_template is not defined and template_uses_python is defined and template_uses_python is sameas(true)) %}{% raw %} @@ -49,6 +49,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", @@ -58,7 +59,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -75,20 +76,25 @@ // 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 %} + "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", - "[jsonc][json][javascript][typescript][graphql][css][scss][html][vue]": { + "[vue][html]": { "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 %} + }{% 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 %} }, "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index 544c4f89..02fdcb60 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -18,6 +18,7 @@ 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 # 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. @@ -72,6 +73,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 in CI. Outside CI, this script always installs them.", +) +_ = parser.add_argument( + "--install-copier", + action="store_true", + default=False, + help="Install copier in CI. Outside CI, this script always installs it.", +) + + +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 +228,34 @@ 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 globally through npm. + + 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`. + """ + 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: + global_bin_dir = Path(npm_prefix) + else: + 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]: if is_windows: return pwsh_cmd(cmd) @@ -351,41 +401,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 57d647c8..9f00ac95 100644 --- a/template/.devcontainer/on-create-command-boilerplate.sh +++ b/template/.devcontainer/on-create-command-boilerplate.sh @@ -8,6 +8,28 @@ git config --local core.symlinks true sh .devcontainer/create-aws-profile.sh +# The Biome VS Code extension starts a session per workspace folder and warns when folders nest. The root folder's +# session already covers the nested ones, so Biome is switched off in their (gitignored) folder settings. +root_folder_path="." +for workspace_file in *.code-workspace; do + if [ ! -f "$workspace_file" ]; then + continue + fi + folders=$(sed -n 's/^[[:space:]]*"path":[[:space:]]*"\([^"]*\)".*/\1/p' "$workspace_file") + if ! echo "$folders" | grep -qxF "$root_folder_path"; then + continue + fi + printf '%s\n' "$folders" | while IFS= read -r folder; do + case "$folder" in + .. | ../* | /*) continue ;; + esac + if [ "$folder" != "$root_folder_path" ] && [ -d "$folder" ] && [ ! -e "$folder/.vscode/settings.json" ]; then + mkdir -p "$folder/.vscode" + echo '{ "biome.enabled": false }' >"$folder/.vscode/settings.json" + fi + done +done + # ============== WARNING ============================================================================== # File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git # See .config/.copier-managed-files.json for details. diff --git a/template/.github/actions/install_deps/action.yml b/template/.github/actions/install_deps/action.yml index 8f684782..b332683e 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/pull_request_template.md b/template/.github/pull_request_template.md index 395fda8b..ac50221e 100644 --- a/template/.github/pull_request_template.md +++ b/template/.github/pull_request_template.md @@ -14,13 +14,13 @@ -## Test plan +## How do we know this works? - + ## Other diff --git a/template/.github/workflows/pre-commit.yaml b/template/.github/workflows/pre-commit.yaml index e8fa0729..95fefb80 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/.pre-commit-config.yaml b/template/.pre-commit-config.yaml index ee17c231..cff15d94 100644 --- a/template/.pre-commit-config.yaml +++ b/template/.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 @@ -85,46 +85,39 @@ repos: .*/vendor_files/.*| )$ - - repo: https://github.com/biomejs/pre-commit - rev: a450e9c04887b1305951c298db5999cd30155f74 # frozen: v2.1.1 + # `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 - # exclude generated files from codegen and strawberry - exclude: | - (?x)^( - .*generated/graphql.ts| - .*/generated/open[-_]api/.*| - .*/schema.graphql| - .*pyrightconfig\.json| - )$ + 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 hooks: - id: prettier args: [--config, .config/.prettierrc] + # 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)^( - .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)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/template/AGENTS.md b/template/AGENTS.md index 793feddf..94e1e464 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -75,9 +75,12 @@ This project is a Python library. - 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 -- ❌ 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/biome.jsonc b/template/biome.jsonc deleted file mode 100644 index b16c2aea..00000000 --- a/template/biome.jsonc +++ /dev/null @@ -1,25 +0,0 @@ -/* - * ============== 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. - * ===================================================================================================== - */ -{ - "formatter": { - "enabled": false, - }, - "linter": { - "enabled": false, - }, - "files": { - "includes": [ - "**", - "!template/template/biome.jsonc", - "!template/biome.jsonc", - "!**/*.css", // still too many false positives, so we exclude CSS files - ], - }, -} From d8feaba139a08202a1bc3342ff267a20a595532a Mon Sep 17 00:00:00 2001 From: Eli Fine <5436635+ejfine@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:16:40 +0000 Subject: [PATCH 2/2] Update devcontainer context hash --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a189c473..0bb9fd8d 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): 87a791dc # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): b983c896 # spellchecker:disable-line }