diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md new file mode 100644 index 0000000..b25984b --- /dev/null +++ b/claude/CLAUDE.md @@ -0,0 +1,12 @@ +# Personal instructions + +## Where code lives +- "The code", "the repo", "the monorepo", or any general code question refers to `~/Projects/cleo-infinite`. +- Anything mentioning Retool refers to `~/Projects/cleo-retool`. + +## Worktrees +- Never write or edit code in the main checkouts (`~/Projects/cleo-infinite`, `~/Projects/cleo-retool`). Work in a treehouse worktree under `~/.treehouse/-//`. Check with `git rev-parse --show-toplevel` and `git worktree list`. +- If code changes are needed and the current directory is not a treehouse worktree, start one yourself without asking first, then continue there. Read-only investigation can stay where it is. +- Treehouse is a zsh alias for `nix run github:kunchenguid/treehouse --`, so it is not on PATH in non-interactive shells. Run the full `nix run` form, and run it with `--help` first rather than guessing subcommands or flags. +- Branch names follow the repo's own rules (cleo-infinite: `-short_slug` from latest `origin/main`). Take the Jira key from the user or the current branch. If there isn't one, ask. Never invent a key. +- A pool slot sitting on a detached HEAD is not necessarily free. Don't reuse a slot that has uncommitted changes or that another session may be using. diff --git a/claude/auto-mode.json b/claude/auto-mode.json new file mode 100644 index 0000000..853bb4d --- /dev/null +++ b/claude/auto-mode.json @@ -0,0 +1,21 @@ +{ + "environment": [ + "Organization: hicleo / Cleo (startwithlucy GitHub org; Jira at hicleo.atlassian.net)", + "Cloud provider: AWS, accessed via SSO with Granted (`assume`, `assume -c`)", + "Trusted repos: startwithlucy/cleo-infinite (origin) and git.thiccdata.io:mbessette/cleo-infinite.git (home). Both private, so confidential material is fine there, but secrets, PII and PHI are never cleared by visibility alone", + "No internal snippet hosting exists: treat public paste and gist services as outside the trust boundary", + "Secrets are held as CI secrets (CODECOMMIT_SSH_PRIVATE_KEY, CODECOMMIT_SSH_PRIVATE_KEY_ID, DD_API_KEY, GITHUB_TOKEN, JIRA_API_KEY, JIRA_EMAIL, SLACK_BOT_TOKEN, SNYK_KEY); never print or transmit their values", + "Default branch is main. The `Copilot Review` ruleset is active and 100+ named branches are protected, so treat branches as protected even where classic protection lists are empty", + "Trusted internal domain: hicleo.atlassian.net. sr71.sdet.hicleo.com (VPN-gated Jenkins) may be used interactively with $JENKINS_USER and $JENKINS_TOKEN from the shell env, never unattended", + "Routine CLIs: mise, yarn (Yarn 4 workspaces), buf, assume/Granted, gh, jira", + "Sensitive data locations: .env, .env.local, .env.test, .env.production and nested equivalents, *.key files, frontends/*/.env*, cicd/deploy-manager/chalicelib/code_prod_deploy_service.py, and infra-space/** terraform state and apply/plan/destroy scripts", + "Sensitive remote targets: any namespace, host or container with `prod` or `production` as a whole word or segment, including pre-prod-enroll and enroll.hicleo.com", + "Protected IaC scopes: infra-space/core/**, infra-space/environment/**, infra-space/applications/**, frontends/web-messaging/infra/**, plus IAM, RBAC, networking, quota and node-pool resources and anything named prod/production", + "Claude Code runs on an ordinary developer machine with open internet; no host containment or network restrictions" + ], + "allow": [], + "soft_deny": [ + "Bash(terraform destroy:*) in infra-space/** and frontends/web-messaging/infra/**: protected IaC, prod-adjacent destructive apply", + "Bash(rm:*) targeting infra-space/**/terraform.tfstate or any .tfstate file: irreversible state destruction" + ] +} diff --git a/claude/skills/prod-db/SKILL.md b/claude/skills/prod-db/SKILL.md new file mode 100644 index 0000000..3eae30e --- /dev/null +++ b/claude/skills/prod-db/SKILL.md @@ -0,0 +1,68 @@ +--- +name: prod-db +description: >- + Look up data in Cleo production read replicas (Postgres, IAM auth, read-only). + Use when the user asks to look up, find, or check something in prod / the prod + DB, e.g. a user id, family id, guide id, or other record IDs for a given + user. Returns IDs and metadata only, never PII/PHI. +user-invocable: true +argument-hint: '[question about prod data]' +--- +# prod-db + +Query prod read replicas through one script. Never call `psql` or `aws rds` directly. + +```sh +~/.config/claude/skills/prod-db/prod-query.sh "" +# prod-query.sh --tables +# prod-query.sh --columns +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROFILE="prod-mono-reader" +REGION="us-east-1" +DB_USER="reader" +MAX_ROWS=200 +DENY_COLS='email|name|phone|birth|dob|address|street|zip|postal|ssn|traits|note|body|content|message|text|diagnos|medical|password|token|secret' + +die() { echo "prod-query: $*" >&2; exit 1; } + +[[ $# -ge 2 ]] || die "usage: prod-query.sh \"
" + +alias_name="$1"; shift +row="$(awk -F'\t' -v a="$alias_name" '$1 == a { print; exit }' "$DIR/databases.tsv")" +[[ -n "$row" ]] || die "unknown alias '$alias_name'. Known: $(cut -f1 "$DIR/databases.tsv" | paste -sd' ' -)" +host="$(cut -f2 <<<"$row")" +database="$(cut -f3 <<<"$row")" + +internal=0 +case "$1" in + --tables) + internal=1 + sql="select table_schema, table_name from information_schema.tables where table_schema not in ('pg_catalog','information_schema') order by 1, 2" + ;; + --columns) + [[ $# -eq 2 ]] || die "--columns needs a table name" + [[ "$2" =~ ^[A-Za-z0-9_.]+$ ]] || die "invalid table name" + internal=1 + sql="select column_name, data_type from information_schema.columns where table_name = '${2##*.}' order by ordinal_position" + ;; + *) + sql="$1" + ;; +esac + +if [[ $internal -eq 0 ]]; then + sql="$(sed -E 's/[[:space:]]*;[[:space:]]*$//' <<<"$sql")" + lc="$(tr '[:upper:]' '[:lower:]' <<<"$sql" | tr '\n' ' ')" + + [[ "$lc" =~ ^[[:space:]]*(select|with)[[:space:]] ]] || die "only a single SELECT/WITH statement is allowed" + [[ "$sql" != *";"* ]] || die "multiple statements are not allowed" + [[ ! "$lc" =~ (^|[^a-z_])(insert|update|delete|drop|alter|create|grant|revoke|truncate|copy|call|do|set|reset|lock|vacuum|pg_read_file|pg_ls_dir|dblink|lo_import|lo_export)([^a-z_]|$) ]] \ + || die "statement contains a disallowed keyword" + + stripped="$(sed -E 's/count\([[:space:]]*\*[[:space:]]*\)//g' <<<"$lc")" + [[ ! "$stripped" =~ (select|,)[[:space:]]*(distinct[[:space:]]+)?([a-z_0-9]+\.)?\* ]] \ + || die "'*' in the select list is not allowed; list explicit ID/metadata columns (use --columns
)" +fi + +aws sts get-caller-identity --profile "$PROFILE" >/dev/null 2>&1 \ + || die "no valid AWS SSO session. Run: ! aws sso login --profile $PROFILE" + +PGPASSWORD="$(aws --profile "$PROFILE" rds generate-db-auth-token \ + --hostname "$host" --port 5432 --region "$REGION" --username "$DB_USER")" +export PGPASSWORD +export PGHOST="$host" PGPORT=5432 PGDATABASE="$database" PGUSER="$DB_USER" PGSSLMODE=require +export PGOPTIONS="-c default_transaction_read_only=on -c statement_timeout=30000" + +out="$(mktemp)" +trap 'rm -f "$out"' EXIT +psql -X --csv -v ON_ERROR_STOP=1 -c "$sql" >"$out" + +header="$(head -n 1 "$out")" +if [[ $internal -eq 0 ]]; then + bad="$(tr ',' '\n' <<<"$header" | tr -d '"' | grep -Ei "$DENY_COLS" || true)" + if [[ -n "$bad" ]]; then + echo "prod-query: blocked, output columns look like PII/PHI (rows not shown):" >&2 + echo "$bad" >&2 + echo "Select IDs/metadata only; use these fields only inside WHERE." >&2 + exit 2 + fi +fi + +total=$(( $(wc -l <"$out") - 1 )) +head -n $(( MAX_ROWS + 1 )) "$out" \ + | sed -E 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}//g' +if (( total > MAX_ROWS )); then + echo "prod-query: output truncated to $MAX_ROWS of $total rows" >&2 +fi diff --git a/cleo.darwin.nix b/cleo.darwin.nix index fdc06bd..139bb6b 100644 --- a/cleo.darwin.nix +++ b/cleo.darwin.nix @@ -71,9 +71,13 @@ in ttyd xcodes xz - # node 22 pinned to the exact version cleo-app requires (nixpkgs-nodejs in flake.nix) - pinnedNode.nodejs_22 - pinnedNode.yarn + # node 24.x from unstable (cached); the pinnedNode 24.19.0 rev had no cache.nixos.org binaries + unstable.nodejs_24 + unstable.yarn + # iOS simulator UI driving (tap/scroll/type) for screenshots and e2e flows + unstable.maestro + unstable.idb-companion + act ]; # Add Homebrew to PATH @@ -147,6 +151,16 @@ in ); }; - nix.settings.experimental-features = "nix-command flakes"; + nix = { + settings = { + experimental-features = "nix-command flakes"; + auto-optimise-store = true; + }; + gc = { + automatic = true; + interval = { Weekday = 0; Hour = 2; Minute = 0; }; # Sunday at 2:00 AM + options = "--delete-older-than 7d"; + }; + }; system.stateVersion = 4; } diff --git a/cleo.home.nix b/cleo.home.nix index efeb4f9..f0fbd12 100644 --- a/cleo.home.nix +++ b/cleo.home.nix @@ -1,4 +1,10 @@ { config, pkgs, vars, ... }: +let + # Agent/skill bodies live in the cleo-infinite checkout; symlinked so they + # follow upstream on every pull. + ansibleFiles = "${config.home.homeDirectory}/Projects/cleo-infinite/scripts/local-dev-ansible/files"; + linkAnsibleFile = name: config.lib.file.mkOutOfStoreSymlink "${ansibleFiles}/${name}"; +in { imports = [ ./programs.vscode.nix @@ -96,6 +102,23 @@ }; }; + # Claude Code (tasks/config/claude-code.yml). The remaining settings keys + # (additionalDirectories, statusLine, ...) come from home.shared.nix. + my.claude.settings = { + awaySummaryEnabled = false; + promptSuggestionEnabled = false; + # Generated by /auto-mode-setup; kept as JSON so it can be re-pasted and diffed. + autoMode = builtins.fromJSON (builtins.readFile ./claude/auto-mode.json); + }; + xdg.configFile."claude/agents/Explore.md".source = linkAnsibleFile "claude-explore-agent.md"; + xdg.configFile."claude/agents/orchestrator-sonnet.md".source = linkAnsibleFile "claude-orchestrator-sonnet-agent.md"; + xdg.configFile."claude/agents/orchestrator-opus.md".source = linkAnsibleFile "claude-orchestrator-opus-agent.md"; + xdg.configFile."claude/skills/orchestrate/SKILL.md".source = linkAnsibleFile "claude-orchestrate-skill.md"; + xdg.configFile."claude/CLAUDE.md".source = + config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/nixos/claude/CLAUDE.md"; + xdg.configFile."claude/skills/prod-db".source = + config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/nixos/claude/skills/prod-db"; + # opencode (tasks/config/opencode.yml) xdg.configFile."opencode/opencode.json".text = builtins.toJSON { "$schema" = "https://opencode.ai/config.json"; diff --git a/flake.lock b/flake.lock index 03f580e..0b177e4 100644 --- a/flake.lock +++ b/flake.lock @@ -254,17 +254,17 @@ }, "nixpkgs-nodejs": { "locked": { - "lastModified": 1737545071, - "narHash": "sha256-j4HeaLw1LZxkCvuOxdO1xTnPYLSOQuzOjGEuCK80X2w=", + "lastModified": 1785767437, + "narHash": "sha256-1FG0hlMWqs1Yh3NJzt5ejMRqhJdSsr1qMqiAgMO3ZKs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "21808d22b1cda1898b71cf1a1beb524a97add2c4", + "rev": "700ca73b459b479645c7e5a957def679414c886f", "type": "github" }, "original": { "owner": "NixOS", "repo": "nixpkgs", - "rev": "21808d22b1cda1898b71cf1a1beb524a97add2c4", + "rev": "700ca73b459b479645c7e5a957def679414c886f", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 417b862..ff9f883 100644 --- a/flake.nix +++ b/flake.nix @@ -38,8 +38,8 @@ nix-flatpak.url = "github:gmodena/nix-flatpak/main"; - # Pinned nixpkgs for a specific nodejs_22 + yarn version required by cleo-app - nixpkgs-nodejs.url = "github:NixOS/nixpkgs/21808d22b1cda1898b71cf1a1beb524a97add2c4"; + # Pinned nixpkgs for a specific nodejs_24 (24.19.0) + yarn version required by cleo-app + nixpkgs-nodejs.url = "github:NixOS/nixpkgs/700ca73b459b479645c7e5a957def679414c886f"; }; outputs = diff --git a/home.shared.nix b/home.shared.nix index 2c660f8..27f88ac 100644 --- a/home.shared.nix +++ b/home.shared.nix @@ -15,6 +15,8 @@ let rebuildCmd = if pkgs.stdenv.isDarwin then "darwin-rebuild" else "nixos-rebuild"; in { + imports = [ ./module.claude.nix ]; + home.stateVersion = lib.mkDefault "26.05"; home.username = userConfig.username; home.homeDirectory = userConfig.homeDirectory; @@ -41,25 +43,23 @@ in vault = "${userConfig._1PassKeyVault}" ''; - xdg.configFile."claude/settings.json".source = pkgs.writeText "claude-settings.json" ( - builtins.toJSON { - "$schema" = "https://json.schemastore.org/claude-code-settings.json"; - theme = "dark"; - agent = "plan"; - denyCommands = [ "code --install-extension*" ]; - additionalDirectories = [ "/tmp" ]; - autoMemoryEnabled = false; - effortLevel = "medium"; - fastModePerSessionOptIn = true; - feedbackSurveyRate = 0; - model = "opusplan"; - showClearContextOnPlanAccept = false; - statusLine = { - type = "command"; - command = "claude-statusline prompt"; - }; - } - ); + my.claude.settings = { + "$schema" = "https://json.schemastore.org/claude-code-settings.json"; + theme = "dark"; + agent = "plan"; + denyCommands = [ "code --install-extension*" ]; + additionalDirectories = [ "/tmp" ]; + autoMemoryEnabled = false; + effortLevel = "medium"; + fastModePerSessionOptIn = true; + feedbackSurveyRate = 0; + model = "opusplan"; + showClearContextOnPlanAccept = false; + statusLine = { + type = "command"; + command = "claude-statusline prompt"; + }; + }; xdg.configFile."claude-statusline/config.toml".text = '' format = "$directory | $git_branch | $model | $cost | $context | $usage" diff --git a/module.claude.nix b/module.claude.nix new file mode 100644 index 0000000..dfa8baf --- /dev/null +++ b/module.claude.nix @@ -0,0 +1,16 @@ +{ config, lib, pkgs, ... }: +let + jsonFormat = pkgs.formats.json { }; +in +{ + # Host modules contribute keys here; the module system deep-merges them into + # a single ~/.config/claude/settings.json. + options.my.claude.settings = lib.mkOption { + type = jsonFormat.type; + default = { }; + description = "Claude Code settings.json contents."; + }; + + config.xdg.configFile."claude/settings.json".source = + jsonFormat.generate "claude-settings.json" config.my.claude.settings; +}