From acadf8f8221fb536b2fa6aa486579b80149572c2 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Mon, 13 Jul 2026 14:25:19 -0500 Subject: [PATCH 1/2] zsh: Move some bash env setup to zshenv so that non-interactive AI terminals see the expected variables and aliases --- zsh/bashlib.sh | 5 ----- zsh/zshenv | 10 +++++++++- zsh/zshrc | 2 -- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/zsh/bashlib.sh b/zsh/bashlib.sh index 502e35d..af74ac9 100644 --- a/zsh/bashlib.sh +++ b/zsh/bashlib.sh @@ -13,11 +13,6 @@ main_bash_setup() { fi source "$DOTFILES/zsh/zshenv" - source "$DOTFILES/zsh/bashlib.sh" - source "$DOTFILES/zsh/aliases.sh" - source "$DOTFILES/zsh/quick.sh" - source "$DOTFILES/env.sh" - # For a full list of active aliases, run `alias`. local path_orig="$(pwd)" (cd nvm use >/dev/null 2>&1 || true) diff --git a/zsh/zshenv b/zsh/zshenv index bd25b65..6cfa183 100644 --- a/zsh/zshenv +++ b/zsh/zshenv @@ -1,7 +1,15 @@ -. "$HOME/.cargo/env" +[[ -r "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env" export REPO="$HOME/ki" export DOTFILES="$REPO/boku/dotfiles" source "$DOTFILES/symlinks.sh" export OUTTXT="$HOME/ki/out.txt" export SKILLS="$HOME/.cursor/skills" export EPICS="$REPO/boku/epics" + +# Global bash setup. Written in zshenv for non-interactive terminals like the one +# used by Codex. Non-interactive shells skip the zshrc setup path. +source "$DOTFILES/zsh/bashlib.sh" +source "$DOTFILES/zsh/aliases.sh" +# For a full list of active aliases, run `alias`. +source "$DOTFILES/zsh/quick.sh" +source "$DOTFILES/env.sh" diff --git a/zsh/zshrc b/zsh/zshrc index 0eb7662..9bc1837 100644 --- a/zsh/zshrc +++ b/zsh/zshrc @@ -319,8 +319,6 @@ last_cmd() { autoload -U +X bashcompinit && bashcompinit complete -o nospace -C "$HOME/go/bin/gocomplete" go -main_bash_setup - # bun completions [ -s "$HOME/.bun/_bun" ] && source "$HOME/.bun/_bun" From 2b1cae58059197ae043d0e293d4f27bf14257df3 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Mon, 13 Jul 2026 14:30:30 -0500 Subject: [PATCH 2/2] codex: Add portable Codex configuration and skills synchronization --- README.md | 40 +++++-- bun.lock | 6 ++ codex/README.md | 29 +++++ codex/config.test.ts | 165 ++++++++++++++++++++++++++++ codex/config.ts | 240 +++++++++++++++++++++++++++++++++++++++++ justfile | 47 +++++++- package.json | 4 +- skillsSync.test.ts | 60 +++++++++++ skillsSync.ts | 250 +++++++++++++++++++++++++++++++++++-------- 9 files changed, 786 insertions(+), 55 deletions(-) create mode 100644 codex/README.md create mode 100644 codex/config.test.ts create mode 100644 codex/config.ts diff --git a/README.md b/README.md index e3e2a87..17e2618 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,12 @@ machines. - [Quick Setup](#quick-setup) - [Symlink Philosophy](#symlink-philosophy) - [Features](#features) - - [WSL Clipboard Integration](#wsl-clipboard-integration) + - [Sync and health checks](#sync-and-health-checks) + - [AI agent skills](#ai-agent-skills) - [Neovim Configuration](#neovim-configuration) - - [Terminal Environment](#terminal-environment) + - [Terminal Environment: tmux, zsh, bun](#terminal-environment-tmux-zsh-bun) + - [Codex config](#codex-config) + - [WSL Clipboard Integration](#wsl-clipboard-integration) - [Requirements](#requirements) - [Testing](#testing) @@ -75,10 +78,21 @@ The `symlinks.sh` script handles creating all necessary symbolic links to connec ## Features -### WSL Clipboard Integration -- Custom `pbcopy` and `pbpaste` commands that work with Windows clipboard -- Neovim configured to use system clipboard across WSL/Windows boundary -- Automatically removes Windows line endings when pasting +### Sync and health checks + +`just sync` runs the established shell bootstrap, applies portable Codex +defaults, and synchronizes managed AI skills. `just health` does not write: it +checks required commands and reports Codex or skills-sync drift with a nonzero +exit status. + +### AI agent skills + +`just skills-sync --run` treats `$HOME/.cursor/skills` as the canonical runtime +directory and also copies all skills to `$HOME/.agents/skills` for Codex CLI. +The Codex destination is created and marked as managed on first use; an +existing unrelated non-empty directory is rejected rather than overwritten. + +See the [Codex skills documentation](https://developers.openai.com/codex/concepts/customization#skills). ### Neovim Configuration - Light/dark theme toggle (Catppuccin/OneDark) @@ -86,12 +100,24 @@ The `symlinks.sh` script handles creating all necessary symbolic links to connec - LSP with auto-installation of language servers - Harpoon, Telescope, and other navigation enhancements -### Terminal Environment +### Terminal Environment: tmux, zsh, bun - Tmux with Dracula theme and plugin manager - Zsh configured for Node.js, Rust, Go, and Python development - Bun JavaScript/TypeScript runtime integration - `tree-sitter-cli` support for Neovim parser installation on the `main` branch +### Codex config + +[`codex/config.ts`](codex/config.ts) maintains portable defaults in +`$HOME/.codex/config.toml` while preserving local project, MCP, and onboarding +state. Run `bun run codex/config.ts` for its usage and options. + +### WSL Clipboard Integration +- Custom `pbcopy` and `pbpaste` commands that work with Windows clipboard +- Neovim configured to use system clipboard across WSL/Windows boundary +- Automatically removes Windows line endings when pasting + + ## Requirements - Ubuntu 24.04 (or compatible) on WSL2 diff --git a/bun.lock b/bun.lock index 0149c3d..ff21acf 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,13 @@ "": { "name": "dotfiles", "dependencies": { + "commander": "^15.0.0", "gray-matter": "^4.0.3", }, "devDependencies": { "@types/bun": "latest", "@uniquedivine/bash": "^0.0.2", + "smol-toml": "^1.7.0", }, "peerDependencies": { "typescript": "^5.0.0", @@ -55,6 +57,8 @@ "bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], @@ -69,6 +73,8 @@ "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], diff --git a/codex/README.md b/codex/README.md new file mode 100644 index 0000000..d856f4d --- /dev/null +++ b/codex/README.md @@ -0,0 +1,29 @@ +# Codex Config + +`config.ts` maintains portable defaults for the Codex CLI. It writes the +runtime file at `$HOME/.codex/config.toml` while preserving local state. + +## Managed settings + +The TypeScript `dotfileConfig` object owns general preferences: personality, +model, reasoning effort, trust level, approval policy, sandbox mode, and +`tui.vim_mode_default`. Edit that object to change the backed-up defaults. + +Project-specific trust, MCP servers, TUI onboarding state, authentication, +history, caches, databases, and logs are intentionally not backed up here. + +See the [Codex config reference](https://developers.openai.com/codex/config-reference). + +## Usage + +```bash +# Show help +bun run codex/config.ts + +# Preview or check drift +bun run codex/config.ts --dry-run +bun run codex/config.ts --check + +# Apply portable defaults +bun run codex/config.ts --run +``` diff --git a/codex/config.test.ts b/codex/config.test.ts new file mode 100644 index 0000000..ef40e2f --- /dev/null +++ b/codex/config.test.ts @@ -0,0 +1,165 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, test } from "bun:test" +import { parse, type TomlTable } from "smol-toml" + +import { applyConfig, dotfileConfig, mergeRuntimeConfig } from "./config.ts" + +const scriptPath = join(import.meta.dir, "config.ts") + +const runConfig = async ( + homeDir: string, + args: string[] = [], +): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + const proc = Bun.spawn(["bun", scriptPath, ...args], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, HOME: homeDir }, + stderr: "pipe", + stdout: "pipe", + }) + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + return { exitCode, stdout, stderr } +} + +describe("codex config", () => { + test("overwrites portable defaults and preserves local tables", () => { + const merged = mergeRuntimeConfig({ + personality: "other", + model: "other-model", + tui: { + vim_mode_default: false, + model_availability_nux: { "gpt-5.6-sol": 4 }, + }, + projects: { "/tmp/project": { trust_level: "trusted" } }, + mcp_servers: { private: { command: "private-command" } }, + }) + + expect(merged).toMatchObject(dotfileConfig) + expect(merged.tui).toEqual({ + vim_mode_default: true, + model_availability_nux: { "gpt-5.6-sol": 4 }, + }) + expect(merged.projects).toEqual({ + "/tmp/project": { trust_level: "trusted" }, + }) + expect(merged.mcp_servers).toEqual({ + private: { command: "private-command" }, + }) + }) + + test("creates the runtime config and is idempotent", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-config-test-")) + + try { + const runtimePath = join(root, ".codex/config.toml") + expect(await applyConfig({ runtimePath, quiet: true })).toBe(true) + const firstText = await Bun.file(runtimePath).text() + expect(parse(firstText) as TomlTable).toEqual(dotfileConfig) + expect(await applyConfig({ runtimePath, quiet: true })).toBe(false) + expect(await Bun.file(runtimePath).text()).toBe(firstText) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("preserves local config when writing", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-config-merge-test-")) + + try { + const runtimePath = join(root, ".codex/config.toml") + await mkdir(join(root, ".codex"), { recursive: true }) + await writeFile( + runtimePath, + '[projects."/tmp/project"]\ntrust_level = "trusted"\n\n' + + "[mcp_servers.private]\ncommand = \"private-command\"\n\n" + + "[tui]\nvim_mode_default = false\n\n" + + "[tui.model_availability_nux]\n\"gpt-5.6-sol\" = 4\n", + ) + + await applyConfig({ runtimePath, quiet: true }) + const config = parse(await Bun.file(runtimePath).text()) as TomlTable + expect(config.projects).toEqual({ + "/tmp/project": { trust_level: "trusted" }, + }) + expect(config.mcp_servers).toEqual({ + private: { command: "private-command" }, + }) + expect(config.tui).toEqual({ + vim_mode_default: true, + model_availability_nux: { "gpt-5.6-sol": 4 }, + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("does not rewrite equivalent TOML comments or formatting", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-config-comment-test-")) + + try { + const runtimePath = join(root, ".codex/config.toml") + const text = `# Portable preferences\n${[ + 'personality = "pragmatic"', + 'approvals_reviewer = "user"', + 'model = "gpt-5.6-terra"', + 'model_reasoning_effort = "medium"', + 'trust_level = "trusted"', + 'approval_policy = "never"', + 'sandbox_mode = "danger-full-access"', + "", + "[tui]", + "vim_mode_default = true", + ].join("\n")}\n` + await mkdir(join(root, ".codex"), { recursive: true }) + await writeFile(runtimePath, text) + + expect(await applyConfig({ runtimePath, quiet: true })).toBe(false) + expect(await Bun.file(runtimePath).text()).toBe(text) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("rejects malformed TOML without changing it", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-config-invalid-test-")) + + try { + const runtimePath = join(root, ".codex/config.toml") + const invalidToml = "model = [\n" + await mkdir(join(root, ".codex"), { recursive: true }) + await writeFile(runtimePath, invalidToml) + await expect(applyConfig({ runtimePath, quiet: true })).rejects.toThrow() + expect(await Bun.file(runtimePath).text()).toBe(invalidToml) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("prints help by default and checks drift without writing", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-config-cli-test-")) + + try { + const runtimePath = join(root, ".codex/config.toml") + const help = await runConfig(root) + expect(help).toMatchObject({ exitCode: 0, stderr: "" }) + expect(help.stdout).toContain("Usage:") + expect(await Bun.file(runtimePath).exists()).toBe(false) + + const check = await runConfig(root, ["--check"]) + expect(check.exitCode).toBe(1) + expect(check.stdout).toContain("Codex runtime config differs") + expect(await Bun.file(runtimePath).exists()).toBe(false) + + const run = await runConfig(root, ["--run", "--quiet"]) + expect(run).toEqual({ exitCode: 0, stdout: "", stderr: "" }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/codex/config.ts b/codex/config.ts new file mode 100644 index 0000000..6907359 --- /dev/null +++ b/codex/config.ts @@ -0,0 +1,240 @@ +import { mkdir, rename, writeFile } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { Command } from "commander" +import { parse, stringify, type TomlTable } from "smol-toml" + +/** + * Portable Codex defaults maintained by this dotfiles repository. + * + * See https://developers.openai.com/codex/config-reference for the available + * configuration fields. Project trust, MCP servers, and onboarding state are + * intentionally local and are preserved from the runtime config. + */ +export const dotfileConfig = { + personality: "pragmatic", + approvals_reviewer: "user", + model: "gpt-5.6-terra", + model_reasoning_effort: "medium", + trust_level: "trusted", + approval_policy: "never", + sandbox_mode: "danger-full-access", + tui: { + vim_mode_default: true, + }, +} satisfies TomlTable + +export const runtimeConfigPath = ( + env: NodeJS.ProcessEnv = process.env, +): string => { + const home = env.HOME + + if (!home) { + throw new Error("HOME is not set") + } + + return resolve(home, ".codex/config.toml") +} + +const isTomlTable = (value: unknown): value is TomlTable => + typeof value === "object" && value !== null && !Array.isArray(value) + +const parseTomlTable = (toml: string, path: string): TomlTable => { + const parsed = parse(toml) as unknown + + if (!isTomlTable(parsed)) { + throw new Error(`${path} must contain a TOML table`) + } + + return parsed +} + +const mergeTables = (runtime: TomlTable, owned: TomlTable): TomlTable => { + const merged: TomlTable = { ...runtime } + + for (const [key, value] of Object.entries(owned)) { + const existing = merged[key] + + if (isTomlTable(existing) && isTomlTable(value)) { + merged[key] = mergeTables(existing, value) + continue + } + + merged[key] = value + } + + return merged +} + +export const mergeRuntimeConfig = ( + runtimeConfig: TomlTable, + config: TomlTable = dotfileConfig, +): TomlTable => mergeTables(runtimeConfig, config) + +const serialize = (config: TomlTable): string => `${stringify(config)}\n` + +const readRuntimeConfig = async ( + path: string, +): Promise<{ config: TomlTable; text: string }> => { + const file = Bun.file(path) + + if (!(await file.exists())) { + return { config: {}, text: "" } + } + + const text = await file.text() + + if (text.trim() === "") { + return { config: {}, text } + } + + return { config: parseTomlTable(text, path), text } +} + +export const unifiedDiff = ( + beforeText: string, + afterText: string, + beforeLabel = "runtime config.toml", + afterLabel = "generated config.toml", +): string => { + if (beforeText === afterText) { + return "" + } + + const beforeLines = beforeText === "" ? [] : beforeText.split("\n") + const afterLines = afterText === "" ? [] : afterText.split("\n") + let prefix = 0 + + while ( + prefix < beforeLines.length && + prefix < afterLines.length && + beforeLines[prefix] === afterLines[prefix] + ) { + prefix += 1 + } + + let suffix = 0 + + while ( + suffix < beforeLines.length - prefix && + suffix < afterLines.length - prefix && + beforeLines[beforeLines.length - 1 - suffix] === + afterLines[afterLines.length - 1 - suffix] + ) { + suffix += 1 + } + + const beforeEnd = beforeLines.length - suffix + const afterEnd = afterLines.length - suffix + const out = [`--- ${beforeLabel}`, `+++ ${afterLabel}`, "@@"] + + for (const line of beforeLines.slice(prefix, beforeEnd)) { + out.push(`-${line}`) + } + + for (const line of afterLines.slice(prefix, afterEnd)) { + out.push(`+${line}`) + } + + return `${out.join("\n")}\n` +} + +interface ApplyOptions { + runtimePath?: string + dryRun?: boolean + quiet?: boolean +} + +export const applyConfig = async ({ + runtimePath = runtimeConfigPath(), + dryRun = false, + quiet = false, +}: ApplyOptions = {}): Promise => { + const { config: runtimeConfig, text: beforeText } = + await readRuntimeConfig(runtimePath) + const nextConfig = mergeRuntimeConfig(runtimeConfig) + const currentText = serialize(runtimeConfig) + const afterText = serialize(nextConfig) + + if (currentText === afterText) { + if (!quiet) { + console.log(`Codex runtime config is already current: ${runtimePath}`) + } + + return false + } + + if (!quiet) { + console.log(`Codex runtime config differs: ${runtimePath}`) + process.stdout.write(unifiedDiff(beforeText, afterText)) + } + + if (!dryRun) { + await mkdir(dirname(runtimePath), { recursive: true }) + const tmpPath = `${runtimePath}.${process.pid}.tmp` + await writeFile(tmpPath, afterText, { mode: 0o600 }) + await rename(tmpPath, runtimePath) + } + + return true +} + +interface ConfigOptions { + check?: boolean + dryRun?: boolean + print?: boolean + quiet?: boolean + run?: boolean +} + +const runConfig = async (options: ConfigOptions): Promise => { + const runtimePath = runtimeConfigPath() + const { config: runtimeConfig } = await readRuntimeConfig(runtimePath) + const nextConfig = mergeRuntimeConfig(runtimeConfig) + + if (options.print) { + process.stdout.write(serialize(nextConfig)) + return + } + + const changed = await applyConfig({ + runtimePath, + dryRun: !options.run || options.dryRun || options.check, + quiet: options.quiet, + }) + + if (options.check && changed) { + process.exitCode = 1 + } +} + +export const createProgram = (): Command => { + const program = new Command() + + program + .name("codex-config") + .description( + "Generate ~/.codex/config.toml while preserving local Codex state.", + ) + .option("--run", "write the runtime config if it differs") + .option("--dry-run", "print the diff without writing") + .option("--check", "exit with code 1 if the runtime config would change") + .option("--print", "print the generated runtime config TOML") + .option("--quiet", "suppress normal output with --run") + .action(async (options: ConfigOptions) => { + const hasAction = + options.run || options.dryRun || options.check || options.print + + if (!hasAction) { + program.outputHelp() + return + } + + await runConfig(options) + }) + + return program +} + +if (import.meta.main) { + await createProgram().parseAsync() +} diff --git a/justfile b/justfile index f5da960..bd0092a 100644 --- a/justfile +++ b/justfile @@ -12,15 +12,58 @@ test: alias t := test +# Apply shell bootstrap, portable Codex config, and managed AI skills. +sync: + #!/usr/bin/env bash + set -Eeuo pipefail + source zsh/bashlib.sh + main_bash_setup + bun run codex/config.ts --run + bun run skillsSync.ts --run + +# Check required tools and drift without changing dotfile-managed state. +health: + #!/usr/bin/env bash + set -Eeuo pipefail + source zsh/bashlib.sh + + failed=0 + for tool in bun just codex rsync; do + if ! which_ok "$tool"; then + failed=1 + fi + done + + if ! which_ok bun; then + exit 1 + fi + + if ! bun run codex/config.ts --check; then + failed=1 + fi + + if [[ -z "${REPO:-}" ]]; then + log_error "REPO is not set; run just sync first or source zsh/zshenv" + failed=1 + elif ! bun run skillsSync.ts --health; then + failed=1 + fi + + exit "$failed" + +# Run the portable Codex config CLI. For options, run `just codex`. +codex *ARGS: + bun run codex/config.ts {{ARGS}} + # Install baseline Ubuntu/WSL shell dependencies. i-bash: sudo apt install -y build-essential ripgrep gh libclang-dev wslu \ - ca-certificates gnupg curl trash-cli + ca-certificates gnupg curl trash-cli clang-format # Install shell dependencies needed by CI tests. i-bash-ci: sudo apt install -y build-essential ripgrep gh -# Synchronize runtime skills (~/.cursor/skills) with public and private backups +# Synchronize runtime skills (~/.cursor/skills) with public/private backups and Codex skills-sync *ARGS: bun run skillsSync.ts {{ARGS}} diff --git a/package.json b/package.json index 3a33608..41218bb 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,14 @@ "type": "module", "devDependencies": { "@types/bun": "latest", - "@uniquedivine/bash": "^0.0.2" + "@uniquedivine/bash": "^0.0.2", + "smol-toml": "^1.7.0" }, "peerDependencies": { "typescript": "^5.0.0" }, "dependencies": { + "commander": "^15.0.0", "gray-matter": "^4.0.3" } } diff --git a/skillsSync.test.ts b/skillsSync.test.ts index f94ede5..5dc33b1 100644 --- a/skillsSync.test.ts +++ b/skillsSync.test.ts @@ -45,6 +45,30 @@ const runSkillsSync = async ( }) } +const skillsSyncResult = async ( + homeDir: string, + repoPath: string, + args: string[], +): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + const proc = Bun.spawn(["bun", scriptPath, ...args], { + cwd: import.meta.dir, + env: { + ...process.env, + HOME: homeDir, + REPO: repoPath, + }, + stderr: "pipe", + stdout: "pipe", + }) + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + return { exitCode, stdout, stderr } +} + const dirNames = async (dir: string): Promise => { const entries = await readdir(dir, { withFileTypes: true }) return entries @@ -60,6 +84,7 @@ describe("skills-sync", () => { syncRepoPath: string syncPublicDir: string syncPrivateDir: string + syncCodexDir: string dryRunHomeDir: string dryRunRepoPath: string dryRunPublicDir: string @@ -74,6 +99,7 @@ describe("skills-sync", () => { const syncRuntimeDir = join(syncHomeDir, ".cursor/skills") const syncPublicDir = join(syncBokuPath, "jiyuu/ai-skills") const syncPrivateDir = join(syncBokuPath, "priv-skills") + const syncCodexDir = join(syncHomeDir, ".agents/skills") await mkdir(syncRuntimeDir, { recursive: true }) await mkdir(join(syncPublicDir, "stale-public"), { recursive: true }) @@ -127,6 +153,7 @@ describe("skills-sync", () => { syncRepoPath, syncPublicDir, syncPrivateDir, + syncCodexDir, dryRunHomeDir, dryRunRepoPath, dryRunPublicDir, @@ -150,6 +177,12 @@ describe("skills-sync", () => { "private-string-true", "private-true", ]) + expect(await dirNames(testCfg.syncCodexDir)).toEqual([ + "private-string-true", + "private-true", + "public-false", + "public-missing", + ]) expect( await Bun.file( @@ -161,6 +194,11 @@ describe("skills-sync", () => { join(testCfg.syncPrivateDir, "private-true/reference.md"), ).text(), ).toBe("private-true reference\n") + expect( + await Bun.file( + join(testCfg.syncCodexDir, "public-false/reference.md"), + ).text(), + ).toBe("public-false reference\n") }) test("copies skill-local readme and license files", async () => { @@ -176,6 +214,28 @@ describe("skills-sync", () => { ).toBe("public skill license\n") }) + test("reports healthy only when every destination is synchronized", async () => { + const healthy = await skillsSyncResult( + testCfg.syncHomeDir, + testCfg.syncRepoPath, + ["--health"], + ) + expect(healthy).toMatchObject({ exitCode: 0, stderr: "" }) + expect(healthy.stdout).toContain("Skills sync is healthy.") + + await writeFile( + join(testCfg.syncCodexDir, "public-false/reference.md"), + "out of sync\n", + ) + const drifted = await skillsSyncResult( + testCfg.syncHomeDir, + testCfg.syncRepoPath, + ["--health"], + ) + expect(drifted.exitCode).toBe(1) + expect(drifted.stdout).toContain("public-false/reference.md") + }) + test("keeps marksman config in destination dirs", async () => { expect( await Bun.file(join(testCfg.syncPublicDir, ".marksman.toml")).text(), diff --git a/skillsSync.ts b/skillsSync.ts index 6b7374a..70573d9 100644 --- a/skillsSync.ts +++ b/skillsSync.ts @@ -1,7 +1,16 @@ -import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises" +import { + lstat, + mkdir, + mkdtemp, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises" import { tmpdir } from "node:os" import { basename, dirname, join, resolve } from "node:path" import { bash } from "@uniquedivine/bash" +import { Command } from "commander" import matter from "gray-matter" interface SkillSets { @@ -13,30 +22,12 @@ interface SkillsSyncConfig { skillsRuntime: string publicSkillsDir: string privateSkillsDir: string + codexSkillsDir: string } -const args = new Set(Bun.argv.slice(2)) -const help = args.has("--help") || args.has("-h") -const runSync = args.has("--run") || args.has("-r") -const dryRun = !runSync +const codexOwnershipMarker = ".dotfiles-cursor-skills-sync" -const printUsage = (): void => { - console.log(`Usage: bun run skillsSync.ts [--run] - -Sync Cursor skills from ~/.cursor/skills into version-controlled skill dirs. - -Default behavior is a dry run. Pass --run to write changes. - -Options: - -r, --run Apply changes with rsync - -n, --dry-run Preview changes only (default) - -h, --help Show this help text`) -} - -if (help) { - printUsage() - process.exit(0) -} +let dryRun = true const defaultConfig = (env: NodeJS.ProcessEnv): SkillsSyncConfig => { const home = env.HOME @@ -56,11 +47,10 @@ const defaultConfig = (env: NodeJS.ProcessEnv): SkillsSyncConfig => { skillsRuntime: resolve(home, ".cursor/skills"), publicSkillsDir: resolve(bokuPath, "jiyuu/ai-skills"), privateSkillsDir: resolve(bokuPath, "priv-skills"), + codexSkillsDir: resolve(home, ".agents/skills"), } } -const cfg = defaultConfig(process.env) - const shellQuote = (value: string): string => JSON.stringify(value) const run = async (cmd: string): Promise => { @@ -148,7 +138,21 @@ const stageSkills = async ( } const rsyncStage = async (stageDir: string, destDir: string): Promise => { - await mkdir(destDir, { recursive: true }) + if (dryRun) { + try { + await lstat(destDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + console.log(`Would create directory ${destDir}`) + return + } + throw error + } + } + + if (!dryRun) { + await mkdir(destDir, { recursive: true }) + } const dryRunFlags = dryRun ? " --dry-run --itemize-changes" : "" await run( @@ -161,6 +165,7 @@ const rsyncStage = async (stageDir: string, destDir: string): Promise => { "--exclude=/.marksman.toml", "--exclude=/README.md", "--exclude=/LICENSE", + `--exclude=/${codexOwnershipMarker}`, dryRunFlags, shellQuote(`${stageDir}/`), shellQuote(`${destDir}/`), @@ -168,6 +173,104 @@ const rsyncStage = async (stageDir: string, destDir: string): Promise => { ) } +const healthRsyncStage = async ( + stageDir: string, + destDir: string, +): Promise => { + try { + await lstat(destDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + console.error(`Skills destination is missing: ${destDir}`) + return false + } + throw error + } + + const out = await bash( + [ + "rsync", + "-aL", + "--delete", + "--dry-run", + "--itemize-changes", + "--omit-dir-times", + "--exclude=/.git/", + "--exclude=/.gitignore", + "--exclude=/.marksman.toml", + "--exclude=/README.md", + "--exclude=/LICENSE", + `--exclude=/${codexOwnershipMarker}`, + shellQuote(`${stageDir}/`), + shellQuote(`${destDir}/`), + ].join(" "), + ) + + if (out.exitCode !== 0) { + throw new Error( + `Skills health rsync failed with exit code ${out.exitCode}: ${destDir}`, + ) + } + + if (out.stdout.trim() === "") { + return true + } + + process.stdout.write(out.stdout) + return false +} + +const prepareCodexSkillsDir = async (destDir: string): Promise => { + await mkdir(dirname(destDir), { recursive: true }) + + try { + const info = await lstat(destDir) + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error(`Codex skills path must be a real directory: ${destDir}`) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + await mkdir(destDir) + } + + const entries = await readdir(destDir) + if (entries.length > 0 && !entries.includes(codexOwnershipMarker)) { + throw new Error( + "Refusing to sync into unmanaged non-empty Codex skills directory: " + + destDir, + ) + } + + await writeFile( + join(destDir, codexOwnershipMarker), + "Managed by dotfiles skills-sync from $HOME/.cursor/skills.\n", + ) +} + +const codexSkillsDirIsHealthy = async (destDir: string): Promise => { + try { + const info = await lstat(destDir) + if (!info.isDirectory() || info.isSymbolicLink()) { + console.error(`Codex skills path must be a real directory: ${destDir}`) + return false + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + console.error(`Codex skills destination is missing: ${destDir}`) + return false + } + throw error + } + + const entries = await readdir(destDir) + if (!entries.includes(codexOwnershipMarker)) { + console.error(`Codex skills destination is unmanaged: ${destDir}`) + return false + } + + return true +} + const printSummary = ( label: string, skills: Set, @@ -181,29 +284,86 @@ const printSummary = ( } } -assertSafePath(cfg.publicSkillsDir, "/jiyuu/ai-skills", "public skills dir") -assertSafePath(cfg.privateSkillsDir, "/priv-skills", "private skills dir") - -const tmpRoot = await mkdtemp(join(tmpdir(), "skills-sync-")) -const tmpPublic = join(tmpRoot, "public") -const tmpPrivate = join(tmpRoot, "private") +interface SkillsSyncOptions { + dryRun?: boolean + health?: boolean + run?: boolean +} -try { - const { publicSkills, privateSkills } = await classifySkills(cfg) +const runSkillsSync = async (options: SkillsSyncOptions): Promise => { + const cfg = defaultConfig(process.env) + const health = options.health ?? false + dryRun = !options.run && !health + + assertSafePath(cfg.publicSkillsDir, "/jiyuu/ai-skills", "public skills dir") + assertSafePath(cfg.privateSkillsDir, "/priv-skills", "private skills dir") + assertSafePath(cfg.codexSkillsDir, "/.agents/skills", "Codex skills dir") + + const tmpRoot = await mkdtemp(join(tmpdir(), "skills-sync-")) + const tmpPublic = join(tmpRoot, "public") + const tmpPrivate = join(tmpRoot, "private") + + try { + const { publicSkills, privateSkills } = await classifySkills(cfg) + const allSkills = new Set([...publicSkills, ...privateSkills]) + + await stageSkills(cfg, publicSkills, tmpPublic) + await stageSkills(cfg, privateSkills, tmpPrivate) + const tmpCodex = join(tmpRoot, "codex") + await stageSkills(cfg, allSkills, tmpCodex) + + if (!dryRun) await prepareCodexSkillsDir(cfg.codexSkillsDir) + + printSummary("public", publicSkills, cfg.publicSkillsDir) + printSummary("private", privateSkills, cfg.privateSkillsDir) + printSummary("codex", allSkills, cfg.codexSkillsDir) + + if (health) { + const results = await Promise.all([ + healthRsyncStage(tmpPublic, cfg.publicSkillsDir), + healthRsyncStage(tmpPrivate, cfg.privateSkillsDir), + codexSkillsDirIsHealthy(cfg.codexSkillsDir), + healthRsyncStage(tmpCodex, cfg.codexSkillsDir), + ]) + + if (results.every(Boolean)) { + console.log("Skills sync is healthy.") + } else { + process.exitCode = 1 + } + } else { + await rsyncStage(tmpPublic, cfg.publicSkillsDir) + await rsyncStage(tmpPrivate, cfg.privateSkillsDir) + await rsyncStage(tmpCodex, cfg.codexSkillsDir) + } - await stageSkills(cfg, publicSkills, tmpPublic) - await stageSkills(cfg, privateSkills, tmpPrivate) + if (dryRun && !health) { + console.log("Dry run complete. No files were changed.") + console.log("Run with --run to apply these changes.") + } + } finally { + await rm(tmpRoot, { recursive: true, force: true }) + } +} - printSummary("public", publicSkills, cfg.publicSkillsDir) - printSummary("private", privateSkills, cfg.privateSkillsDir) +export const createProgram = (): Command => { + const program = new Command() - await rsyncStage(tmpPublic, cfg.publicSkillsDir) - await rsyncStage(tmpPrivate, cfg.privateSkillsDir) + program + .name("skills-sync") + .description( + "Sync Cursor skills to public, private, and managed Codex destinations.", + ) + .option("-r, --run", "apply changes with rsync") + .option("-n, --dry-run", "preview changes without writing") + .option("--health", "fail when destinations are unsafe or out of sync") + .action(async (options: SkillsSyncOptions) => { + await runSkillsSync(options) + }) + + return program +} - if (dryRun) { - console.log("Dry run complete. No files were changed.") - console.log("Run with --run to apply these changes.") - } -} finally { - await rm(tmpRoot, { recursive: true, force: true }) +if (import.meta.main) { + await createProgram().parseAsync() }