diff --git a/.agents/external-skills.json b/.agents/external-skills.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.agents/external-skills.json @@ -0,0 +1 @@ +{} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..ec5ea99 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +# Format staged Swift files in-place, then re-stage them. +# Note: this formats working-tree content, not the staged blob. +# Partially-staged files will be fully re-staged after formatting. + +staged=$(git diff --cached --name-only --diff-filter=ACMR -- '*.swift') + +if [ -n "$staged" ]; then + git diff --cached --name-only --diff-filter=ACMR -z -- '*.swift' \ + | xargs -0 mise exec -- swiftformat + + git diff --cached --name-only --diff-filter=ACMR -z -- '*.swift' \ + | xargs -0 git add +fi + +# Sync AGENTS.md + .agents/skills → CLAUDE.md + .claude/skills. +./sync-agents --git-add diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab3ca7..38d4878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,19 @@ concurrency: cancel-in-progress: true jobs: + format: + name: SwiftFormat Lint + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v3 + - run: ./swiftformat --lint + test: name: Build & Test + needs: format runs-on: macos-26 steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v3 - - run: tuist test + - run: mise exec -- tuist test diff --git a/.gitignore b/.gitignore index 28235d2..02986be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Tuist managed Derived/ +## Generated agent files (re-create via ./sync-agents --install) +CLAUDE.md +.claude/skills/ + # Xcode build/ DerivedData/ diff --git a/.mise.toml b/.mise.toml index 1ffcf12..52e66d9 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,2 +1,3 @@ [tools] tuist = "4.40.0" +swiftformat = "0.60.1" diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..61e6db2 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,5 @@ +--swiftversion 6.1 +--indent 4 +--indentcase true +--extension-acl on-declarations +--exclude Derived diff --git a/AGENTS.md b/AGENTS.md index aecbedf..f5b240b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,17 +2,43 @@ ## Build system -| Tool | Version | Pinned via | -|-------|---------|--------------| -| Tuist | 4.40.0 | `.mise.toml` | +| Tool | Version | Pinned via | +|-------------|---------|--------------| +| Tuist | 4.40.0 | `.mise.toml` | +| SwiftFormat | 0.60.1 | `.mise.toml` | Tuist manifests live at the repo root (`Project.swift`, `Tuist.swift`). Run `./ide` (or `./ide -i` to also install dependencies) to regenerate the -Xcode project. +Xcode project, install external agent skills, and point Git at `.githooks/`. + +Root dev scripts: `ide`, `swiftformat` (runs SwiftFormat via mise), and +`sync-agents` (keeps Claude Code–oriented files in sync with `AGENTS.md`). + +## Formatting + +- **SwiftFormat** uses [`.swiftformat`](.swiftformat). Run `./swiftformat` to + format the tree, or `./swiftformat --lint` to check only (as in CI). +- The pre-commit hook (enabled by `./ide` via `core.hooksPath`) formats staged + `*.swift` files in place and re-stages them. + +## Agent instructions sync + +`AGENTS.md` is the source of truth for AI agent instructions. Cursor reads +`AGENTS.md` natively; Claude Code uses `CLAUDE.md` and `.claude/skills/`. +Generated files (`CLAUDE.md`, `.claude/skills/`) are gitignored and produced +by `./sync-agents`. + +- `./sync-agents` — generate `CLAUDE.md` next to each `AGENTS.md` and mirror + `.agents/skills/` into `.claude/skills/`. +- `./sync-agents --install` — fetch external skills listed in + `.agents/external-skills.json` (run automatically by `./ide`). +- `./sync-agents --add [name]` — add an external skill from GitHub. +- `./sync-agents --update` — re-fetch all external skills to the latest commit. ## Targets -_No apps or modules yet._ Add targets to `Project.swift` using `macApp()` or `framework()` helpers. +- **StuffCore** — macOS framework for shared code (`StuffCore/Sources/`), with unit tests under `StuffCore/Tests/` (Swift Testing). +- Add more targets in `Project.swift` using `macApp()` or `framework()` helpers. ## Deployment diff --git a/Project.swift b/Project.swift index 34d0a6d..a18e305 100644 --- a/Project.swift +++ b/Project.swift @@ -6,7 +6,7 @@ let macDeployment: DeploymentTargets = .macOS("26.0") func framework( _ name: String, bundleIdSuffix: String, - dependencies: [TargetDependency] = [] + dependencies: [TargetDependency] = [], ) -> [Target] { [ .target( @@ -16,7 +16,7 @@ func framework( bundleId: "com.stuff.\(bundleIdSuffix)", deploymentTargets: macDeployment, sources: ["\(name)/Sources/**"], - dependencies: dependencies + dependencies: dependencies, ), .target( name: "\(name)Tests", @@ -25,7 +25,7 @@ func framework( bundleId: "com.stuff.\(bundleIdSuffix).tests", deploymentTargets: macDeployment, sources: ["\(name)/Tests/**"], - dependencies: [.target(name: name)] + dependencies: [.target(name: name)], ), ] } @@ -34,7 +34,7 @@ func macApp( _ name: String, bundleIdSuffix: String, infoPlist: [String: Plist.Value] = [:], - dependencies: [TargetDependency] = [] + dependencies: [TargetDependency] = [], ) -> [Target] { [ .target( @@ -46,7 +46,7 @@ func macApp( infoPlist: .extendingDefault(with: infoPlist), sources: ["\(name)/Sources/**"], resources: ["\(name)/Resources/**"], - dependencies: dependencies + dependencies: dependencies, ), .target( name: "\(name)Tests", @@ -55,7 +55,7 @@ func macApp( bundleId: "com.stuff.\(bundleIdSuffix).tests", deploymentTargets: macDeployment, sources: ["\(name)/Tests/**"], - dependencies: [.target(name: name)] + dependencies: [.target(name: name)], ), ] } @@ -64,7 +64,7 @@ let project = Project( name: "Stuff", options: .options( defaultKnownRegions: ["en"], - developmentRegion: "en" + developmentRegion: "en", ), - targets: [] + targets: framework("StuffCore", bundleIdSuffix: "stuffcore"), ) diff --git a/README.md b/README.md index 6a14204..576e348 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Random apps and stuff. ## Requirements - Xcode 26+ -- [mise](https://mise.jdx.dev) (manages Tuist version) +- [mise](https://mise.jdx.dev) (pins Tuist and SwiftFormat) - iOS 26.0+ ## Getting started @@ -14,21 +14,36 @@ Random apps and stuff. # Install mise (if needed) brew install mise -# Generate the Xcode project +# Install pinned tools (Tuist, SwiftFormat) +mise install + +# Generate the Xcode project (also sets Git hooks and runs sync-agents --install) ./ide -# Or install dependencies first, then generate +# Or install Tuist package dependencies first, then generate ./ide -i ``` +Run tests with `mise exec -- tuist test` (or open the generated workspace in Xcode). + +The `./ide` script sets `core.hooksPath` to `.githooks`. The pre-commit hook +formats staged Swift with SwiftFormat and runs `./sync-agents --git-add` so +generated Claude files stay in sync with `AGENTS.md`. + ## Project structure ``` Project.swift Tuist project manifest Tuist.swift Tuist configuration -.mise.toml Pins Tuist 4.40.0 -ide Dev script – regenerates Xcode project +.mise.toml Pins Tuist 4.40.0 and SwiftFormat 0.60.1 +.swiftformat SwiftFormat rules +ide Dev script – hooks, sync-agents, tuist generate +swiftformat Run SwiftFormat via mise (default: format `.`) +sync-agents Sync AGENTS.md → CLAUDE.md and .claude/skills/ +.githooks/ Git hooks (pre-commit) +.agents/ External skills manifest (`external-skills.json`) AGENTS.md Repository shape for AI agents +StuffCore/ Shared macOS framework (Sources/, Tests/) ``` ## License diff --git a/StuffCore/Sources/StuffCore.swift b/StuffCore/Sources/StuffCore.swift new file mode 100644 index 0000000..981cb5e --- /dev/null +++ b/StuffCore/Sources/StuffCore.swift @@ -0,0 +1,4 @@ +public enum StuffCore { + /// Placeholder until shared code lands here. + public static let version = 1 +} diff --git a/StuffCore/Tests/StuffCoreTests.swift b/StuffCore/Tests/StuffCoreTests.swift new file mode 100644 index 0000000..cb48b2d --- /dev/null +++ b/StuffCore/Tests/StuffCoreTests.swift @@ -0,0 +1,7 @@ +import StuffCore +import Testing + +@Test +func versionIsDefined() { + #expect(StuffCore.version == 1) +} diff --git a/ide b/ide index e00869f..59d4bf3 100755 --- a/ide +++ b/ide @@ -1,6 +1,10 @@ #!/bin/bash set -euo pipefail +# CI runs formatting via the workflow's `format` job; +# this only configures the local pre-commit hook. +git config core.hooksPath .githooks + INSTALL=false for arg in "$@"; do @@ -16,5 +20,8 @@ if [ "$INSTALL" = true ]; then mise exec -- tuist install fi +echo "==> sync-agents --install" +./sync-agents --install + echo "==> tuist generate" mise exec -- tuist generate diff --git a/swiftformat b/swiftformat new file mode 100755 index 0000000..93ebc6c --- /dev/null +++ b/swiftformat @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +has_positional=false + +for arg in "$@"; do + [[ "$arg" != -* ]] && has_positional=true && break +done + +if $has_positional; then + mise exec -- swiftformat "$@" +else + mise exec -- swiftformat "$@" . +fi diff --git a/sync-agents b/sync-agents new file mode 100755 index 0000000..4e81f7a --- /dev/null +++ b/sync-agents @@ -0,0 +1,335 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# sync-agents — keep AI agent configuration in sync across tools. +# +# AGENTS.md is the source of truth. Cursor and Codex read it natively; +# Claude Code needs CLAUDE.md and .claude/skills/. This script bridges +# the gap. +# +# Commands: +# ./sync-agents Generate CLAUDE.md files from +# AGENTS.md and sync local skills +# to .claude/skills/. Incremental: +# skips files that are already up +# to date. +# +# ./sync-agents --install Fetch external skills listed in +# .agents/external-skills.json. +# Skips skills already present. +# Run automatically by ./ide. +# +# ./sync-agents --add [name] Add an external skill from a +# GitHub repo. Clones the repo, +# copies the skill into +# .agents/skills/, and pins the +# commit SHA in the manifest. +# +# ./sync-agents --update Re-fetch all external skills +# from their repos at the latest +# commit. Updates the pinned SHA +# in the manifest. +# +# ./sync-agents --git-add Sync, then stage any changed +# files with git. Used by the +# pre-commit hook. + +require "fileutils" +require "json" +require "tmpdir" +require "open3" + +REPO_ROOT = File.expand_path("..", __FILE__) +MARKER = "" +SKILLS_DIR = File.join(REPO_ROOT, ".agents", "skills") +CLAUDE_SKILLS_DIR = File.join(REPO_ROOT, ".claude", "skills") +MANIFEST_PATH = File.join(REPO_ROOT, ".agents", "external-skills.json") + +EXCLUDED_DIRS = %w[Derived .build .git .cursor .claude].freeze + +class SyncAgents + def initialize + @changed_files = [] + end + + def run(args) + if args.include?("--add") + idx = args.index("--add") + url = args[idx + 1] + name = args[idx + 2] + abort "Usage: sync-agents --add [skill-name]" unless url + add_skill(url, name) + elsif args.include?("--install") + install_skills + elsif args.include?("--update") + update_skills + end + + sync_claude_md + sync_skills + update_skills_gitignore + + stage_files if args.include?("--git-add") && !@changed_files.empty? + end + + private + + # --- CLAUDE.md sync --- + + def sync_claude_md + expected = [] + + find_files("AGENTS.md", exclude: EXCLUDED_DIRS).each do |agents_file| + claude_path = File.join(File.dirname(agents_file), "CLAUDE.md") + content = "#{MARKER}\n\n#{File.read(agents_file)}" + write_if_changed(claude_path, content) + expected << claude_path + end + + find_files("CLAUDE.md", exclude: %w[Derived .build .git]).each do |claude_file| + next if expected.include?(claude_file) + first_line = File.open(claude_file, &:readline).chomp rescue next + remove_if_exists(claude_file) if first_line == MARKER + end + end + + # --- Skills sync --- + + def sync_skills + if Dir.exist?(SKILLS_DIR) + FileUtils.mkdir_p(CLAUDE_SKILLS_DIR) + unless dirs_equal?(SKILLS_DIR, CLAUDE_SKILLS_DIR) + FileUtils.rm_rf(CLAUDE_SKILLS_DIR) + FileUtils.cp_r(SKILLS_DIR, CLAUDE_SKILLS_DIR) + puts "Synced .agents/skills/ → .claude/skills/" + @changed_files << CLAUDE_SKILLS_DIR + end + elsif Dir.exist?(CLAUDE_SKILLS_DIR) + FileUtils.rm_rf(CLAUDE_SKILLS_DIR) + puts "Removed .claude/skills/" + @changed_files << CLAUDE_SKILLS_DIR + end + end + + # --- Gitignore management for external skills --- + + def update_skills_gitignore + manifest = load_manifest + gitignore_path = File.join(SKILLS_DIR, ".gitignore") + + if manifest.empty? + remove_if_exists(gitignore_path) if File.exist?(gitignore_path) + return + end + + lines = manifest.keys.sort.map { |name| "/#{name}/\n" } + content = "# External skills — fetched via ./sync-agents --install\n" + lines.join + write_if_changed(gitignore_path, content) + end + + # --- Add a new external skill --- + + def add_skill(url, name) + repo = parse_github_repo(url) + abort "Could not parse GitHub repo from: #{url}" unless repo + + Dir.mktmpdir do |tmpdir| + clone_default_branch(repo, tmpdir) + + if name + skill_src = File.join(tmpdir, name) + unless Dir.exist?(skill_src) && File.exist?(File.join(skill_src, "SKILL.md")) + available = list_skills(tmpdir) + abort "Skill '#{name}' not found in #{repo}. Available: #{available.join(", ")}" + end + else + available = list_skills(tmpdir) + case available.length + when 0 then abort "No skills found in #{repo}" + when 1 then name = available.first + else abort "Multiple skills in #{repo}: #{available.join(", ")}\n" \ + "Specify one: ./sync-agents --add #{url} " + end + skill_src = File.join(tmpdir, name) + end + + dest = File.join(SKILLS_DIR, name) + FileUtils.rm_rf(dest) + FileUtils.mkdir_p(SKILLS_DIR) + FileUtils.cp_r(skill_src, dest) + sha = head_sha(tmpdir) + puts "Added #{name} from #{repo}@#{sha[0..7]}" + + manifest = load_manifest + manifest[name] = { "repo" => repo, "path" => name, "ref" => sha } + save_manifest(manifest) + end + end + + # --- Install all external skills from manifest (skip if present) --- + + def install_skills + manifest = load_manifest + if manifest.empty? + puts "No external skills in manifest." + return + end + + manifest.each do |name, entry| + dest = File.join(SKILLS_DIR, name) + if Dir.exist?(dest) && File.exist?(File.join(dest, "SKILL.md")) + next + end + + repo = entry["repo"] + path = entry["path"] + ref = entry["ref"] || "main" + + Dir.mktmpdir do |tmpdir| + clone_repo(repo, ref, tmpdir) + + skill_src = File.join(tmpdir, path) + unless Dir.exist?(skill_src) + warn "Warning: '#{path}' not found in #{repo}@#{ref}, skipping #{name}" + next + end + + FileUtils.mkdir_p(SKILLS_DIR) + FileUtils.cp_r(skill_src, dest) + puts "Installed #{name} from #{repo}@#{ref}" + end + end + end + + # --- Update all external skills (force re-fetch) --- + + def update_skills + manifest = load_manifest + if manifest.empty? + puts "No external skills to update." + return + end + + changed = false + manifest.each do |name, entry| + repo = entry["repo"] + path = entry["path"] + + Dir.mktmpdir do |tmpdir| + clone_default_branch(repo, tmpdir) + sha = head_sha(tmpdir) + + if sha == entry["ref"] + puts "#{name} already at #{sha[0..7]}" + next + end + + skill_src = File.join(tmpdir, path) + unless Dir.exist?(skill_src) + warn "Warning: '#{path}' not found in #{repo}@#{sha[0..7]}, skipping #{name}" + next + end + + dest = File.join(SKILLS_DIR, name) + FileUtils.rm_rf(dest) + FileUtils.cp_r(skill_src, dest) + entry["ref"] = sha + changed = true + puts "Updated #{name} from #{repo}@#{sha[0..7]}" + end + end + + save_manifest(manifest) if changed + end + + # --- Helpers --- + + def write_if_changed(path, content) + return if File.exist?(path) && File.read(path) == content + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + puts "Updated #{path}" + @changed_files << path + end + + def remove_if_exists(path) + return unless File.exist?(path) + File.delete(path) + puts "Removed #{path}" + @changed_files << path + end + + def find_files(name, exclude: []) + Dir.glob(File.join(REPO_ROOT, "**", name)).reject do |f| + exclude.any? { |d| f.include?("/#{d}/") } + end + end + + def dirs_equal?(a, b) + return false unless Dir.exist?(a) && Dir.exist?(b) + a_files = Dir.glob(File.join(a, "**", "*"), File::FNM_DOTMATCH).map { |f| f.sub(a, "") }.sort + b_files = Dir.glob(File.join(b, "**", "*"), File::FNM_DOTMATCH).map { |f| f.sub(b, "") }.sort + return false unless a_files == b_files + a_files.each do |rel| + af = File.join(a, rel) + bf = File.join(b, rel) + next if File.directory?(af) + return false unless File.read(af) == File.read(bf) + end + true + end + + def stage_files + @changed_files.each do |file| + system("git", "add", "-A", "--", file, err: File::NULL) + end + end + + def parse_github_repo(url) + match = url.match(%r{github\.com[/:]([^/]+/[^/]+?)(?:\.git)?/?$}) + match && match[1] + end + + def clone_default_branch(repo, dest) + url = "https://github.com/#{repo}.git" + system("git", "init", "-q", dest, out: File::NULL, err: File::NULL) + system("git", "-C", dest, "remote", "add", "origin", url, out: File::NULL, err: File::NULL) + _, stderr, status = Open3.capture3("git", "-C", dest, "fetch", "--depth", "1", "origin", "HEAD") + abort "Failed to clone #{repo}: #{stderr}" unless status.success? + system("git", "-C", dest, "checkout", "-q", "FETCH_HEAD", out: File::NULL, err: File::NULL) + end + + def clone_repo(repo, ref, dest) + url = "https://github.com/#{repo}.git" + system("git", "init", "-q", dest, out: File::NULL, err: File::NULL) + system("git", "-C", dest, "remote", "add", "origin", url, out: File::NULL, err: File::NULL) + _, stderr, status = Open3.capture3("git", "-C", dest, "fetch", "--depth", "1", "origin", ref) + abort "Failed to fetch #{repo}@#{ref}: #{stderr}" unless status.success? + system("git", "-C", dest, "checkout", "-q", "FETCH_HEAD", out: File::NULL, err: File::NULL) + end + + def head_sha(repo_dir) + stdout, _, status = Open3.capture3("git", "-C", repo_dir, "rev-parse", "HEAD") + abort "Failed to resolve HEAD in #{repo_dir}" unless status.success? + stdout.strip + end + + def list_skills(dir) + Dir.children(dir) + .select { |name| File.exist?(File.join(dir, name, "SKILL.md")) } + .sort + end + + def load_manifest + return {} unless File.exist?(MANIFEST_PATH) + JSON.parse(File.read(MANIFEST_PATH)) + end + + def save_manifest(manifest) + FileUtils.mkdir_p(File.dirname(MANIFEST_PATH)) + File.write(MANIFEST_PATH, JSON.pretty_generate(manifest) + "\n") + puts "Updated #{MANIFEST_PATH}" + end +end + +SyncAgents.new.run(ARGV)