diff --git a/.agents/external-skills.json b/.agents/external-skills.json new file mode 100644 index 0000000..038811c --- /dev/null +++ b/.agents/external-skills.json @@ -0,0 +1,7 @@ +{ + "swiftui-pro": { + "repo": "twostraws/swiftui-agent-skill", + "path": "swiftui-pro", + "ref": "61b74001b64b292da8397355464d7c8a4c2c7d89" + } +} diff --git a/.agents/skills/.gitignore b/.agents/skills/.gitignore new file mode 100644 index 0000000..ef7c27b --- /dev/null +++ b/.agents/skills/.gitignore @@ -0,0 +1,2 @@ +# External skills — fetched via ./sync-agents --install +/swiftui-pro/ diff --git a/.agents/skills/custom-container-view-controller/SKILL.md b/.agents/skills/custom-container-view-controller/SKILL.md new file mode 100644 index 0000000..f3444bc --- /dev/null +++ b/.agents/skills/custom-container-view-controller/SKILL.md @@ -0,0 +1,21 @@ +--- +name: custom-container-view-controller +description: Build UIKit custom container view controllers with correct child lifecycle management. Use when creating container view controllers, embedding child VCs, or managing view controller containment. +--- + +# Custom Container View Controller + +## For non-lazy single child containers (the default) + +- Add child view controllers in `init` (do NOT load the view in init). +- Add the child view controllers' view in `viewDidLoad`. Set the frame before adding the subview to self.view. +- Override `viewWillLayoutSubviews` and set the child view controller's view.frame to self.view.bounds. + +## For lazy single child containers (special case) + +Lazily loading / containers are rare, but sometimes are needed, eg if we need the entire view controller and view hierarchy set up before we can create a child. `BRootViewController` is an example of this. In these cases: + +- Add a `setUpIfNeeded` method. +- It should run once, from within `viewIsAppearing`. +- It should create the child view controller (based on a provider closure) and insert it into the view controller hierarchy, and THEN the view hierarchy. +- Override `viewWillLayoutSubviews` and set the child view controller's view.frame to self.view.bounds. \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 2ecf17c..ec5ea99 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -14,3 +14,6 @@ if [ -n "$staged" ]; then 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/.gitignore b/.gitignore index 71f4323..e27f222 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ xcuserdata/ ## macOS .DS_Store +## Generated agent files (re-create via ./sync-agents --install) +CLAUDE.md +.claude/skills/ + ## Obj-C/Swift specific *.hmap diff --git a/AGENTS.md b/AGENTS.md index 829d368..3c901b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,130 +1,57 @@ # AGENTS.md — Repository Shape -This document describes the structure and conventions of the Broadway repository for AI agents and contributors. - -## Overview - -Broadway is a SwiftUI iOS + Mac Catalyst application managed by **Tuist**. The Xcode project is not checked in — it is generated from the `Project.swift` manifest. +Broadway is a SwiftUI iOS + Mac Catalyst design system managed by **Tuist**. The Xcode project is not checked in — it is generated from `Project.swift`. ## Directory Layout ``` -/ -├── .githooks/pre-commit # Git pre-commit hook (SwiftFormat lint) -├── .mise.toml # mise tool versions (pins Tuist, SwiftFormat) -├── .swiftformat # SwiftFormat configuration -├── Tuist.swift # Tuist global configuration -├── Project.swift # Tuist project manifest (root level) -├── BroadwayCatalog/ -│ ├── Sources/ # Catalog app source code (Swift / SwiftUI) -│ │ ├── BroadwayApp.swift # @main entry point -│ │ └── ContentView.swift # Root view -│ ├── Resources/ # Bundled resources (assets, localization, etc.) -│ └── Tests/ # Catalog app unit tests (Swift Testing) -│ └── BroadwayCatalogTests.swift -├── BroadwayUI/ -│ ├── Sources/ # UI framework source code -│ │ └── BRootViewController.swift # Root container VC (context + trait propagation) -│ └── Tests/ # UI framework unit tests (Swift Testing) -│ └── BRootViewControllerTests.swift -├── BroadwayTestHost/ -│ └── Sources/ # Minimal app used as test host for unit tests -│ └── TestHostApp.swift # @main entry point (empty window) -├── BroadwayTesting/ -│ └── Sources/ # Test utilities framework (depends on BroadwayCore) -│ └── BroadwayTesting.swift # Module entry point -├── BroadwayCore/ -│ ├── Sources/ # Core framework source code -│ │ ├── AnyEquatable.swift # Type-erased Equatable wrapper -│ │ ├── BAccessibility.swift # Accessibility snapshot + Observer -│ │ ├── BContext.swift # Root environment container -│ │ ├── BContext+UITraits.swift # UITraitDefinition bridge (#if canImport(UIKit)) -│ │ ├── BStylesheets.swift # Lazy cached stylesheet resolver -│ │ ├── BThemes.swift # Type-keyed theme container -│ │ ├── BTraits.swift # Type-keyed trait container -│ │ ├── CopyOnWrite.swift # COW property wrapper -│ │ └── TypeIdentifier.swift # Lightweight type-keyed identifier -│ └── Tests/ # Core framework unit tests (Swift Testing) -│ ├── AnyEquatableTests.swift -│ ├── BAccessibilityTests.swift -│ ├── BContextTests.swift -│ ├── BThemesTests.swift -│ ├── BTraitsTests.swift -│ ├── CopyOnWriteTests.swift -│ └── TypeIdentifierTests.swift -├── Plans/ # Archived implementation plans (see index below) -├── swiftformat # Run SwiftFormat (--lint to check only) -├── ide # Dev script (installs hooks, runs tuist generate) -├── LICENSE # Apache 2.0 -├── README.md # Project overview and setup instructions -└── AGENTS.md # This file +BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/) +BroadwayUI/ # Reusable UI component framework (Sources/, Tests/) +BroadwayCore/ # Foundational utilities framework (Sources/, Tests/) +BroadwayTestHost/ # Minimal test host app (Sources/) +BroadwayTesting/ # Shared test utilities framework (Sources/) +Project.swift # Tuist project manifest +Tuist.swift # Tuist global configuration +ide # Dev script (installs hooks, runs tuist generate) +swiftformat # Run SwiftFormat (--lint to check only) +sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md ``` ## Build System -- **Tuist 4+** is used to generate the Xcode project from `Project.swift`. +- **Tuist 4+** generates the Xcode project from `Project.swift`. The `.xcodeproj` and `Derived/` are git-ignored. - Tuist and SwiftFormat are version-pinned via **mise** in `.mise.toml`. Run `mise install` to install them. -- Run `./ide` to generate the Xcode project (or `./ide -i` to run `mise exec -- tuist install` first). -- Run `mise exec -- tuist test` to execute all tests. -- Run `mise exec -- tuist test ` to test a specific target. The scheme name is the **framework name** (e.g., `BroadwayCore`), not the test target name (`BroadwayCoreTests`). -- The generated `.xcodeproj` and `Derived/` directory are git-ignored. +- Run `./ide` to generate the project (or `./ide -i` to run `mise exec -- tuist install` first). +- Run `mise exec -- tuist test` to execute all tests, or `mise exec -- tuist test ` for a specific target. ## Formatting -- **SwiftFormat** enforces consistent code style. Configuration lives in `.swiftformat`. -- Run `./swiftformat` to format all Swift files in-place. -- Run `./swiftformat --lint` to check without modifying (used in CI and pre-commit). -- The `./ide` script configures `core.hooksPath` to `.githooks/`, which installs a pre-commit hook that lints staged `.swift` files. -- CI runs `./swiftformat --lint` as a gate before build & test. +- **SwiftFormat** enforces code style via `.swiftformat`. Run `./swiftformat` to format, `./swiftformat --lint` to check. +- The pre-commit hook lints staged `.swift` files automatically. -## Targets +## Agent Instructions Sync -| Target | Type | Bundle ID | Destinations | Min Deployment | -|---|---|---|---|---| -| `BroadwayCatalog` | `.app` | `com.broadway.catalog` | iPhone, iPad, Mac Catalyst | iOS 26.0 | -| `BroadwayCatalogTests` | `.unitTests` | `com.broadway.catalog.tests` | iPhone, iPad, Mac Catalyst | iOS 26.0 | -| `BroadwayUI` | `.framework` | `com.broadway.ui` | iPhone, iPad, Mac Catalyst | iOS 26.0 | -| `BroadwayUITests` | `.unitTests` | `com.broadway.ui.tests` | iPhone, iPad, Mac Catalyst | iOS 26.0 | -| `BroadwayCore` | `.framework` | `com.broadway.core` | iPhone, iPad, Mac Catalyst | iOS 26.0 | -| `BroadwayCoreTests` | `.unitTests` | `com.broadway.core.tests` | iPhone, iPad, Mac Catalyst | iOS 26.0 | -| `BroadwayTestHost` | `.app` | `com.broadway.testhost` | iPhone, iPad, Mac Catalyst | iOS 26.0 | -| `BroadwayTesting` | `.framework` | `com.broadway.testing` | iPhone, iPad, Mac Catalyst | iOS 26.0 | +`AGENTS.md` is the source of truth for AI agent instructions. Cursor and Codex read nested `AGENTS.md` natively; Claude Code uses `CLAUDE.md` and `.claude/skills/`. Generated files (`CLAUDE.md`, `.claude/skills/`) are gitignored and created by `./sync-agents`. -### Dependency Graph +- `./sync-agents` — generate `CLAUDE.md` files and sync skills to `.claude/skills/`. +- `./sync-agents --install` — fetch external skills from `.agents/external-skills.json` (run automatically by `./ide`). +- `./sync-agents --add [name]` — add a new external skill from GitHub. +- `./sync-agents --update` — force re-fetch all external skills to latest. + +## Dependency Graph ``` -BroadwayCatalog (app) ──▶ BroadwayUI (framework) ──▶ BroadwayCore (framework) - ▲ -BroadwayTestHost (app) ──▶ BroadwayUI ──────────────────────┤ - │ -BroadwayTesting (framework) ────────────────────────────────┘ +BroadwayCatalog (app) --> BroadwayUI (framework) --> BroadwayCore (framework) +BroadwayTestHost (app) --> BroadwayUI --> BroadwayCore +BroadwayTesting (framework) --> BroadwayCore All framework test targets use BroadwayTestHost and depend on BroadwayTesting. ``` ## Key Conventions -- **SwiftUI** is the UI framework. Catalog app views live under `BroadwayCatalog/Sources/`. -- **BroadwayUI** is the reusable component library. All shared UI lives under `BroadwayUI/Sources/`. -- **BroadwayCore** provides foundational utilities and shared logic. Source lives under `BroadwayCore/Sources/`. -- **BroadwayTestHost** is a minimal app that serves as the test host for framework unit tests. Source lives under `BroadwayTestHost/Sources/`. -- **BroadwayTesting** provides shared test utilities. All test targets depend on it. Source lives under `BroadwayTesting/Sources/`. +- **Shell scripts** should be kept short (≤ ~20 lines). For anything longer, use **Ruby**. - **Swift Testing** (`import Testing`) is used for unit tests, not XCTest. - Source files use `/Sources/**` globs; test files use `/Tests/**`. -- Resources (asset catalogs, localization files, etc.) go in `BroadwayCatalog/Resources/`. -- The catalog app uses `@main` via `BroadwayApp.swift` as the app entry point. - Info.plist is auto-generated by Tuist via `infoPlist: .extendingDefault(with:)`. - -## Plans - -Implementation plans are stored in the `Plans/` directory. When you develop a plan using Cursor's plan mode, copy the final plan into `Plans/` and add an entry to the index below. - -**Naming convention**: `--.md`, where `` is the next sequential number (zero-padded to 3 digits), `` is the date the plan was created, and `` is a short snake_case description. For example: `002-2026-03-15-dark_mode_support.md`. - -## Adding New Files - -- **New catalog app files**: Add `.swift` files to `BroadwayCatalog/Sources/`. -- **New UI components**: Add `.swift` files to `BroadwayUI/Sources/`. Mark public API as `public`. -- **New test files**: Add `.swift` files to the appropriate `Tests/` directory. -- **New resources**: Add to `BroadwayCatalog/Resources/`. They are bundled via the `Resources/**` glob. -- **New targets or dependencies**: Edit `Project.swift` at the repository root. +- New targets or dependencies: edit `Project.swift`. diff --git a/BroadwayCatalog/AGENTS.md b/BroadwayCatalog/AGENTS.md new file mode 100644 index 0000000..b46a724 --- /dev/null +++ b/BroadwayCatalog/AGENTS.md @@ -0,0 +1,13 @@ +# BroadwayCatalog + +The catalog app — a living showcase of BroadwayUI components. Depends on BroadwayUI. + +## Structure + +- `Sources/` — App views and logic. Entry point is `BroadwayApp.swift` (`@main`). +- `Resources/` — Asset catalogs, localization files, and other bundled resources. + +## Conventions + +- App-specific views go here, not in BroadwayUI. +- Resources are bundled via the `Resources/**` glob in `Project.swift`. diff --git a/BroadwayCore/AGENTS.md b/BroadwayCore/AGENTS.md new file mode 100644 index 0000000..0ff125c --- /dev/null +++ b/BroadwayCore/AGENTS.md @@ -0,0 +1,18 @@ +# BroadwayCore + +Foundational utilities and shared logic. All other frameworks depend on this module. + +## Key Types + +- **BContext** — Root environment container with type-keyed themes, traits, and stylesheets. +- **BThemes** / **BTraits** — Type-keyed containers for theme and trait values. +- **BStylesheets** — Lazy cached stylesheet resolver. +- **BAccessibility** — Accessibility snapshot and observer. +- **AnyEquatable** — Type-erased Equatable wrapper. +- **CopyOnWrite** — COW property wrapper. +- **TypeIdentifier** — Lightweight type-keyed identifier. + +## Conventions + +- Mark all public API as `public`. +- `BContext+UITraits.swift` bridges to `UITraitDefinition` via `#if canImport(UIKit)`. diff --git a/BroadwayUI/AGENTS.md b/BroadwayUI/AGENTS.md new file mode 100644 index 0000000..b604eb6 --- /dev/null +++ b/BroadwayUI/AGENTS.md @@ -0,0 +1,12 @@ +# BroadwayUI + +Reusable UI component library. Depends on BroadwayCore. + +## Key Types + +- **BRootViewController** — Root container view controller that propagates BContext and traits to its children. + +## Conventions + +- Mark all public API as `public`. +- UI components go in `Sources/`. This is the shared component library — app-specific views belong in BroadwayCatalog. diff --git a/Plans/001-2026-02-27-github_ci_setup.md b/Plans/001-2026-02-27-github_ci_setup.md deleted file mode 100644 index 9402ea6..0000000 --- a/Plans/001-2026-02-27-github_ci_setup.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: GitHub CI Setup -overview: Add a GitHub Actions workflow that builds and tests all targets on pull requests and pushes to main, using mise to install the pinned Tuist version on a macOS 26 runner. -todos: - - id: create-workflow - content: Create .github/workflows/ci.yml with the build + test workflow - status: completed -isProject: false ---- - -# GitHub CI Setup - -## Approach - -Create a single workflow file at `.github/workflows/ci.yml` that: - -1. Triggers on **pull requests** and **pushes to main** -2. Runs on `macos-26` (ARM, GA as of Feb 26 2026 -- includes Xcode 26.x with the iOS 26 SDK) -3. Uses `jdx/mise-action@v3` to install Tuist at the version pinned in `[.mise.toml](.mise.toml)` (currently 4.40.0) -4. Runs `tuist test` to build and execute all test targets (`BroadwayCatalogTests` + `BroadwayUITests`) - -## Workflow file - -`.github/workflows/ci.yml` -- roughly: - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Build & Test - runs-on: macos-26 - steps: - - uses: actions/checkout@v4 - - uses: jdx/mise-action@v3 - - run: tuist test -``` - -Key details: - -- `**concurrency**` cancels redundant in-flight runs for the same branch (saves CI minutes) -- `**jdx/mise-action@v3**` reads `.mise.toml`, installs + activates Tuist 4.40.0, and caches it across runs -- `**tuist test**` generates the project and runs all unit test targets in one command -- No need for `tuist generate` separately since `tuist test` handles it - diff --git a/README.md b/README.md index 1c6d556..88b2d76 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ An open-source iOS and Mac Catalyst design system prototype. ## Requirements - Xcode 16+ -- [mise](https://mise.jdx.dev) (manages Tuist automatically) +- [mise](https://mise.jdx.dev) (manages Tuist and SwiftFormat automatically) - iOS 26.0+ ## Getting Started @@ -22,14 +22,17 @@ Or via Homebrew: brew install mise ``` -### Install Tools & Generate +### Set Up the Project ```bash -mise install # Installs the pinned version of Tuist from .mise.toml -./ide # Generates the Xcode project -./ide -i # Runs tuist install first, then generates +mise install # Install pinned versions of Tuist and SwiftFormat +./ide -i # Install dependencies, fetch external skills, generate Xcode project ``` +After the initial setup, run `./ide` (without `-i`) to regenerate the Xcode project without re-installing dependencies. + +The `./ide` script also configures a Git pre-commit hook that automatically formats staged Swift files with SwiftFormat and keeps generated AI agent configuration in sync. + ### Run Tests ```bash @@ -41,43 +44,26 @@ Or open the generated project in Xcode and run tests with **Cmd+U**. ## Project Structure ``` -Broadway/ -├── .mise.toml # mise tool versions (pins Tuist) -├── Tuist.swift # Tuist configuration -├── Project.swift # Tuist project manifest -├── BroadwayCatalog/ -│ ├── Sources/ # Catalog app source files -│ │ ├── BroadwayApp.swift # @main app entry point -│ │ └── ContentView.swift # Root SwiftUI view -│ ├── Resources/ # Asset catalogs, etc. -│ └── Tests/ # Catalog app unit tests -│ └── BroadwayCatalogTests.swift -├── BroadwayUI/ -│ ├── Sources/ # UI framework source files -│ │ └── BroadwayUI.swift -│ └── Tests/ # UI framework unit tests -│ └── BroadwayUITests.swift -├── BroadwayCore/ -│ ├── Sources/ # Core framework source files -│ │ └── BroadwayCore.swift -│ └── Tests/ # Core framework unit tests -│ └── BroadwayCoreTests.swift -├── Plans/ # Archived implementation plans -├── ide # Dev script (generate project) -├── LICENSE # Apache 2.0 -└── README.md +BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/) +BroadwayUI/ # Reusable UI component framework (Sources/, Tests/) +BroadwayCore/ # Foundational utilities framework (Sources/, Tests/) +BroadwayTestHost/ # Minimal test host app +BroadwayTesting/ # Shared test utilities framework +Project.swift # Tuist project manifest +ide # Dev setup script +swiftformat # Run SwiftFormat +sync-agents # Sync AI agent configuration across tools ``` -## Targets +## AI Agent Skills + +External skills are managed via `sync-agents`. The manifest at `.agents/external-skills.json` tracks installed skills pinned to specific commits. -| Target | Product | Destinations | -|---|---|---| -| **BroadwayCatalog** | App | iOS, Mac Catalyst | -| **BroadwayCatalogTests** | Unit Tests | iOS, Mac Catalyst | -| **BroadwayUI** | Framework | iOS, Mac Catalyst | -| **BroadwayUITests** | Unit Tests | iOS, Mac Catalyst | -| **BroadwayCore** | Framework | iOS, Mac Catalyst | -| **BroadwayCoreTests** | Unit Tests | iOS, Mac Catalyst | +```bash +./sync-agents --add [name] # Add a new skill from GitHub +./sync-agents --update # Update all skills to latest +./sync-agents --install # Fetch skills from the manifest +``` ## License diff --git a/ide b/ide index 5a12518..59d4bf3 100755 --- a/ide +++ b/ide @@ -20,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/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)