diff --git a/Brewfile b/Brewfile index 97fa1ef..d8f6b8e 100644 --- a/Brewfile +++ b/Brewfile @@ -18,6 +18,7 @@ brew "pyenv" brew "tmux" brew "tree" brew "wget" +brew "yq" brew "zsh-autosuggestions" brew "zsh-syntax-highlighting" cask "font-hack-nerd-font" diff --git a/README.md b/README.md index 49af985..022b899 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,44 @@ cd into the `dotfiles` directory, and then run: ./assimilate.sh ``` +The installer shares `agent-instructions.md` with Claude Code and Codex by +linking it to `~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md`. + +### Codex configuration + +`assimilate.sh` uses `yq` to structurally merge the portable root settings from +`codex/config.managed.toml` into `~/.codex/config.toml`. It validates the merged +file with Codex's strict config loader before replacing the live config +atomically. Settings outside the managed fragment are preserved, and the first +existing config is retained as `~/.codex/config.toml.dotfiles-backup`. + +The checked-in settings enable turn notifications, automatic review, low +verbosity, concise reasoning summaries, and the pragmatic personality. Codex +uses `AGENTS.md` natively and falls back to `CLAUDE.md` when it is absent. + +Enable continuous Claude Code import once in the Codex UI and leave session +imports disabled. Those choices are desktop-local state and are deliberately +not automated through internal state files or JSON-RPC APIs. + +The portable Git preferences—branch prefix, squash merge method, and PR and +commit instructions—are managed in the schema-supported `[desktop]` table. +Other desktop preferences such as appearance, editor target, and queue behavior +remain local. Generated and machine-specific state such as local paths, +installed plugins, connector authentication, caches, session history, and +per-project trust also remains local. Never check in +`.codex-global-state.json`, Codex session databases, or the complete live +`config.toml`. + +### Claude Code configuration + +`assimilate.sh` uses `jq` to structurally merge the portable settings from +`claude/settings.managed.json` into `~/.claude/settings.json`. Local settings +such as permissions, hooks, plugins, and model selection are preserved. The +managed fragment enables concise output, configures the checked-in statusline, +and disables commit attribution, PR attribution, and cloud or Remote Control +session links. The first existing settings file is retained as +`~/.claude/settings.json.dotfiles-backup`. + ### Tmux Plugin Manager - Install Tmux Plugin Manager ([Github](https://github.com/tmux-plugins/tpm#tmux-plugin-manager)) - Install tmux packages with `prefix + I` diff --git a/agent-instructions.md b/agent-instructions.md new file mode 100644 index 0000000..08a891e --- /dev/null +++ b/agent-instructions.md @@ -0,0 +1,19 @@ +# Commit Instructions + +Commit messages should be concise, just one sentence + +# Pull Request Instructions + +Create pull requests with the simple format + +## Summary + + +## Test Plan + + +# Code Formatting Instructions + +Single-line commands or code within a sentence should be wrapped in backticks + +Multi-line commands or code should use fenced code blocks with triple backticks diff --git a/assimilate.sh b/assimilate.sh index e445ec1..336b923 100755 --- a/assimilate.sh +++ b/assimilate.sh @@ -3,7 +3,7 @@ set -ex PREFIX="$HOME" -DOTFILES="$PREFIX/dotfiles" +DOTFILES=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) BACKUPS="$PREFIX/backups" function sym () { @@ -44,10 +44,15 @@ sym ghostty/config Library/Application\ Support/com.mitchellh.ghostty/confi sym tmux-powerline/config.sh .config/tmux-powerline/config.sh sym tmux-powerline/themes/theme.sh .config/tmux-powerline/themes/theme.sh sym claude/statusline.sh .claude/statusline.sh +sym agent-instructions.md .claude/CLAUDE.md +sym agent-instructions.md .codex/AGENTS.md sym zed/keymap.json .config/zed/keymap.json brew bundle install +"$DOTFILES/claude/sync-settings.sh" +"$DOTFILES/codex/sync-config.sh" + function clone_pinned () { url="$1"; dir="$2"; sha="$3" if [ ! -d "$dir" ]; then diff --git a/claude/settings.managed.json b/claude/settings.managed.json new file mode 100644 index 0000000..b94cf63 --- /dev/null +++ b/claude/settings.managed.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + }, + "outputStyle": "Concise", + "statusLine": { + "type": "command", + "command": "~/.claude/statusline.sh", + "padding": 0 + } +} diff --git a/claude/sync-settings.sh b/claude/sync-settings.sh new file mode 100755 index 0000000..26a832f --- /dev/null +++ b/claude/sync-settings.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +managed_settings="$script_dir/settings.managed.json" +claude_home=${CLAUDE_CONFIG_DIR:-"$HOME/.claude"} +dest_settings="$claude_home/settings.json" +backup_settings="$claude_home/settings.json.dotfiles-backup" + +if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required to sync Claude settings" >&2 + exit 1 +fi + +mkdir -p "$claude_home" +tmp_settings=$(mktemp "$claude_home/.settings.json.XXXXXX") + +cleanup() { + rm -f "$tmp_settings" +} +trap cleanup EXIT HUP INT TERM + +if [ -f "$dest_settings" ]; then + jq -s '.[0] * .[1]' "$dest_settings" "$managed_settings" > "$tmp_settings" +else + jq '.' "$managed_settings" > "$tmp_settings" +fi + +jq -e 'type == "object"' "$tmp_settings" >/dev/null +chmod 600 "$tmp_settings" + +if [ -f "$dest_settings" ] && [ ! -f "$backup_settings" ]; then + cp -p "$dest_settings" "$backup_settings" + chmod 600 "$backup_settings" +fi + +mv "$tmp_settings" "$dest_settings" +tmp_settings= + +echo "> Synced managed Claude settings to $dest_settings" diff --git a/codex/config.managed.toml b/codex/config.managed.toml new file mode 100644 index 0000000..2648f26 --- /dev/null +++ b/codex/config.managed.toml @@ -0,0 +1,23 @@ +#:schema https://developers.openai.com/codex/config-schema.json + +notify = ["/bin/sh", "-c", "/usr/bin/osascript -e 'display notification \"Turn completed\" with title \"Codex\"' >/dev/null 2>&1"] +model_reasoning_summary = "concise" +model_verbosity = "low" +personality = "pragmatic" +approvals_reviewer = "auto_review" + +# AGENTS.md is built in; use CLAUDE.md when no AGENTS.md is present. +project_doc_fallback_filenames = ["CLAUDE.md"] +project_doc_max_bytes = 196608 + +[desktop] +git-branch-prefix = "akan72/" +git-pull-request-merge-method = "squash" +git-commit-instructions = "Commit messages should be concise, just one sentence" +git-pr-instructions = """Create pull requests with the simple format + +## Summary + + +## Test Plan +""" diff --git a/codex/sync-config.sh b/codex/sync-config.sh new file mode 100755 index 0000000..5423ec0 --- /dev/null +++ b/codex/sync-config.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env sh + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +managed_config="$script_dir/config.managed.toml" +codex_home=${CODEX_HOME:-"$HOME/.codex"} +dest_config="$codex_home/config.toml" +backup_config="$codex_home/config.toml.dotfiles-backup" + +if ! command -v yq >/dev/null 2>&1; then + echo "error: yq is required to sync Codex settings" >&2 + exit 1 +fi + +if ! command -v codex >/dev/null 2>&1; then + echo "error: codex is required to validate Codex settings" >&2 + exit 1 +fi + +mkdir -p "$codex_home" +tmp_config=$(mktemp "$codex_home/.config.toml.XXXXXX") +validation_home=$(mktemp -d "${TMPDIR:-/tmp}/codex-config-validation.XXXXXX") + +cleanup() { + rm -f "$tmp_config" + rm -rf "$validation_home" +} +trap cleanup EXIT HUP INT TERM + +if [ -f "$dest_config" ]; then + yq eval-all \ + --input-format=toml \ + --output-format=toml \ + 'select(fileIndex == 0) * select(fileIndex == 1)' \ + "$dest_config" "$managed_config" > "$tmp_config" +else + cp "$managed_config" "$tmp_config" +fi + +chmod 600 "$tmp_config" +cp "$tmp_config" "$validation_home/config.toml" +validation_log="$validation_home/stderr.log" +if ! CODEX_HOME="$validation_home" \ + codex app-server --strict-config --listen stdio:// \ + /dev/null 2>"$validation_log"; then + cat "$validation_log" >&2 + exit 1 +fi + +if [ -f "$dest_config" ] && [ ! -f "$backup_config" ]; then + cp -p "$dest_config" "$backup_config" + chmod 600 "$backup_config" +fi + +mv "$tmp_config" "$dest_config" +tmp_config= + +echo "> Synced managed Codex settings to $dest_config" diff --git a/tests/assimilate-path.sh b/tests/assimilate-path.sh new file mode 100755 index 0000000..1a222aa --- /dev/null +++ b/tests/assimilate-path.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh + +set -eu + +test_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$test_dir/.." && pwd) +test_home=$(mktemp -d "${TMPDIR:-/tmp}/assimilate-path.XXXXXX") +codex_marker="$test_home/codex-called" + +cleanup() { + rm -rf "$test_home" +} +trap cleanup EXIT HUP INT TERM + +set +e +HOME="$test_home" \ + PATH="$test_dir/fixtures/bin:$PATH" \ + TEST_CODEX_MARKER="$codex_marker" \ + sh "$repo_root/assimilate.sh" >/dev/null 2>&1 +status=$? +set -e + +if [ "$status" -ne 1 ]; then + echo "expected assimilate.sh to reach the stubbed codex command, got status $status" >&2 + exit 1 +fi + +if [ ! -f "$codex_marker" ]; then + echo "expected the checkout's Codex sync script to invoke codex" >&2 + exit 1 +fi + +test "$(readlink "$test_home/.claude/CLAUDE.md")" = "$repo_root/agent-instructions.md" +test "$(readlink "$test_home/.codex/AGENTS.md")" = "$repo_root/agent-instructions.md" +test "$(readlink "$test_home/.claude/statusline.sh")" = "$repo_root/claude/statusline.sh" +test -f "$test_home/.claude/settings.json" +test ! -L "$test_home/.claude/settings.json" +test "$(jq -r '.outputStyle' "$test_home/.claude/settings.json")" = "Concise" + +expected_args='app-server --strict-config --listen stdio://' +actual_args=$(sed -n '1p' "$codex_marker") +if [ "$actual_args" != "$expected_args" ]; then + echo "expected Codex validator arguments $expected_args, got $actual_args" >&2 + exit 1 +fi + +echo "assimilate.sh resolves files relative to its own checkout" diff --git a/tests/claude-sync-settings.sh b/tests/claude-sync-settings.sh new file mode 100755 index 0000000..6822d34 --- /dev/null +++ b/tests/claude-sync-settings.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh + +set -eu + +test_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$test_dir/.." && pwd) +test_root=$(mktemp -d "${TMPDIR:-/tmp}/claude-sync.XXXXXX") +claude_home="$test_root/claude-home" +invalid_home="$test_root/invalid-home" + +cleanup() { + rm -rf "$test_root" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "$claude_home" "$invalid_home" +cp "$test_dir/fixtures/claude/settings.json" "$claude_home/settings.json" +cp "$test_dir/fixtures/claude/invalid-settings.json" "$invalid_home/settings.json" + +original_hash=$(shasum -a 256 "$claude_home/settings.json" | awk '{print $1}') +CLAUDE_CONFIG_DIR="$claude_home" "$repo_root/claude/sync-settings.sh" + +settings="$claude_home/settings.json" +backup="$claude_home/settings.json.dotfiles-backup" +managed="$repo_root/claude/settings.managed.json" + +test "$(jq -r '.attribution.commit' "$settings")" = "" +test "$(jq -r '.attribution.pr' "$settings")" = "" +test "$(jq -r '.attribution.sessionUrl' "$settings")" = "false" +test "$(jq -r '.outputStyle' "$settings")" = "Concise" +test "$(jq -r '.statusLine.type' "$settings")" = "command" +test "$(jq -r '.statusLine.command' "$settings")" = "~/.claude/statusline.sh" +test "$(jq -r '.statusLine.padding' "$settings")" = "0" +test "$(jq -r '.model' "$settings")" = "keep-me" +test "$(jq -r '.enabledPlugins.local' "$settings")" = "true" +test "$(jq -r 'has("permissions")' "$managed")" = "false" +test "$(jq -r 'has("hooks")' "$managed")" = "false" +test "$(jq -r 'has("enabledPlugins")' "$managed")" = "false" +test "$(stat -f '%Lp' "$settings")" = "600" +test -f "$backup" +test "$(shasum -a 256 "$backup" | awk '{print $1}')" = "$original_hash" + +first_hash=$(shasum -a 256 "$settings" | awk '{print $1}') +CLAUDE_CONFIG_DIR="$claude_home" "$repo_root/claude/sync-settings.sh" +second_hash=$(shasum -a 256 "$settings" | awk '{print $1}') +test "$first_hash" = "$second_hash" +test "$(shasum -a 256 "$backup" | awk '{print $1}')" = "$original_hash" + +invalid_hash=$(shasum -a 256 "$invalid_home/settings.json" | awk '{print $1}') +set +e +CLAUDE_CONFIG_DIR="$invalid_home" "$repo_root/claude/sync-settings.sh" >/dev/null 2>&1 +status=$? +set -e +test "$status" -ne 0 +test "$(shasum -a 256 "$invalid_home/settings.json" | awk '{print $1}')" = "$invalid_hash" + +echo "Claude settings sync applies portable defaults and preserves local settings" diff --git a/tests/codex-sync-config.sh b/tests/codex-sync-config.sh new file mode 100755 index 0000000..b0d3910 --- /dev/null +++ b/tests/codex-sync-config.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env sh + +set -eu + +test_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$test_dir/.." && pwd) +test_root=$(mktemp -d "${TMPDIR:-/tmp}/codex-sync.XXXXXX") +codex_home="$test_root/codex-home" +invalid_home="$test_root/invalid-home" + +cleanup() { + rm -rf "$test_root" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "$codex_home" "$invalid_home" +cp "$test_dir/fixtures/codex/config.toml" "$codex_home/config.toml" +cp "$test_dir/fixtures/codex/invalid-config.toml" "$invalid_home/config.toml" + +original_hash=$(shasum -a 256 "$codex_home/config.toml" | awk '{print $1}') +CODEX_HOME="$codex_home" "$repo_root/codex/sync-config.sh" + +config="$codex_home/config.toml" +backup="$codex_home/config.toml.dotfiles-backup" + +assert_value() { + expression=$1 + expected=$2 + actual=$(yq --input-format=toml --output-format=yaml --unwrapScalar "$expression" "$config") + if [ "$actual" != "$expected" ]; then + echo "expected $expression to be $expected, got $actual" >&2 + exit 1 + fi +} + +assert_value '.model' 'keep-me' +assert_value '.model_verbosity' 'low' +assert_value '.model_reasoning_summary' 'concise' +assert_value '.personality' 'pragmatic' +assert_value '.approvals_reviewer' 'auto_review' +assert_value '.project_doc_fallback_filenames[0]' 'CLAUDE.md' +assert_value '.project_doc_max_bytes' '196608' +assert_value '.notify[0]' '/bin/sh' +assert_value '.desktop."git-branch-prefix"' 'akan72/' +assert_value '.desktop."git-pull-request-merge-method"' 'squash' +assert_value '.desktop."git-commit-instructions"' 'Commit messages should be concise, just one sentence' +assert_value '.desktop."git-pr-instructions" | contains("## Summary")' 'true' +assert_value '.desktop."ambient-suggestions-enabled"' 'false' +assert_value '.projects."/tmp/example".trust_level' 'trusted' +assert_value '.mcp_servers.local.command' 'true' + +test "$(stat -f '%Lp' "$config")" = "600" +test -f "$backup" +test "$(shasum -a 256 "$backup" | awk '{print $1}')" = "$original_hash" + +first_hash=$(shasum -a 256 "$config" | awk '{print $1}') +CODEX_HOME="$codex_home" "$repo_root/codex/sync-config.sh" +second_hash=$(shasum -a 256 "$config" | awk '{print $1}') +test "$first_hash" = "$second_hash" +test "$(shasum -a 256 "$backup" | awk '{print $1}')" = "$original_hash" + +invalid_hash=$(shasum -a 256 "$invalid_home/config.toml" | awk '{print $1}') +set +e +CODEX_HOME="$invalid_home" "$repo_root/codex/sync-config.sh" >/dev/null 2>&1 +status=$? +set -e +test "$status" -ne 0 +test "$(shasum -a 256 "$invalid_home/config.toml" | awk '{print $1}')" = "$invalid_hash" + +echo "Codex config sync preserves local settings and applies managed values" diff --git a/tests/fixtures/bin/brew b/tests/fixtures/bin/brew new file mode 100755 index 0000000..3e156ec --- /dev/null +++ b/tests/fixtures/bin/brew @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +exit 0 diff --git a/tests/fixtures/bin/codex b/tests/fixtures/bin/codex new file mode 100755 index 0000000..bc3480a --- /dev/null +++ b/tests/fixtures/bin/codex @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +printf '%s\n' "$*" > "$TEST_CODEX_MARKER" +exit 1 diff --git a/tests/fixtures/claude/invalid-settings.json b/tests/fixtures/claude/invalid-settings.json new file mode 100644 index 0000000..cbad912 --- /dev/null +++ b/tests/fixtures/claude/invalid-settings.json @@ -0,0 +1,3 @@ +{ + "invalid": +} diff --git a/tests/fixtures/claude/settings.json b/tests/fixtures/claude/settings.json new file mode 100644 index 0000000..ac760d4 --- /dev/null +++ b/tests/fixtures/claude/settings.json @@ -0,0 +1,10 @@ +{ + "model": "keep-me", + "attribution": { + "commit": "old commit attribution", + "pr": "old PR attribution" + }, + "enabledPlugins": { + "local": true + } +} diff --git a/tests/fixtures/codex/config.toml b/tests/fixtures/codex/config.toml new file mode 100644 index 0000000..95d9847 --- /dev/null +++ b/tests/fixtures/codex/config.toml @@ -0,0 +1,12 @@ +model = "keep-me" +model_verbosity = "high" + +[desktop] +git-branch-prefix = "local/" +ambient-suggestions-enabled = false + +[projects."/tmp/example"] +trust_level = "trusted" + +[mcp_servers.local] +command = "true" diff --git a/tests/fixtures/codex/invalid-config.toml b/tests/fixtures/codex/invalid-config.toml new file mode 100644 index 0000000..51cccb1 --- /dev/null +++ b/tests/fixtures/codex/invalid-config.toml @@ -0,0 +1 @@ +not_a_real_codex_setting = true