diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f06139 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Validate skill + run: ./scripts/validate-skill.sh --quick --score-only + + - name: Test + run: go test ./... + + - name: Smoke test + run: ./scripts/smoke-test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f66d8fe..376940d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,98 +1,52 @@ -name: Release +name: release on: push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - workflow_dispatch: - inputs: - version_bump: - description: 'Version bump type' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major + - "v*" permissions: contents: write jobs: - validate: - uses: ./.github/workflows/validate.yml - release: - needs: validate runs-on: ubuntu-latest + steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Determine version - id: version - run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - TAG="${GITHUB_REF#refs/tags/}" - echo "new_tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Using pushed tag: $TAG" - else - LATEST=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) - if [ -z "$LATEST" ]; then - MAJOR=1; MINOR=0; PATCH=0 - else - VERSION="${LATEST#v}" - MAJOR=$(echo "$VERSION" | cut -d. -f1) - MINOR=$(echo "$VERSION" | cut -d. -f2) - PATCH=$(echo "$VERSION" | cut -d. -f3) - - case "${{ inputs.version_bump }}" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; - esac - fi - NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" - echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT" - echo "Computed next version: $NEW_TAG (bump: ${{ inputs.version_bump }})" - fi + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable - - name: Get release info - id: release_info - run: | - MSG=$(git log -1 --pretty=format:'%s') - echo "msg=$MSG" >> "$GITHUB_OUTPUT" + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DELIMITER="EOF_$(openssl rand -hex 8)" - BODY=$(git log -1 --pretty=format:'%b') - { - echo "body<<$DELIMITER" - printf '%s' "$BODY" - echo "" - echo "$DELIMITER" - } >> "$GITHUB_OUTPUT" + - name: Package skills bundle + run: ./scripts/package-skills.sh "${GITHUB_REF_NAME}" - - name: Create tag (manual dispatch only) - if: github.event_name == 'workflow_dispatch' + - name: Generate skills bundle checksum run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "${{ steps.version.outputs.new_tag }}" - git push origin "${{ steps.version.outputs.new_tag }}" - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.new_tag }} - name: ${{ steps.version.outputs.new_tag }} - body: | - **Commit:** `${{ github.sha }}` - **Branch:** `${{ github.ref_name }}` - **Author:** ${{ github.actor }} - - ${{ steps.release_info.outputs.body }} - draft: false - prerelease: false + cd dist + sha256sum "composekit-skills_${GITHUB_REF_NAME}.tar.gz" >> checksums.txt + cat checksums.txt + + - name: Upload checksums (updated) + run: gh release upload "${GITHUB_REF_NAME}" "dist/checksums.txt" --clobber + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload skills bundle + run: gh release upload "${GITHUB_REF_NAME}" "dist/composekit-skills_${GITHUB_REF_NAME}.tar.gz" --clobber + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml deleted file mode 100644 index bbc752c..0000000 --- a/.github/workflows/validate.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Validate Skill - -on: - push: - branches: [main] - pull_request: - workflow_call: - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Run skill scanner - run: ./scripts/validate.sh diff --git a/.gitignore b/.gitignore index e43b0f9..acd8178 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,24 @@ .DS_Store +__MACOSX/ + +# Go build output +bin/ +dist/ +tmp/ +tmp-*/ +*.test +*.out + +# Local smoke-test output +tmp-smoke/ +tmp-skills/ +tmp-home/ + +# Editor/local +.idea/ +.vscode/ +.env +.env.* + +# OS +Thumbs.db diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..c1d10f4 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,51 @@ +version: 2 + +project_name: composekit + +before: + hooks: + - go mod tidy + - go test ./... + - ./scripts/validate-skill.sh --quick --score-only + +builds: + - id: composekit + main: . + binary: composekit + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + +archives: + - id: binaries + builds: + - composekit + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + +release: + draft: false + prerelease: auto diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f340b4d --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +APP := composekit + +.PHONY: build test smoke validate-skill clean snapshot + +build: + go build -o bin/$(APP) . + +test: + go test ./... + +smoke: + ./scripts/smoke-test.sh + +validate-skill: + ./scripts/validate-skill.sh + +clean: + rm -rf bin dist tmp-smoke tmp-skills + +snapshot: + goreleaser release --snapshot --clean diff --git a/README.md b/README.md index 63489d5..d0c1ff7 100644 --- a/README.md +++ b/README.md @@ -1,429 +1,94 @@ -

- Compose Multiplatform icon -

+# ComposeKit -

compose-skill

+ComposeKit is a CLI for installing and updating AI coding skills for Jetpack Compose, Compose Multiplatform, and Kotlin Multiplatform workflows. -

- Make your AI coding tool actually understand Compose.
- A comprehensive agent skill for Jetpack Compose and Compose Multiplatform (KMP/CMP). -

+## Install -

- setup 5 min - Jetpack Compose 1.8+ - Kotlin 2.0+ - Compose Multiplatform 1.7+ - Agent Skills standard -

- ---- - -## What This Skill Does - -This is an **AI agent skill** β€” not a library, not documentation. Install it once, and your AI coding agent (Codex, Cursor, Claude Code) gains production-grade knowledge of the entire Compose app development lifecycle: architecture, UI, state, navigation, networking, persistence, performance, accessibility, cross-platform, build configuration, distribution, and code review. - -The skill covers **Android**, **iOS**, **Desktop**, and **Web** targets with the same architectural principles. - -## What's Covered - - - - - - - - - - - - - - - - - - - - - - - -
πŸ—οΈ Architecture & State🎨 Compose UI🌐 Data & Networking
- MVI with Event, State, Effect
- Unidirectional data flow
- ViewModel patterns
- State modeling (4 buckets)
- Clean code & anti-patterns -
- Three-phase model & side effects
- Coil 3 image loading
- Lists, grids, pagers & keying
- Shared element animations
- Material 3 theming & adaptive -
- Ktor HTTP client & auth flows
- DTO-to-domain mapping
- Room Database (KMP)
- DataStore (Preferences & Typed)
- Paging 3 with MVI -
🧭 Navigation & DI⚑ Performance & QualityπŸ“± Cross-Platform
- Navigation 3 & Nav 2
- Tabs, scenes, deep links
- Koin (CMP) & Hilt (Android)
- ViewModel scoping -
- Recomposition minimization
- Compiler Metrics & profiles
- Turbine testing
- Macrobenchmark & UI tests
- Accessibility & WCAG -
- KMP commonMain sharing
- expect/actual patterns
- iOS interop (SKIE)
- CMP resources & localization
- Gradle/AGP 9+, CI/CD, signing -
- -## Without vs With compose-skill - -| Concern | Without | With | -|:--------|:--------|:-----| -| **State management** | Scattered `mutableStateOf` in composables | Single `StateFlow` owned by ViewModel | -| **Business logic** | Mixed into UI layer | Isolated in ViewModel's `onEvent()` handler | -| **One-shot actions** | Boolean flags in state | `Channel` for navigation, snackbar | -| **Recomposition** | Frequent, hard to diagnose | Minimized via state shape and read boundaries | -| **Navigation** | Ad-hoc calls from composables | Semantic effects, route layer executes | -| **Networking** | Inconsistent error handling | Ktor + `Result`/`ApiResult` wrapper, DTO mappers | -| **Persistence** | Raw SharedPreferences | DataStore + Room with MVI integration | -| **Accessibility** | Missing or incorrect semantics | `contentDescription`, touch targets, WCAG contrast | -| **Cross-platform** | Android-only or inconsistent | `commonMain` with `expect/actual` for platform APIs | -| **Build config** | Hardcoded versions | Version catalog, AGP 9+ patterns, conventions | -| **Testing** | Manual UI testing | ViewModel eventβ†’stateβ†’effect via Turbine | -| **Code review** | Inconsistent patterns | Anti-pattern detection with documented fixes | - -## Installation - -Pick your agent and run **one command**. The clone target becomes the skill folder β€” agents detect `SKILL.md` at the root automatically. - -> **Only three things matter for the skill to work:** `SKILL.md`, `agents/`, and `references/`. Everything else in this repo (README, LICENSE, scripts, assets, .github) is for documentation, validation, and CI β€” the agent never reads them. If you prefer a minimal install, you only need those three. - -> **Why is `SKILL.md` at the root?** All three agents (Codex, Cursor, Claude Code) look for `SKILL.md` at the **top level** of the skill directory. This repo is structured so that cloning it directly into the skill path gives you a ready-to-use skill β€” no moving files or extra nesting required. - -> Skill installation paths may change as agents evolve. The locations below are accurate at the time of writing β€” for the latest instructions, refer to each agent's official docs or ask your agent *"How do I add a skill?"* -> - [Codex Skills docs](https://developers.openai.com/codex/skills/) Β· [Cursor Skills docs](https://www.cursor.com/docs/context/skills) Β· [Claude Code Skills docs](https://code.claude.com/docs/en/slash-commands) - -### Quick Install (copy-paste) - -| Client | User-global | Per-repo | -|:-------|:------------|:---------| -| **Codex** | `~/.codex/skills/compose-skill` | `.codex/skills/compose-skill` | -| **Cursor** | `~/.cursor/skills/compose-skill` | `.cursor/skills/compose-skill` | -| **Claude Code** | `~/.claude/skills/compose-skill` | `.claude/skills/compose-skill` | -| **Other agents** | Upload `SKILL.md`, `agents/`, and `references/` as project knowledge | β€” | +### macOS/Linux ```bash -# Replace with the install location from the table above -git clone https://github.com/Meet-Miyani/compose-skill.git - -# Examples: -git clone https://github.com/Meet-Miyani/compose-skill.git ~/.cursor/skills/compose-skill -git clone https://github.com/Meet-Miyani/compose-skill.git .codex/skills/compose-skill +curl -fsSL https://raw.githubusercontent.com/Meet-Miyani/composekit/main/install.sh | bash ``` -## Common Mistakes - -| Problem | Fix | -|:--------|:----| -| Folder named `compose-skill-main` | Rename to `compose-skill` (GitHub ZIP downloads add `-main`) | -| `SKILL.md` not at root of skill folder | Don't nest inside another directory β€” clone directly into the skill path | -| Skill not detected after install | Restart the agent / IDE | +### Install and initialize -## Verify Activation - -| Client | How to verify | -|:-------|:--------------| -| **Codex** | Run `/skills` β€” `compose-skill` appears in the list | -| **Cursor** | **Settings β†’ Rules** β€” skill appears under *Agent Decides* | -| **Claude Code** | Run `/skills` or ask *"What skills are available?"* | - -## Usage - -Once installed, the skill activates **automatically** when your prompt matches its triggers (`@Composable`, `StateFlow`, `ViewModel`, `KMP`, `Ktor`, `recomposition`, `DataStore`, etc.). You can also invoke it **explicitly** β€” the syntax varies by client: - -| Client | Explicit invocation | Automatic | -|:-------|:-------------------|:----------| -| **Codex CLI** | `$compose-skill` in your prompt | Yes | -| **Codex IDE extension** | `$compose-skill` in chat | Yes | -| **Codex App** | `/compose-skill` in chat | Yes | -| **Cursor** | `/compose-skill` in Agent chat | Yes | -| **Claude Code** | `/compose-skill` in chat | Yes | - -### Invocation Examples - -**Codex CLI / IDE extension** β€” dollar-sign prefix: -```text -$compose-skill Refactor this screen to MVI with proper state modeling. +```bash +curl -fsSL https://raw.githubusercontent.com/Meet-Miyani/composekit/main/install.sh | bash -s -- --init ``` -**Codex App / Cursor / Claude Code** β€” slash prefix: -```text -/compose-skill How do I set up Paging 3 with MVI in a KMP project? -``` +## Usage -## Skill Structure +Install the default Compose skill into detected AI coding agents: -```text -compose-skill/ -β”‚ -β”‚ ## Required (the skill itself) ───────────────────── -β”œβ”€β”€ SKILL.md # Skill definition β€” agent reads this -β”œβ”€β”€ agents/ -β”‚ └── openai.yaml # Codex UI metadata -└── references/ # 37 deep-dive reference files - β”‚ # (loaded on-demand by SKILL.md) - β”œβ”€β”€ architecture.md - β”œβ”€β”€ coroutines-flow.md - β”œβ”€β”€ compose-essentials.md - β”œβ”€β”€ material-design.md - β”œβ”€β”€ image-loading.md - β”œβ”€β”€ lists-grids.md - β”œβ”€β”€ paging.md - β”œβ”€β”€ paging-offline.md - β”œβ”€β”€ paging-mvi-testing.md - β”œβ”€β”€ navigation.md - β”œβ”€β”€ navigation-3.md - β”œβ”€β”€ navigation-2.md - β”œβ”€β”€ navigation-3-di.md - β”œβ”€β”€ navigation-2-di.md - β”œβ”€β”€ navigation-migration.md - β”œβ”€β”€ performance.md - β”œβ”€β”€ animations.md - β”œβ”€β”€ ui-ux.md - β”œβ”€β”€ testing.md - β”œβ”€β”€ room-database.md - β”œβ”€β”€ datastore.md - β”œβ”€β”€ networking-ktor.md - β”œβ”€β”€ networking-ktor-auth.md - β”œβ”€β”€ networking-ktor-testing.md - β”œβ”€β”€ networking-ktor-architecture.md - β”œβ”€β”€ dependency-injection.md - β”œβ”€β”€ koin.md - β”œβ”€β”€ hilt.md - β”œβ”€β”€ cross-platform.md - β”œβ”€β”€ resources.md - β”œβ”€β”€ ios-swift-interop.md - β”œβ”€β”€ accessibility.md - β”œβ”€β”€ clean-code.md - β”œβ”€β”€ anti-patterns.md - β”œβ”€β”€ gradle-build.md - └── ci-cd-distribution.md -β”‚ -β”‚ ## Optional (repo extras) ────────────────────────── -β”œβ”€β”€ README.md # This file (not read by agents) -β”œβ”€β”€ LICENSE # MIT License -β”œβ”€β”€ assets/ -β”‚ └── compose-multiplatform-icon.svg # Logo for README -└── scripts/ - └── validate.sh # Skill scanner / validation tool +```bash +composekit init ``` -## Reference Guide - -The `references/` directory contains 37 deep-dive files that the skill loads on-demand. Here's what each one covers in detail: - -
-Kotlin Foundations - -| Reference | What's Inside | -|:----------|:-------------| -| **coroutines-flow.md** | StateFlow vs SharedFlow vs Channel decision table, Flow operators (`flatMapLatest`, `combine`, `debounce`, `catch`), Dispatchers (IO/Default/Main), structured concurrency (`viewModelScope`, `supervisorScope`), exception handling, `CancellationException`, `stateIn`/`shareIn`, backpressure (`buffer`/`conflate`/`collectLatest`), `callbackFlow`, Mutex/Semaphore, testing with Turbine | - -
- -
-Architecture +Update installed skills to the latest release: -| Reference | What's Inside | -|:----------|:-------------| -| **architecture.md** | ViewModel/event-handling pipeline, state modeling, Channel vs SharedFlow for effects, domain layer rules, inter-feature communication (event bus, feature API contracts), module dependency rules, GOOD/BAD code examples | -| **clean-code.md** | Avoiding overengineering, file organization, naming conventions, disciplined vs bloated MVI comparison | -| **anti-patterns.md** | Cross-cutting anti-pattern quick-reference table with "why it hurts" and "better replacement" for each, plus routing index to domain-specific anti-patterns in other reference files | - -
- -
-Compose APIs - -| Reference | What's Inside | -|:----------|:-------------| -| **material-design.md** | M3 theme setup (dynamic color, dark/light, color roles), typography/shapes, component decisions (Scaffold, TopAppBar, NavigationBar/Rail/Suite, BottomSheet, Snackbar, Dialog), adaptive layouts (window size classes, canonical layouts), M2β†’M3 migration | -| **image-loading.md** | Coil 3 setup for Compose/CMP, `AsyncImage`/`rememberAsyncImagePainter`/`SubcomposeAsyncImage` decision guide, placeholder/error/fallback/crossfade, memory/disk/network cache policy, transformations vs `Modifier.clip`, SVG (`coil-svg`), `Res.getUri` resource loading | -| **compose-essentials.md** | Three phases model, state primitives, side effects (`LaunchedEffect`, `DisposableEffect`, `rememberUpdatedState`), modifier ordering, `graphicsLayer`, slot pattern, `CompositionLocal`, `collectAsStateWithLifecycle` | -| **lists-grids.md** | LazyColumn/LazyRow, keys, `contentType`, grids, pager, scroll state, nested scrolling, list anti-patterns | -| **paging.md** | PagingSource, Pager + ViewModel setup (PagingData as separate Flow, never in UiState), `cachedIn`, filter/search with `flatMapLatest`, `LazyPagingItems` (all lazy layouts), LoadState handling, PagingData transformations, `PagingSource.invalidate()` | -| **paging-offline.md** | RemoteMediator offline-first with Room, `initialize()` (`LAUNCH_INITIAL_REFRESH` vs `SKIP_INITIAL_REFRESH`), remote keys, Pager wiring | -| **paging-mvi-testing.md** | MVI dual-flow pattern (PagingData separate from UiState), route collection, PagingSource unit tests, `asSnapshot`, `TestPager`, anti-patterns table | -| **navigation.md** | Shared navigation concepts: Nav 2 vs Nav 3 decision guide, MVI navigation rules (both versions), anti-patterns table, routing to version-specific files | -| **navigation-3.md** | Nav 3 full reference: route definition, back stack persistence, `NavDisplay` full API, top-level tabs (`NavigationSuiteScaffold`), ViewModel scoping with entry decorators, Scenes (dialog, bottom sheet, list-detail, Material Adaptive), animations, back stack manipulation, deep links, CMP polymorphic serialization | -| **navigation-2.md** | Nav 2 full reference: `NavHost`/`NavController`, type-safe routes (2.8+), string routes, top-level tabs (`NavigationBar` + `currentBackStackEntryAsState` + `saveState`/`restoreState`), deep links (`NavDeepLink`), navigate with results (`SavedStateHandle`), nested graphs, animations (`enterTransition`/`exitTransition`), conditional navigation (auth guards), predictive back | -| **navigation-3-di.md** | Nav 3 + DI wiring: Hilt `hiltViewModel` in entry blocks + `@AssistedInject` + multibinding entry providers, Koin `navigation` DSL + `koinEntryProvider()`, modularization api/impl split, entry-scoped VMs via decorators | -| **navigation-2-di.md** | Nav 2 + DI wiring: Hilt `hiltViewModel` in composable destinations + graph-scoped VMs via `getBackStackEntry` + `SavedStateHandle`, Koin `koinViewModel` + `koinNavViewModel` + `sharedKoinViewModel` for graph-scoped sharing | -| **navigation-migration.md** | Nav 2 to Nav 3 migration: conceptual shift table, step-by-step migration (routes β†’ NavKey, controller β†’ backStack, NavHost β†’ NavDisplay, graph VMs β†’ entry decorators, deep links, tabs), incremental strategy, coexistence | - -
- -
-Performance & Quality - -| Reference | What's Inside | -|:----------|:-------------| -| **performance.md** | Three phases, primitive state specializations, `TextFieldState`, Strong Skipping Mode, stability config, Compose Compiler Metrics, baseline profiles, API decision table, 20 recomposition rules, diagnostic checklist | -| **animations.md** | Complete animation API reference: decision tree, `AnimationSpec` (spring/tween/keyframes), `animate*AsState`, `Animatable` (sequential, concurrent, gesture-driven), `updateTransition`, `AnimatedVisibility`, `AnimatedContent`, shared element transitions with navigation and Coil, swipe-to-dismiss, Canvas/custom drawing, `graphicsLayer`, performance optimization | -| **ui-ux.md** | Loading states, skeleton/shimmer, preserving content during refresh, inline validation, perceived performance | -| **accessibility.md** | `contentDescription` rules, `Modifier.semantics` (role, stateDescription, heading), `mergeDescendants`, `clearAndSetSemantics`, touch targets (48dp), WCAG color contrast, custom interactive elements, custom accessibility actions | -| **testing.md** | Turbine for StateFlow testing, ViewModel event→state→effect testing, validation/UI tests, Macrobenchmark, lean test matrix by app scale | - -
- -
-Data & Persistence - -| Reference | What's Inside | -|:----------|:-------------| -| **datastore.md** | KMP + Android setup, Preferences DataStore keys/read/write, Typed DataStore with JSON serialization, singleton enforcement, corruption handling, SharedPreferences migration, MVI integration, DI wiring, testing, anti-patterns | -| **room-database.md** | Entity design, performance-oriented DAOs, indexes, relationships (`@Embedded`/`@Relation`/`@Junction`), TypeConverters, transactions, migrations, MVI integration, anti-patterns | - -
- -
-Networking, DI & Cross-Platform - -| Reference | What's Inside | -|:----------|:-------------| -| **networking-ktor.md** | HttpClient configuration, platform engines, plugins (ContentNegotiation, Retry, Timeout, Logging, ContentEncoding), custom plugins (`createClientPlugin`), DTOs, mappers, API service (CRUD, multipart), repository pattern, proxy/SSL | -| **networking-ktor-auth.md** | Bearer token auth with refresh, WebSockets (frames, serialization converter, session), SSE (Server-Sent Events) | -| **networking-ktor-testing.md** | MockEngine setup (success, error, request assertions, multiple responses), engine injection, Koin/Hilt DI integration, testing anti-patterns | -| **networking-ktor-architecture.md** | `Result` vs `ApiResult` decision, `safeRequest` wrapper, exception classification, plugin composition strategy, client factory design, response observation plugin, debug vs production, architecture anti-patterns | -| **dependency-injection.md** | DI decision guide (Hilt vs Koin), shared concepts | -| **koin.md** | Koin setup for CMP and Android, module organization, `koinViewModel`, `koinInject`, Koin + Nav 3 (`navigation`, `koinEntryProvider`), scoped navigation, MVI ViewModel integration, testing | -| **hilt.md** | Android-only Hilt setup, `@HiltViewModel`, `hiltViewModel()`, modules (`@Provides`/`@Binds`), scopes, Navigation Compose integration, MVI pattern with Hilt, testing | -| **cross-platform.md** | `commonMain` vs platform placement, interfaces vs `expect/actual`, platform bridge patterns (interface+DI, expect/actual, typealias), lifecycle, state restoration, resources, accessibility | -| **ios-swift-interop.md** | Kotlin→Swift naming, nullability/collection bridging, SKIE setup, suspend→async, Flow→AsyncSequence, sealed class mapping, SwiftUI/UIKit interop (`ComposeUIViewController`, `UIKitView`), iOS API design rules | -| **resources.md** | Android `R` vs CMP `Res` comparison, `composeResources/` directory structure, Gradle setup, drawable/string/plural/font/raw-file APIs with code examples, qualifiers (language, theme, density), localization, generated resource maps, Android assets interop (`Res.getUri`), MVI integration | - -
- -
-Build, Distribution & CI/CD - -| Reference | What's Inside | -|:----------|:-------------| -| **gradle-build.md** | AGP 9+ project structure, version catalog (`[versions]`/`[libraries]`/`[plugins]`/`[bundles]`), bundle patterns, composite builds (`includeBuild` + `dependencySubstitution`), private Maven repos, `settings.gradle.kts`, `gradle.properties`, module-level build scripts, `compileSdk { version = release(N) }`, KSP/Room/Koin wiring, convention plugins guidance | -| **ci-cd-distribution.md** | GitHub Actions workflows (Android APK, Desktop multi-OS DMG/MSI/DEB), desktop app module setup (`compose.desktop`), iOS Xcode framework integration, signing/notarization (Android/macOS/iOS), adding JVM desktop target to existing CMP project, Gradle task reference table | - -
- -## Example Prompts - -
-Architecture & State - -```text -Refactor this Compose screen to MVI. -How should I structure a KMP feature module with Compose UI and ViewModel? -Audit this feature against the compose-skill and list anti-patterns first, then apply minimal fixes. +```bash +composekit update ``` -
-
-UI & Performance +List available skills: -```text -I have too much recomposition in this form screen. What should I change? -Optimize recomposition in this screen and explain each state-shape change. -Add shared element transitions between my list and detail screens. +```bash +composekit skills list ``` -
-
-Data & Networking +Find skills: -```text -Set up Ktor with bearer token auth and refresh for my KMP project. -Should this be SharedFlow or Channel for one-off effects? -How do I use DataStore for user preferences in a KMP app? +```bash +composekit skills find navigation ``` -
-
-Cross-Platform & Distribution +Detect supported agent skill directories: -```text -How do I expose this Kotlin StateFlow to Swift using SKIE? -Set up GitHub Actions to build DMG, MSI, and DEB for my desktop app. -Add iOS target to my existing Compose Multiplatform project. +```bash +composekit targets detect ``` -
-
-Accessibility & Quality +## Commands + +| Command | Description | +|:--------|:------------| +| `composekit init` | Install the Compose skill to detected targets | +| `composekit update` | Update installed skills | +| `composekit doctor` | Check installation status | +| `composekit remove` | Remove installed skills | +| `composekit version` | Print CLI version | +| `composekit skills list` | List available skills | +| `composekit skills find ` | Search skills by name, description, or keywords | +| `composekit skills add ` | Install a specific skill | +| `composekit skills remove ` | Remove a specific skill | +| `composekit skills installed` | List installed skills | +| `composekit targets detect` | Detect supported agent skill directories | +| `composekit targets list` | List saved targets | +| `composekit targets add ` | Add a custom target directory | +| `composekit targets remove ` | Remove a custom target directory | + +## Supported agent skill directories + +ComposeKit detects and installs skills into: + +| Agent | Path | +|---|---| +| Antigravity | `~/.gemini/antigravity/skills` | +| Claude | `~/.claude/skills` | +| Codex | `~/.codex/skills` | +| Cursor | `~/.cursor/skills` | +| Firebender | `~/.firebender/skills` | +| Gemini | `~/.gemini/skills` | +| OpenCode | `~/.config/opencode/skills` | + +## Building from source -```text -Review this screen for accessibility issues. -How do I make my custom interactive component accessible? -Set up ViewModel tests with Turbine for this feature. +```bash +git clone https://github.com/Meet-Miyani/composekit.git +cd composekit +go build -o bin/composekit . ``` -
- -## Official Documentation - -| Resource | Link | -|:---------|:-----| -| Jetpack Compose | [developer.android.com/compose](https://developer.android.com/develop/ui/compose) | -| Compose Multiplatform | [jetbrains.com/compose-multiplatform](https://www.jetbrains.com/compose-multiplatform/) | -| Kotlin Coroutines | [kotlinlang.org/coroutines](https://kotlinlang.org/docs/coroutines-overview.html) | -| StateFlow & SharedFlow | [kotlinlang.org/flow](https://kotlinlang.org/docs/flow.html#stateflow-and-sharedflow) | -| ViewModel | [developer.android.com/viewmodel](https://developer.android.com/topic/libraries/architecture/viewmodel) | -| Navigation 3 | [developer.android.com/navigation](https://developer.android.com/develop/ui/compose/navigation) | -| Coil | [coil-kt.github.io/coil](https://coil-kt.github.io/coil/) | -| Paging 3 | [developer.android.com/paging](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) | -| Room | [developer.android.com/room](https://developer.android.com/training/data-storage/room) | -| DataStore | [developer.android.com/datastore](https://developer.android.com/topic/libraries/architecture/datastore) | -| Ktor Client | [ktor.io/docs/client](https://ktor.io/docs/client.html) | -| Koin | [insert-koin.io](https://insert-koin.io/docs/reference/koin-compose/compose/) | -| Hilt | [developer.android.com/hilt](https://developer.android.com/training/dependency-injection/hilt-android) | -| Agent Skills Standard | [agentskills.io](https://agentskills.io/) | - -## Contributing - -Contributions are welcome! Whether it's fixing a typo, improving a reference doc, or adding coverage for a new Compose API β€” all help is appreciated. - -1. **Fork** the repository -2. **Create a branch** for your change (`git checkout -b improve-navigation-docs`) -3. **Make your changes** β€” keep reference files focused and under 500 lines where possible -4. **Run the scanner** to verify everything passes: - ```bash - ./scripts/validate.sh - ``` -5. **Open a pull request** with a clear description of what changed and why - -### Guidelines - -- Follow the [agentskills.io specification](https://agentskills.io/specification) for any structural changes -- Keep `SKILL.md` body under 500 lines β€” move detailed content to `references/` -- Every reference file in `references/` should be linked from `SKILL.md` -- Use code examples that compile and follow the skill's MVI conventions -- Don't add dependencies β€” the skill is pure markdown and bash ## License -This project is licensed under the [MIT License](LICENSE). - ---- - -

- Built for Jetpack Compose and Compose Multiplatform.
- Works with Codex, Cursor, Claude Code, and any Agent Skills-compatible tool. -

+MIT License β€” see [LICENSE](LICENSE). diff --git a/agents/openai.yaml b/agents/openai.yaml deleted file mode 100644 index e5500d4..0000000 --- a/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "compose-skill" - short_description: "AI agent skill for Jetpack Compose and Compose Multiplatform β€” architecture, state, navigation, DI, performance, cross-platform, and code review" - default_prompt: "Use $compose-skill to build, refactor, or review Compose/CMP features, adapting to the project's architecture (MVI recommended for new work), and verifying dependencies before recommending them." diff --git a/catalog/skills.json b/catalog/skills.json new file mode 100644 index 0000000..7ae511d --- /dev/null +++ b/catalog/skills.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "skills": [ + { + "name": "compose", + "path": "skills/compose", + "displayName": "Compose Multiplatform", + "description": "Production-grade Jetpack Compose and Compose Multiplatform guidance for Kotlin, KMP, CMP, architecture, state management, navigation, networking, persistence, performance, accessibility, testing, previews, and cross-platform UI delivery.", + "keywords": [ + "compose", + "jetpack-compose", + "compose-multiplatform", + "cmp", + "kmp", + "kotlin", + "kotlin-multiplatform", + "android", + "ios", + "desktop", + "navigation", + "ui", + "state", + "mvi", + "mvvm", + "ktor", + "room", + "datastore", + "sql", + "testing", + "performance", + "accessibility" + ] + } + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..73df5b4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Meet-Miyani/composekit + +go 1.23 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ba0ac6c --- /dev/null +++ b/install.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="${COMPOSEKIT_REPO:-Meet-Miyani/composekit}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +BINARY="composekit" + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" + +case "$os" in + darwin) os="darwin" ;; + linux) os="linux" ;; + *) echo "Unsupported OS: $os"; exit 1 ;; +esac + +case "$arch" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) echo "Unsupported architecture: $arch"; exit 1 ;; +esac + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +api="https://api.github.com/repos/${REPO}/releases/latest" +release_json="$tmp/release.json" + +curl -fsSL "$api" -o "$release_json" + +asset_url="$(grep -o '"browser_download_url": "[^"]*"' "$release_json" \ + | cut -d'"' -f4 \ + | grep "${os}_${arch}" \ + | head -n 1)" + +checksums_url="$(grep -o '"browser_download_url": "[^"]*"' "$release_json" \ + | cut -d'"' -f4 \ + | grep 'checksums.txt' \ + | head -n 1)" + +if [ -z "${asset_url:-}" ]; then + echo "Could not find release asset for ${os}_${arch}" + exit 1 +fi + +if [ -z "${checksums_url:-}" ]; then + echo "Could not find checksums.txt" + exit 1 +fi + +archive="$tmp/archive" +checksums="$tmp/checksums.txt" + +curl -fsSL "$asset_url" -o "$archive" +curl -fsSL "$checksums_url" -o "$checksums" + +archive_name="$(basename "$asset_url")" +if command -v sha256sum >/dev/null 2>&1; then + sha_cmd="sha256sum" +else + sha_cmd="shasum -a 256" +fi +actual_sha="$($sha_cmd "$archive" | awk '{print $1}')" +expected_sha="$(grep "$archive_name" "$checksums" | awk '{print $1}')" + +if [ "$actual_sha" != "$expected_sha" ]; then + echo "Checksum verification failed" + echo "expected: $expected_sha" + echo "actual: $actual_sha" + exit 1 +fi + +mkdir -p "$tmp/extract" +case "$archive_name" in + *.tar.gz) tar -xzf "$archive" -C "$tmp/extract" ;; + *.zip) unzip -q "$archive" -d "$tmp/extract" ;; + *) echo "Unsupported archive format: $archive_name"; exit 1 ;; +esac + +mkdir -p "$INSTALL_DIR" +found_binary="$(find "$tmp/extract" -type f -name "$BINARY" -perm -111 | head -n 1)" + +if [ -z "${found_binary:-}" ]; then + found_binary="$(find "$tmp/extract" -type f -name "$BINARY" | head -n 1)" +fi + +if [ -z "${found_binary:-}" ]; then + echo "Could not find $BINARY in archive" + exit 1 +fi + +cp "$found_binary" "$INSTALL_DIR/$BINARY" +chmod +x "$INSTALL_DIR/$BINARY" + +echo "Installed $BINARY to $INSTALL_DIR/$BINARY" + +# Run init if --init flag is passed +for arg in "$@"; do + if [ "$arg" = "--init" ]; then + echo "" + echo "Running composekit init..." + "$INSTALL_DIR/$BINARY" init + break + fi +done + +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + echo "" + echo "Add this to your shell profile if needed:" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" + ;; +esac diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go new file mode 100644 index 0000000..7c411d1 --- /dev/null +++ b/internal/catalog/catalog.go @@ -0,0 +1,131 @@ +package catalog + +import ( + "encoding/json" + "fmt" + "io/fs" + "strings" + + "github.com/Meet-Miyani/composekit/internal/targets" +) + +type SkillEntry struct { + Name string `json:"name"` + Path string `json:"path"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Keywords []string `json:"keywords"` +} + +type Catalog struct { + SchemaVersion int `json:"schemaVersion"` + Skills []SkillEntry `json:"skills"` +} + +func Load(efs fs.FS, path string) (*Catalog, error) { + data, err := fs.ReadFile(efs, path) + if err != nil { + return nil, err + } + var c Catalog + if err := json.Unmarshal(data, &c); err != nil { + return nil, err + } + return &c, nil +} + +func (c *Catalog) FindByQuery(query string) []SkillEntry { + q := strings.ToLower(query) + var results []SkillEntry + for _, s := range c.Skills { + if strings.Contains(strings.ToLower(s.Name), q) { + results = append(results, s) + continue + } + if strings.Contains(strings.ToLower(s.DisplayName), q) { + results = append(results, s) + continue + } + if strings.Contains(strings.ToLower(s.Description), q) { + results = append(results, s) + continue + } + for _, kw := range s.Keywords { + if strings.Contains(strings.ToLower(kw), q) { + results = append(results, s) + break + } + } + } + return results +} + +func (c *Catalog) FindByName(name string) *SkillEntry { + for _, s := range c.Skills { + if s.Name == name { + return &s + } + } + return nil +} + +type InstallStatus struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Path string `json:"path"` +} + +func (c *Catalog) PrintListShort() { + fmt.Println("Available skills:") + fmt.Println() + for _, s := range c.Skills { + fmt.Printf(" %s\n", s.Name) + } +} + +func (c *Catalog) PrintSkillsLong(statuses map[string][]InstallStatus) { + for _, s := range c.Skills { + fmt.Printf("%s\n", s.Name) + fmt.Printf(" Display name: %s\n", s.DisplayName) + fmt.Printf(" Description: %s\n", s.Description) + fmt.Printf(" Keywords: %s\n", strings.Join(s.Keywords, ", ")) + fmt.Println(" Installed:") + if sts, ok := statuses[s.Name]; ok { + for _, st := range sts { + inst := "no" + if st.Installed { + inst = "yes" + } + agentName := targets.AgentNameForPath(st.Name) + fmt.Printf(" %-12s %-4s %s\n", agentName, inst, st.Path) + } + } + fmt.Println() + } +} + +func (c *Catalog) PrintFindResults(query string, results []SkillEntry) { + if len(results) == 0 { + fmt.Printf("No skills found matching '%s'\n", query) + return + } + for _, s := range results { + fmt.Printf("%s\n", s.Name) + fmt.Printf(" %s\n", s.DisplayName) + q := strings.ToLower(query) + if strings.Contains(strings.ToLower(s.Name), q) { + fmt.Printf(" Matched: name\n") + } else if strings.Contains(strings.ToLower(s.DisplayName), q) { + fmt.Printf(" Matched: display name\n") + } else if strings.Contains(strings.ToLower(s.Description), q) { + fmt.Printf(" Matched: description\n") + } else { + for _, kw := range s.Keywords { + if strings.Contains(strings.ToLower(kw), q) { + fmt.Printf(" Matched: keyword %s\n", kw) + break + } + } + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fd3bfd0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/Meet-Miyani/composekit/internal/targets" +) + +type Config struct { + Targets []string `json:"targets"` +} + +func ConfigDir() (string, error) { + if v := os.Getenv("COMPOSEKIT_CONFIG_HOME"); v != "" { + return v, nil + } + userDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + dir := filepath.Join(userDir, "composekit") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + return dir, nil +} + +func ConfigPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +func Load() *Config { + cfg := &Config{} + p, err := ConfigPath() + if err != nil { + return cfg + } + data, err := os.ReadFile(p) + if err != nil { + return cfg + } + json.Unmarshal(data, cfg) + for i, t := range cfg.Targets { + cfg.Targets[i] = targets.ExpandHome(t) + } + return cfg +} + +func Save(cfg *Config) error { + p, err := ConfigPath() + if err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0644) +} diff --git a/internal/github/releases.go b/internal/github/releases.go new file mode 100644 index 0000000..6ea6da0 --- /dev/null +++ b/internal/github/releases.go @@ -0,0 +1,199 @@ +package github + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +type ReleaseAsset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +type GitHubRelease struct { + TagName string `json:"tag_name"` + Assets []ReleaseAsset `json:"assets"` +} + +func Repo() string { + if v := os.Getenv("COMPOSEKIT_REPO"); v != "" { + return v + } + return "Meet-Miyani/composekit" +} + +func FetchLatestRelease() (*GitHubRelease, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", Repo()) + resp, err := http.Get(apiURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil +} + +func FindAsset(release *GitHubRelease, pattern string) *ReleaseAsset { + for _, a := range release.Assets { + if strings.Contains(a.Name, pattern) { + return &a + } + } + return nil +} + +func DownloadFile(url, destination string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("download returned %d for %s", resp.StatusCode, url) + } + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return err + } + out, err := os.Create(destination) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +func VerifyChecksum(filePath, expectedHex string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + sum := fmt.Sprintf("%x", sha256.Sum256(data)) + if sum != expectedHex { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHex, sum) + } + return nil +} + +func ParseChecksums(checksumsPath string) (map[string]string, error) { + data, err := os.ReadFile(checksumsPath) + if err != nil { + return nil, err + } + result := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) >= 2 { + result[parts[1]] = parts[0] + } + } + return result, nil +} + +func ExtractTarGz(archivePath, destDir string) error { + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target := filepath.Join(destDir, header.Name) + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + out, err := os.Create(target) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + } + } + return nil +} + +func DownloadSkillsBundle(release *GitHubRelease, tmpDir string) (string, error) { + bundlePattern := fmt.Sprintf("composekit-skills_%s.tar.gz", release.TagName) + asset := FindAsset(release, bundlePattern) + if asset == nil { + bundlePattern = "composekit-skills_" + asset = FindAsset(release, bundlePattern) + } + if asset == nil { + return "", fmt.Errorf("no skills bundle found in release %s", release.TagName) + } + + archivePath := filepath.Join(tmpDir, asset.Name) + if err := DownloadFile(asset.URL, archivePath); err != nil { + return "", fmt.Errorf("download skills bundle: %w", err) + } + + checksumsAsset := FindAsset(release, "checksums.txt") + if checksumsAsset != nil { + checksumsPath := filepath.Join(tmpDir, "checksums.txt") + if err := DownloadFile(checksumsAsset.URL, checksumsPath); err != nil { + return "", fmt.Errorf("download checksums: %w", err) + } + checksums, err := ParseChecksums(checksumsPath) + if err != nil { + return "", fmt.Errorf("parse checksums: %w", err) + } + if expectedHex, ok := checksums[asset.Name]; ok { + if err := VerifyChecksum(archivePath, expectedHex); err != nil { + return "", fmt.Errorf("checksum verification: %w", err) + } + } + } + + extractDir := filepath.Join(tmpDir, "skills-bundle") + if err := ExtractTarGz(archivePath, extractDir); err != nil { + return "", fmt.Errorf("extract skills bundle: %w", err) + } + + return extractDir, nil +} diff --git a/internal/install/install.go b/internal/install/install.go new file mode 100644 index 0000000..c2c4f3c --- /dev/null +++ b/internal/install/install.go @@ -0,0 +1,152 @@ +package install + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/Meet-Miyani/composekit/internal/manifest" +) + +func CopyPayload(efs fs.FS, payloadRoot, dest string) error { + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + return fs.WalkDir(efs, payloadRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(payloadRoot, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + out := filepath.Join(dest, rel) + if d.IsDir() { + return os.MkdirAll(out, 0755) + } + data, err := fs.ReadFile(efs, path) + if err != nil { + return err + } + return os.WriteFile(out, data, 0644) + }) +} + +func CopyDir(srcDir, dest string) error { + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + out := filepath.Join(dest, rel) + if d.IsDir() { + return os.MkdirAll(out, 0755) + } + src, err := os.Open(path) + if err != nil { + return err + } + defer src.Close() + outFile, err := os.Create(out) + if err != nil { + return err + } + defer outFile.Close() + _, err = io.Copy(outFile, src) + return err + }) +} + +func ManagedFiles(efs fs.FS, payloadRoot string) ([]string, error) { + var files []string + err := fs.WalkDir(efs, payloadRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(payloadRoot, path) + if err != nil { + return err + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + return files, err +} + +func ManagedFilesFromDir(srcDir string) ([]string, error) { + var files []string + err := filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + return files, err +} + +func ExistsEmbedded(efs fs.FS, payloadRoot, rel string) bool { + _, err := efs.Open(filepath.ToSlash(filepath.Join(payloadRoot, rel))) + return err == nil +} + +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func InstallSkill(efs fs.FS, skillRoot, dest, name, displayName, cliVersion, gitCommit, source string) error { + if err := CopyPayload(efs, skillRoot, dest); err != nil { + return err + } + files, err := ManagedFiles(efs, skillRoot) + if err != nil { + return err + } + return manifest.Write(dest, name, displayName, cliVersion, gitCommit, source, files) +} + +func InstallSkillFromDir(srcDir, dest, name, displayName, cliVersion, gitCommit, source string) error { + if err := CopyDir(srcDir, dest); err != nil { + return err + } + files, err := ManagedFilesFromDir(srcDir) + if err != nil { + return err + } + return manifest.Write(dest, name, displayName, cliVersion, gitCommit, source, files) +} + +func RemoveSkill(dest string) error { + if !Exists(dest) { + return fmt.Errorf("not installed at %s", dest) + } + if !manifest.IsManaged(dest) { + return fmt.Errorf("unmanaged install at %s; use --force to remove", dest) + } + return os.RemoveAll(dest) +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..5494a2d --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,67 @@ +package manifest + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +const ManifestFile = ".composekit-manifest.json" + +type Manifest struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + InstalledBy string `json:"installedBy"` + SkillVersion string `json:"skillVersion"` + CliVersion string `json:"cliVersion"` + GitCommit string `json:"gitCommit"` + Source string `json:"source"` + InstalledAt string `json:"installedAt"` + ManagedFiles []string `json:"managedFiles"` +} + +func Read(skillDir string) (*Manifest, error) { + p := filepath.Join(skillDir, ManifestFile) + data, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} + +func Write(skillDir, name, displayName, cliVersion, gitCommit, source string, managedFiles []string) error { + m := Manifest{ + Name: name, + DisplayName: displayName, + InstalledBy: "composekit", + SkillVersion: cliVersion, + CliVersion: cliVersion, + GitCommit: gitCommit, + Source: source, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + ManagedFiles: managedFiles, + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(skillDir, ManifestFile), data, 0644) +} + +func Exists(skillDir string) bool { + _, err := os.Stat(filepath.Join(skillDir, ManifestFile)) + return err == nil +} + +func IsManaged(skillDir string) bool { + m, err := Read(skillDir) + if err != nil { + return false + } + return m.InstalledBy == "composekit" +} diff --git a/internal/targets/targets.go b/internal/targets/targets.go new file mode 100644 index 0000000..2bb709f --- /dev/null +++ b/internal/targets/targets.go @@ -0,0 +1,167 @@ +package targets + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +type AgentTarget struct { + Name string + SkillDir string + DetectPath string +} + +var KnownAgentTargets = []AgentTarget{ + {Name: "antigravity", SkillDir: ".gemini/antigravity/skills", DetectPath: ".gemini/antigravity"}, + {Name: "claude", SkillDir: ".claude/skills", DetectPath: ".claude"}, + {Name: "codex", SkillDir: ".codex/skills", DetectPath: ".codex"}, + {Name: "cursor", SkillDir: ".cursor/skills", DetectPath: ".cursor"}, + {Name: "firebender", SkillDir: ".firebender/skills", DetectPath: ".firebender"}, + {Name: "gemini", SkillDir: ".gemini/skills", DetectPath: ".gemini"}, + {Name: "opencode", SkillDir: ".config/opencode/skills", DetectPath: ".config/opencode"}, +} + +func HomeDir() string { + if v := os.Getenv("COMPOSEKIT_HOME"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return "." + } + return home +} + +func ExpandHome(path string) string { + if strings.HasPrefix(path, "~/") { + return filepath.Join(HomeDir(), path[2:]) + } + return path +} + +func AgentSkillDir(t AgentTarget) string { + return filepath.Join(HomeDir(), t.SkillDir) +} + +func Detect() []AgentTarget { + var detected []AgentTarget + for _, t := range KnownAgentTargets { + p := ExpandHome(filepath.Join("~", t.DetectPath)) + if _, err := os.Stat(p); err == nil { + detected = append(detected, t) + } + } + return detected +} + +func ResolveTargets(agents []string, allAgents bool, customTargets []string) []string { + var dirs []string + seen := make(map[string]bool) + + if allAgents { + for _, t := range KnownAgentTargets { + d := AgentSkillDir(t) + if !seen[d] { + dirs = append(dirs, d) + seen[d] = true + } + } + } + + if len(agents) > 0 { + agentSet := make(map[string]bool) + for _, a := range agents { + agentSet[strings.ToLower(strings.TrimSpace(a))] = true + } + for _, t := range KnownAgentTargets { + if agentSet[t.Name] { + d := AgentSkillDir(t) + if !seen[d] { + dirs = append(dirs, d) + seen[d] = true + } + } + } + } + + if len(agents) == 0 && !allAgents { + detected := Detect() + for _, t := range detected { + d := AgentSkillDir(t) + if !seen[d] { + dirs = append(dirs, d) + seen[d] = true + } + } + } + + for _, t := range customTargets { + if !seen[t] { + dirs = append(dirs, t) + seen[t] = true + } + } + + return dirs +} + +func DefaultTargets() []string { + return []string{ + filepath.Join(HomeDir(), ".gemini/antigravity/skills"), + filepath.Join(HomeDir(), ".gemini/skills"), + } +} + +type DetectResult struct { + Name string `json:"name"` + Path string `json:"path"` + Exists bool `json:"exists"` +} + +func DetectAll() []DetectResult { + var results []DetectResult + for _, t := range KnownAgentTargets { + p := ExpandHome(filepath.Join("~", t.DetectPath)) + _, err := os.Stat(p) + results = append(results, DetectResult{ + Name: t.Name, + Path: AgentSkillDir(t), + Exists: err == nil, + }) + } + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + return results +} + +func AgentNameForPath(path string) string { + for _, t := range KnownAgentTargets { + expected := AgentSkillDir(t) + if expected == path { + return t.Name + } + } + return filepath.Base(path) +} + +func PrintDetect(results []DetectResult) { + fmt.Println("Detected agent targets:") + fmt.Println() + for _, r := range results { + if r.Exists { + fmt.Printf(" %-12s %s\n", r.Name, r.Path) + } + } + fmt.Println() + fmt.Println("Not detected:") + fmt.Println() + for _, r := range results { + if !r.Exists { + fmt.Printf(" %-12s %s\n", r.Name, r.Path) + } + } +} diff --git a/internal/ui/output.go b/internal/ui/output.go new file mode 100644 index 0000000..0b2c125 --- /dev/null +++ b/internal/ui/output.go @@ -0,0 +1,63 @@ +package ui + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +func HasFlag(flag string) bool { + for _, arg := range os.Args { + if arg == flag { + return true + } + } + return false +} + +func GetFlagValue(flag string) string { + for i, arg := range os.Args { + if arg == flag && i+1 < len(os.Args) { + return os.Args[i+1] + } + } + return "" +} + +func GetAgents() []string { + val := GetFlagValue("--agent") + if val == "" { + return nil + } + return splitCommas(val) +} + +func splitCommas(s string) []string { + if s == "" { + return nil + } + var result []string + for _, part := range strings.Split(s, ",") { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func PrintJSON(v interface{}) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return + } + fmt.Println(string(data)) +} + +func Must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3829cfb --- /dev/null +++ b/main.go @@ -0,0 +1,649 @@ +package main + +import ( + "embed" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Meet-Miyani/composekit/internal/catalog" + "github.com/Meet-Miyani/composekit/internal/config" + gh "github.com/Meet-Miyani/composekit/internal/github" + "github.com/Meet-Miyani/composekit/internal/install" + "github.com/Meet-Miyani/composekit/internal/manifest" + "github.com/Meet-Miyani/composekit/internal/targets" + "github.com/Meet-Miyani/composekit/internal/ui" +) + +//go:embed skills/** catalog/** +var embeddedFiles embed.FS + +var ( + version = "dev" + commit = "unknown" + date = "unknown" +) + +const defaultSkillName = "compose" +const catalogRoot = "catalog/skills.json" + +func main() { + if len(os.Args) < 2 { + help() + return + } + + switch os.Args[1] { + case "init": + ui.Must(cmdInit()) + case "update": + ui.Must(cmdUpdate()) + case "doctor": + ui.Must(cmdDoctor()) + case "remove": + ui.Must(cmdRemove()) + case "version": + fmt.Printf("composekit %s\ncommit: %s\nbuilt: %s\n", version, commit, date) + case "skills": + if len(os.Args) < 3 { + help() + return + } + ui.Must(cmdSkills(os.Args[2])) + case "targets": + if len(os.Args) < 3 { + help() + return + } + ui.Must(cmdTargets(os.Args[2])) + case "help", "--help", "-h": + help() + default: + fmt.Println("Unknown command:", os.Args[1]) + help() + os.Exit(1) + } +} + +func resolveTargets() []string { + if dir := ui.GetFlagValue("--target"); dir != "" { + abs, err := filepath.Abs(dir) + ui.Must(err) + return []string{abs} + } + + if ui.HasFlag("--all-agents") { + cfg := config.Load() + return targets.ResolveTargets(nil, true, cfg.Targets) + } + + if agents := ui.GetAgents(); len(agents) > 0 { + cfg := config.Load() + return targets.ResolveTargets(agents, false, cfg.Targets) + } + + cfg := config.Load() + result := targets.ResolveTargets(nil, false, cfg.Targets) + if len(result) > 0 { + return result + } + + return targets.DefaultTargets() +} + +func loadCatalog() (*catalog.Catalog, error) { + return catalog.Load(embeddedFiles, catalogRoot) +} + +func installFromEmbedded(skill *catalog.SkillEntry, dest string, version, commit, source string) error { + return install.InstallSkill(embeddedFiles, skill.Path, dest, skill.Name, skill.DisplayName, version, commit, source) +} + +func installFromDir(skillDir, dest, name, displayName, version, commit, source string) error { + return install.InstallSkillFromDir(skillDir, dest, name, displayName, version, commit, source) +} + +func cmdInit() error { + cat, err := loadCatalog() + if err != nil { + return fmt.Errorf("failed to load catalog: %w", err) + } + + skill := cat.FindByName(defaultSkillName) + if skill == nil { + return fmt.Errorf("skill '%s' not found in catalog", defaultSkillName) + } + + targetDirs := resolveTargets() + force := ui.HasFlag("--force") + dryRun := ui.HasFlag("--dry-run") + useJSON := ui.HasFlag("--json") + + type result struct { + Target string `json:"target"` + Status string `json:"status"` + } + var results []result + + for _, target := range targetDirs { + dest := filepath.Join(target, skill.Name) + if install.Exists(dest) { + if !manifest.IsManaged(dest) && !force { + errMsg := fmt.Sprintf("unmanaged install at %s; use --force to overwrite", dest) + if useJSON { + results = append(results, result{Target: dest, Status: "error: " + errMsg}) + } else { + fmt.Fprintf(os.Stderr, "Error: %s\n", errMsg) + } + continue + } + if !dryRun { + os.RemoveAll(dest) + } + } + if !dryRun { + if err := installFromEmbedded(skill, dest, version, commit, "embedded"); err != nil { + return err + } + } + if useJSON { + results = append(results, result{Target: dest, Status: "installed"}) + } else { + fmt.Printf("Skill '%s' installed to %s\n", skill.Name, dest) + } + } + + if dryRun && !useJSON { + fmt.Println("Dry run β€” no changes made") + for _, target := range targetDirs { + dest := filepath.Join(target, skill.Name) + fmt.Printf("Would install '%s' to %s\n", skill.Name, dest) + } + } + + if useJSON { + ui.PrintJSON(results) + } + return nil +} + +func cmdUpdate() error { + offline := ui.HasFlag("--offline") + force := ui.HasFlag("--force") + dryRun := ui.HasFlag("--dry-run") + useJSON := ui.HasFlag("--json") + + var bundleDir string + var source string + var releaseTag string + + if !offline { + fmt.Println("Fetching latest skill bundle from GitHub...") + release, err := gh.FetchLatestRelease() + if err != nil { + return fmt.Errorf("fetch release: %w", err) + } + releaseTag = release.TagName + source = "github-release:" + releaseTag + + tmpDir, err := os.MkdirTemp("", "composekit-update-*") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + bundleDir, err = gh.DownloadSkillsBundle(release, tmpDir) + if err != nil { + return fmt.Errorf("download skills bundle: %w", err) + } + fmt.Printf("Fetched latest skill bundle: %s\n", releaseTag) + } else { + source = "embedded" + } + + targetDirs := resolveTargets() + + type result struct { + Target string `json:"target"` + Status string `json:"status"` + } + var results []result + + for _, target := range targetDirs { + dest := filepath.Join(target, defaultSkillName) + if !install.Exists(dest) { + errMsg := fmt.Sprintf("not installed at %s; run init first", dest) + if useJSON { + results = append(results, result{Target: dest, Status: "error: " + errMsg}) + } else { + fmt.Fprintf(os.Stderr, "Error: %s\n", errMsg) + } + continue + } + manifestPath := filepath.Join(dest, manifest.ManifestFile) + if !install.Exists(manifestPath) && !force { + errMsg := fmt.Sprintf("manifest missing at %s; use --force to replace unmanaged install", manifestPath) + if useJSON { + results = append(results, result{Target: dest, Status: "error: " + errMsg}) + } else { + fmt.Fprintf(os.Stderr, "Error: %s\n", errMsg) + } + continue + } + if !dryRun { + os.RemoveAll(dest) + if offline { + cat, err := loadCatalog() + if err != nil { + return err + } + skill := cat.FindByName(defaultSkillName) + if skill == nil { + return fmt.Errorf("skill '%s' not found in catalog", defaultSkillName) + } + if err := installFromEmbedded(skill, dest, version, commit, source); err != nil { + return err + } + } else { + skillDir := filepath.Join(bundleDir, "skills", defaultSkillName) + if !install.Exists(skillDir) { + return fmt.Errorf("skill '%s' not found in downloaded bundle at %s", defaultSkillName, skillDir) + } + if err := installFromDir(skillDir, dest, defaultSkillName, "Compose Multiplatform", version, commit, source); err != nil { + return err + } + } + } + if useJSON { + results = append(results, result{Target: dest, Status: "updated"}) + } else { + fmt.Printf("Skill '%s' updated at %s\n", defaultSkillName, dest) + } + } + + if useJSON { + ui.PrintJSON(results) + } + return nil +} + +func cmdRemove() error { + targetDirs := resolveTargets() + force := ui.HasFlag("--force") + dryRun := ui.HasFlag("--dry-run") + useJSON := ui.HasFlag("--json") + + type result struct { + Target string `json:"target"` + Status string `json:"status"` + } + var results []result + + for _, target := range targetDirs { + dest := filepath.Join(target, defaultSkillName) + if !install.Exists(dest) { + if useJSON { + results = append(results, result{Target: dest, Status: "not_installed"}) + } else { + fmt.Println("Not installed:", dest) + } + continue + } + if !manifest.IsManaged(dest) && !force { + errMsg := fmt.Sprintf("unmanaged install at %s; use --force to remove", dest) + if useJSON { + results = append(results, result{Target: dest, Status: "error: " + errMsg}) + } else { + fmt.Fprintf(os.Stderr, "Error: %s\n", errMsg) + } + continue + } + if !dryRun { + if err := os.RemoveAll(dest); err != nil { + return err + } + } + if useJSON { + results = append(results, result{Target: dest, Status: "removed"}) + } else { + fmt.Println("Removed", dest) + } + } + + if useJSON { + ui.PrintJSON(results) + } + return nil +} + +func cmdDoctor() error { + useJSON := ui.HasFlag("--json") + + cat, err := loadCatalog() + if err != nil { + return fmt.Errorf("failed to load catalog: %w", err) + } + + skill := cat.FindByName(defaultSkillName) + + var skillRoot string + if skill != nil { + skillRoot = skill.Path + } + + refCount := 0 + if skillRoot != "" { + files, _ := install.ManagedFiles(embeddedFiles, skillRoot) + for _, f := range files { + if strings.HasSuffix(f, ".md") && strings.Contains(f, "references/") { + refCount++ + } + } + } + + targetDirs := resolveTargets() + + type targetStatus struct { + Path string `json:"path"` + Installed bool `json:"installed"` + Manifest bool `json:"manifest"` + Managed bool `json:"managed"` + } + + if useJSON { + type doctorResult struct { + CLI struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + } `json:"cli"` + Catalog struct { + Valid bool `json:"valid"` + Skills int `json:"skills"` + } `json:"catalog"` + Payload struct { + Skill string `json:"skill"` + Embedded bool `json:"embedded"` + Files struct { + SkillMD bool `json:"SKILL.md"` + AgentYaml bool `json:"agents/openai.yaml"` + References int `json:"references"` + } `json:"files"` + } `json:"payload"` + Targets []targetStatus `json:"targets"` + } + + var result doctorResult + result.CLI.Version = version + result.CLI.Commit = commit + result.CLI.Date = date + result.Catalog.Valid = skill != nil + if cat != nil { + result.Catalog.Skills = len(cat.Skills) + } + result.Payload.Skill = defaultSkillName + result.Payload.Embedded = true + result.Payload.Files.SkillMD = skillRoot != "" && install.ExistsEmbedded(embeddedFiles, skillRoot, "SKILL.md") + result.Payload.Files.AgentYaml = skillRoot != "" && install.ExistsEmbedded(embeddedFiles, skillRoot, "agents/openai.yaml") + result.Payload.Files.References = refCount + for _, target := range targetDirs { + dest := filepath.Join(target, defaultSkillName) + result.Targets = append(result.Targets, targetStatus{ + Path: target, + Installed: install.Exists(dest), + Manifest: install.Exists(filepath.Join(dest, manifest.ManifestFile)), + Managed: manifest.IsManaged(dest), + }) + } + ui.PrintJSON(result) + return nil + } + + fmt.Println("ComposeKit doctor") + fmt.Println() + fmt.Println("CLI:") + fmt.Println(" version:", version) + fmt.Println(" commit:", commit) + fmt.Println(" date:", date) + fmt.Println() + fmt.Println("Catalog:") + fmt.Println(" valid:", skill != nil) + if cat != nil { + fmt.Println(" skills:", len(cat.Skills)) + } + fmt.Println() + fmt.Println("Payload:") + fmt.Println(" skill:", defaultSkillName) + fmt.Println(" embedded: yes") + if skillRoot != "" { + fmt.Println(" SKILL.md:", yesno(install.ExistsEmbedded(embeddedFiles, skillRoot, "SKILL.md"))) + fmt.Println(" agents/openai.yaml:", yesno(install.ExistsEmbedded(embeddedFiles, skillRoot, "agents/openai.yaml"))) + } + fmt.Println(" references:", refCount, "files") + fmt.Println() + fmt.Println("Targets:") + for _, target := range targetDirs { + dest := filepath.Join(target, defaultSkillName) + fmt.Printf(" %s\n", target) + fmt.Printf(" installed: %s\n", yesno(install.Exists(dest))) + fmt.Printf(" manifest: %s\n", yesno(install.Exists(filepath.Join(dest, manifest.ManifestFile)))) + fmt.Printf(" managed: %s\n", yesno(manifest.IsManaged(dest))) + } + return nil +} + +func cmdSkills(subcommand string) error { + cat, err := loadCatalog() + if err != nil { + return fmt.Errorf("failed to load catalog: %w", err) + } + + switch subcommand { + case "list": + if ui.HasFlag("--long") || ui.HasFlag("-l") { + targetDirs := resolveTargets() + statuses := make(map[string][]catalog.InstallStatus) + for _, s := range cat.Skills { + var sts []catalog.InstallStatus + for _, target := range targetDirs { + dest := filepath.Join(target, s.Name) + sts = append(sts, catalog.InstallStatus{ + Name: target, + Installed: install.Exists(dest), + Path: dest, + }) + } + statuses[s.Name] = sts + } + cat.PrintSkillsLong(statuses) + } else { + cat.PrintListShort() + } + + case "find": + if len(os.Args) < 4 { + return fmt.Errorf("usage: composekit skills find ") + } + query := os.Args[3] + results := cat.FindByQuery(query) + cat.PrintFindResults(query, results) + + case "add": + if len(os.Args) < 4 { + return fmt.Errorf("usage: composekit skills add ") + } + skillToAdd := os.Args[3] + s := cat.FindByName(skillToAdd) + if s == nil { + return fmt.Errorf("skill '%s' not found in catalog", skillToAdd) + } + + force := ui.HasFlag("--force") + targetDirs := resolveTargets() + for _, target := range targetDirs { + dest := filepath.Join(target, s.Name) + if install.Exists(dest) { + if !manifest.IsManaged(dest) && !force { + return fmt.Errorf("unmanaged install at %s; use --force to overwrite", dest) + } + os.RemoveAll(dest) + } + if err := installFromEmbedded(s, dest, version, commit, "embedded"); err != nil { + return err + } + fmt.Printf("Skill '%s' installed to %s\n", s.Name, dest) + } + + case "remove": + if len(os.Args) < 4 { + return fmt.Errorf("usage: composekit skills remove ") + } + skillToRemove := os.Args[3] + force := ui.HasFlag("--force") + targetDirs := resolveTargets() + for _, target := range targetDirs { + dest := filepath.Join(target, skillToRemove) + if !install.Exists(dest) { + fmt.Println("Not installed:", dest) + continue + } + if !manifest.IsManaged(dest) && !force { + return fmt.Errorf("unmanaged install at %s; use --force to remove", dest) + } + if err := os.RemoveAll(dest); err != nil { + return err + } + fmt.Println("Removed", dest) + } + + case "installed": + targetDirs := resolveTargets() + fmt.Println("Installed skills:") + for _, target := range targetDirs { + for _, s := range cat.Skills { + dest := filepath.Join(target, s.Name) + if install.Exists(dest) { + fmt.Printf(" %s at %s\n", s.Name, dest) + } + } + } + + default: + return fmt.Errorf("unknown skills subcommand: %s", subcommand) + } + return nil +} + +func cmdTargets(subcommand string) error { + switch subcommand { + case "detect": + results := targets.DetectAll() + targets.PrintDetect(results) + + case "list": + cfg := config.Load() + if len(cfg.Targets) == 0 { + fmt.Println("No targets configured") + return nil + } + fmt.Println("Configured targets:") + for _, t := range cfg.Targets { + dest := filepath.Join(t, defaultSkillName) + status := "not installed" + if install.Exists(dest) { + status = "installed" + } + fmt.Printf(" %s (%s)\n", t, status) + } + + case "add": + if len(os.Args) < 4 { + return fmt.Errorf("usage: composekit targets add ") + } + dir := os.Args[3] + abs, err := filepath.Abs(dir) + if err != nil { + return err + } + cfg := config.Load() + for _, t := range cfg.Targets { + if t == abs { + fmt.Println("Target already exists:", abs) + return nil + } + } + cfg.Targets = append(cfg.Targets, abs) + if err := config.Save(cfg); err != nil { + return err + } + fmt.Println("Added target:", abs) + + case "remove": + if len(os.Args) < 4 { + return fmt.Errorf("usage: composekit targets remove ") + } + dir := os.Args[3] + abs, err := filepath.Abs(dir) + if err != nil { + return err + } + cfg := config.Load() + var updated []string + found := false + for _, t := range cfg.Targets { + if t == abs { + found = true + continue + } + updated = append(updated, t) + } + if !found { + return fmt.Errorf("target not found: %s", abs) + } + cfg.Targets = updated + if err := config.Save(cfg); err != nil { + return err + } + fmt.Println("Removed target:", abs) + + default: + return fmt.Errorf("unknown targets subcommand: %s", subcommand) + } + return nil +} + +func yesno(b bool) string { + if b { + return "yes" + } + return "no" +} + +func help() { + fmt.Println(strings.TrimSpace(`ComposeKit + +A CLI tool to install and manage AI coding skills for Jetpack Compose, +Compose Multiplatform, and Kotlin Multiplatform workflows. + +Usage: + composekit init [--target ] [--agent ] [--all-agents] [--force] [--dry-run] [--json] + composekit update [--target ] [--agent ] [--all-agents] [--force] [--dry-run] [--json] [--offline] + composekit doctor [--target ] [--json] + composekit remove [--target ] [--agent ] [--all-agents] [--force] [--dry-run] [--json] + composekit version + +Skills: + composekit skills list [--long] + composekit skills find + composekit skills add [--target ] [--agent ] [--all-agents] [--force] + composekit skills remove [--target ] [--agent ] [--all-agents] [--force] + composekit skills installed + +Targets: + composekit targets detect + composekit targets list + composekit targets add + composekit targets remove +`)) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..4267716 --- /dev/null +++ b/main_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Meet-Miyani/composekit/internal/catalog" + "github.com/Meet-Miyani/composekit/internal/install" + "github.com/Meet-Miyani/composekit/internal/manifest" +) + +func TestManagedFiles(t *testing.T) { + cat, err := loadCatalog() + if err != nil { + t.Fatal(err) + } + skill := cat.FindByName(defaultSkillName) + if skill == nil { + t.Fatalf("skill %q not found", defaultSkillName) + } + + files, err := install.ManagedFiles(embeddedFiles, skill.Path) + if err != nil { + t.Fatal(err) + } + + if len(files) == 0 { + t.Fatal("expected at least one managed file") + } + + found := false + for _, f := range files { + if f == "SKILL.md" { + found = true + break + } + } + if !found { + t.Error("expected SKILL.md in managed files") + } + + found = false + for _, f := range files { + if f == "agents/openai.yaml" { + found = true + break + } + } + if !found { + t.Error("expected agents/openai.yaml in managed files") + } +} + +func TestExistsEmbedded(t *testing.T) { + cat, err := loadCatalog() + if err != nil { + t.Fatal(err) + } + skill := cat.FindByName(defaultSkillName) + if skill == nil { + t.Fatalf("skill %q not found", defaultSkillName) + } + + if !install.ExistsEmbedded(embeddedFiles, skill.Path, "SKILL.md") { + t.Error("expected SKILL.md to exist in embedded payload") + } + if !install.ExistsEmbedded(embeddedFiles, skill.Path, "agents/openai.yaml") { + t.Error("expected agents/openai.yaml to exist in embedded payload") + } + if install.ExistsEmbedded(embeddedFiles, skill.Path, "nonexistent.md") { + t.Error("expected nonexistent.md to not exist") + } +} + +func TestInitAndRemove(t *testing.T) { + tmpDir := t.TempDir() + targetDir := filepath.Join(tmpDir, "skills") + + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + t.Setenv("COMPOSEKIT_CONFIG_HOME", filepath.Join(tmpDir, "config")) + os.Args = []string{"composekit", "init", "--target", targetDir} + if err := cmdInit(); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(targetDir, defaultSkillName) + if !install.Exists(dest) { + t.Fatal("expected skill to be installed") + } + + if !install.Exists(filepath.Join(dest, "SKILL.md")) { + t.Error("expected SKILL.md to exist") + } + + if !install.Exists(filepath.Join(dest, manifest.ManifestFile)) { + t.Error("expected manifest to exist") + } + + // Test update (offline mode) + os.Args = []string{"composekit", "update", "--target", targetDir, "--force", "--offline"} + if err := cmdUpdate(); err != nil { + t.Fatal(err) + } + + if !install.Exists(dest) { + t.Fatal("expected skill to still exist after update") + } + + // Test remove + os.Args = []string{"composekit", "remove", "--target", targetDir} + if err := cmdRemove(); err != nil { + t.Fatal(err) + } + + if install.Exists(dest) { + t.Error("expected skill to be removed") + } +} + +func TestCatalog(t *testing.T) { + cat, err := catalog.Load(embeddedFiles, catalogRoot) + if err != nil { + t.Fatal(err) + } + if len(cat.Skills) == 0 { + t.Fatal("expected at least one skill in catalog") + } +} + +func TestCatalogFind(t *testing.T) { + cat, err := catalog.Load(embeddedFiles, catalogRoot) + if err != nil { + t.Fatal(err) + } + s := cat.FindByName("compose") + if s == nil { + t.Fatal("expected to find compose skill") + } + results := cat.FindByQuery("navigation") + if len(results) == 0 { + t.Error("expected to find skills matching 'navigation'") + } +} diff --git a/scripts/package-skills.sh b/scripts/package-skills.sh new file mode 100755 index 0000000..46d558d --- /dev/null +++ b/scripts/package-skills.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-snapshot}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +mkdir -p dist + +tar -czf "dist/composekit-skills_${VERSION}.tar.gz" \ + skills/compose/ \ + catalog/skills.json + +echo "Created dist/composekit-skills_${VERSION}.tar.gz" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..90aca21 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +rm -rf ./tmp-smoke +mkdir -p ./tmp-smoke/bin + +go build -o ./tmp-smoke/bin/composekit . + +export COMPOSEKIT_HOME="$PWD/tmp-smoke/home" +export COMPOSEKIT_CONFIG_HOME="$PWD/tmp-smoke/config" + +mkdir -p "$COMPOSEKIT_HOME/.codex" +mkdir -p "$COMPOSEKIT_HOME/.cursor" +mkdir -p "$COMPOSEKIT_HOME/.claude" +mkdir -p "$COMPOSEKIT_CONFIG_HOME" + +./tmp-smoke/bin/composekit version +./tmp-smoke/bin/composekit targets detect + +./tmp-smoke/bin/composekit skills list +./tmp-smoke/bin/composekit skills list --long 2>&1 | head -20 +./tmp-smoke/bin/composekit skills find kmp + +./tmp-smoke/bin/composekit init + +test -f "$COMPOSEKIT_HOME/.codex/skills/compose/SKILL.md" +test -f "$COMPOSEKIT_HOME/.cursor/skills/compose/SKILL.md" +test -f "$COMPOSEKIT_HOME/.claude/skills/compose/SKILL.md" + +./tmp-smoke/bin/composekit doctor + +./tmp-smoke/bin/composekit update --offline + +test -f "$COMPOSEKIT_HOME/.codex/skills/compose/.composekit-manifest.json" +test -f "$COMPOSEKIT_HOME/.cursor/skills/compose/.composekit-manifest.json" +test -f "$COMPOSEKIT_HOME/.claude/skills/compose/.composekit-manifest.json" + +./tmp-smoke/bin/composekit remove + +test ! -d "$COMPOSEKIT_HOME/.codex/skills/compose" +test ! -d "$COMPOSEKIT_HOME/.cursor/skills/compose" +test ! -d "$COMPOSEKIT_HOME/.claude/skills/compose" + +# Test --all-agents flag +mkdir -p "$COMPOSEKIT_HOME/.gemini" +./tmp-smoke/bin/composekit init --all-agents + +test -f "$COMPOSEKIT_HOME/.codex/skills/compose/SKILL.md" +test -f "$COMPOSEKIT_HOME/.cursor/skills/compose/SKILL.md" +test -f "$COMPOSEKIT_HOME/.claude/skills/compose/SKILL.md" +test -f "$COMPOSEKIT_HOME/.gemini/skills/compose/SKILL.md" + +./tmp-smoke/bin/composekit remove --all-agents + +test ! -d "$COMPOSEKIT_HOME/.codex/skills/compose" +test ! -d "$COMPOSEKIT_HOME/.cursor/skills/compose" +test ! -d "$COMPOSEKIT_HOME/.claude/skills/compose" +test ! -d "$COMPOSEKIT_HOME/.gemini/skills/compose" + +echo "smoke test passed" diff --git a/scripts/validate.sh b/scripts/validate-skill.sh similarity index 99% rename from scripts/validate.sh rename to scripts/validate-skill.sh index 4576158..ef2022b 100755 --- a/scripts/validate.sh +++ b/scripts/validate-skill.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT/skills/compose" + # ╔══════════════════════════════════════════════════════════════════╗ # β•‘ Agent Skill Scanner v4 β•‘ # β•‘ Validates skill packages against the agentskills.io spec β•‘ @@ -21,8 +24,8 @@ set -euo pipefail # β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• # # Usage: -# ./scripts/validate.sh # full scan -# ./scripts/validate.sh --help # show usage +# ./scripts/validate-skill.sh # full scan +# ./scripts/validate-skill.sh --help # show usage # # Environment: # CI=true β€” emits GitHub Actions annotations (auto-detected) @@ -39,6 +42,7 @@ CURRENT_SECTION="" CI="${CI:-false}" NO_COLOR="${NO_COLOR:-0}" OUTPUT_MODE="terminal" +QUICK_MODE=false SKILL_FILE="SKILL.md" SKILL_DIR="$(basename "$(pwd)")" @@ -2061,11 +2065,11 @@ show_help() { echo " Computes a quality score (0-100) across 5 dimensions." echo "" echo " $(_bold "Usage:")" - echo " ./scripts/validate.sh Run full scan (terminal)" - echo " ./scripts/validate.sh --json Output as JSON" - echo " ./scripts/validate.sh --md Output as Markdown report" - echo " ./scripts/validate.sh --score-only Print score and grade only" - echo " ./scripts/validate.sh --help Show this help" + echo " ./scripts/validate-skill.sh Run full scan (terminal)" + echo " ./scripts/validate-skill.sh --json Output as JSON" + echo " ./scripts/validate-skill.sh --md Output as Markdown report" + echo " ./scripts/validate-skill.sh --score-only Print score and grade only" + echo " ./scripts/validate-skill.sh --help Show this help" echo "" echo " $(_bold "Output Modes:")" echo " $(_cyan "--json") Machine-readable JSON (pipe to jq, feed to web tools)" @@ -2140,6 +2144,7 @@ main() { --json) OUTPUT_MODE="json"; NO_COLOR=1 ;; --md|--markdown) OUTPUT_MODE="markdown"; NO_COLOR=1 ;; --score-only|--score) OUTPUT_MODE="score-only" ;; + --quick) QUICK_MODE=true ;; *) echo "Unknown flag: $1"; echo "Run with --help for usage."; exit 2 ;; esac shift @@ -2174,16 +2179,18 @@ main() { check_frontmatter check_body check_links - check_references + if [ "$QUICK_MODE" = false ]; then + check_references + check_reference_depth + check_token_budget + check_content_quality + check_heading_hierarchy + fi check_markdown - check_reference_depth check_scripts check_repo_hygiene - check_token_budget - check_content_quality check_agents_metadata check_security - check_heading_hierarchy compute_quality_score if [ "$OUTPUT_MODE" != "terminal" ]; then diff --git a/SKILL.md b/skills/compose/SKILL.md similarity index 97% rename from SKILL.md rename to skills/compose/SKILL.md index 2116abf..3ab00b2 100644 --- a/SKILL.md +++ b/skills/compose/SKILL.md @@ -1,11 +1,11 @@ --- -name: compose-skill -license: MIT +name: compose description: > - Jetpack Compose and Compose Multiplatform (KMP/CMP) architecture skill. - Only use when the user explicitly mentions "compose-skill", "@compose-skill", - or "use compose skill" in their message. Do NOT auto-activate based on - keyword matching β€” this skill should only be triggered by direct user request. + production-grade jetpack compose and compose multiplatform guidance for kotlin, kmp, cmp, + architecture, state management, navigation, networking, persistence, performance, + accessibility, testing, previews, and cross-platform ui delivery. use when the user + explicitly asks for compose guidance, compose multiplatform help, kotlin multiplatform ui + guidance, or requests the compose skill. --- # Jetpack Compose & Compose Multiplatform diff --git a/skills/compose/agents/openai.yaml b/skills/compose/agents/openai.yaml new file mode 100644 index 0000000..f1df86d --- /dev/null +++ b/skills/compose/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Compose Multiplatform" + short_description: "Production-grade Jetpack Compose and Compose Multiplatform guidance for Kotlin, KMP, CMP, architecture, state management, navigation, networking, persistence, performance, accessibility, testing, previews, and cross-platform UI delivery." + default_prompt: "Use $compose to build, refactor, or review Compose/CMP features, adapting to the project's architecture (MVI recommended for new work), and verifying dependencies before recommending them." diff --git a/assets/compose-multiplatform-icon.svg b/skills/compose/assets/compose-multiplatform-icon.svg similarity index 100% rename from assets/compose-multiplatform-icon.svg rename to skills/compose/assets/compose-multiplatform-icon.svg diff --git a/references/accessibility.md b/skills/compose/references/accessibility.md similarity index 100% rename from references/accessibility.md rename to skills/compose/references/accessibility.md diff --git a/references/animations-advanced.md b/skills/compose/references/animations-advanced.md similarity index 100% rename from references/animations-advanced.md rename to skills/compose/references/animations-advanced.md diff --git a/references/animations.md b/skills/compose/references/animations.md similarity index 100% rename from references/animations.md rename to skills/compose/references/animations.md diff --git a/references/anti-patterns.md b/skills/compose/references/anti-patterns.md similarity index 100% rename from references/anti-patterns.md rename to skills/compose/references/anti-patterns.md diff --git a/references/architecture.md b/skills/compose/references/architecture.md similarity index 100% rename from references/architecture.md rename to skills/compose/references/architecture.md diff --git a/references/ci-cd-distribution.md b/skills/compose/references/ci-cd-distribution.md similarity index 100% rename from references/ci-cd-distribution.md rename to skills/compose/references/ci-cd-distribution.md diff --git a/references/clean-code.md b/skills/compose/references/clean-code.md similarity index 100% rename from references/clean-code.md rename to skills/compose/references/clean-code.md diff --git a/references/compose-essentials.md b/skills/compose/references/compose-essentials.md similarity index 100% rename from references/compose-essentials.md rename to skills/compose/references/compose-essentials.md diff --git a/references/coroutines-flow-advanced.md b/skills/compose/references/coroutines-flow-advanced.md similarity index 100% rename from references/coroutines-flow-advanced.md rename to skills/compose/references/coroutines-flow-advanced.md diff --git a/references/coroutines-flow.md b/skills/compose/references/coroutines-flow.md similarity index 100% rename from references/coroutines-flow.md rename to skills/compose/references/coroutines-flow.md diff --git a/references/cross-platform.md b/skills/compose/references/cross-platform.md similarity index 100% rename from references/cross-platform.md rename to skills/compose/references/cross-platform.md diff --git a/references/datastore.md b/skills/compose/references/datastore.md similarity index 100% rename from references/datastore.md rename to skills/compose/references/datastore.md diff --git a/references/dependency-injection.md b/skills/compose/references/dependency-injection.md similarity index 100% rename from references/dependency-injection.md rename to skills/compose/references/dependency-injection.md diff --git a/references/gradle-build.md b/skills/compose/references/gradle-build.md similarity index 100% rename from references/gradle-build.md rename to skills/compose/references/gradle-build.md diff --git a/references/hilt.md b/skills/compose/references/hilt.md similarity index 100% rename from references/hilt.md rename to skills/compose/references/hilt.md diff --git a/references/image-loading.md b/skills/compose/references/image-loading.md similarity index 100% rename from references/image-loading.md rename to skills/compose/references/image-loading.md diff --git a/references/ios-swift-interop.md b/skills/compose/references/ios-swift-interop.md similarity index 100% rename from references/ios-swift-interop.md rename to skills/compose/references/ios-swift-interop.md diff --git a/references/koin.md b/skills/compose/references/koin.md similarity index 100% rename from references/koin.md rename to skills/compose/references/koin.md diff --git a/references/lists-grids.md b/skills/compose/references/lists-grids.md similarity index 100% rename from references/lists-grids.md rename to skills/compose/references/lists-grids.md diff --git a/references/material-design.md b/skills/compose/references/material-design.md similarity index 100% rename from references/material-design.md rename to skills/compose/references/material-design.md diff --git a/references/mvi.md b/skills/compose/references/mvi.md similarity index 100% rename from references/mvi.md rename to skills/compose/references/mvi.md diff --git a/references/mvvm.md b/skills/compose/references/mvvm.md similarity index 100% rename from references/mvvm.md rename to skills/compose/references/mvvm.md diff --git a/references/navigation-2-di.md b/skills/compose/references/navigation-2-di.md similarity index 100% rename from references/navigation-2-di.md rename to skills/compose/references/navigation-2-di.md diff --git a/references/navigation-2.md b/skills/compose/references/navigation-2.md similarity index 100% rename from references/navigation-2.md rename to skills/compose/references/navigation-2.md diff --git a/references/navigation-3-di.md b/skills/compose/references/navigation-3-di.md similarity index 100% rename from references/navigation-3-di.md rename to skills/compose/references/navigation-3-di.md diff --git a/references/navigation-3.md b/skills/compose/references/navigation-3.md similarity index 100% rename from references/navigation-3.md rename to skills/compose/references/navigation-3.md diff --git a/references/navigation-migration.md b/skills/compose/references/navigation-migration.md similarity index 100% rename from references/navigation-migration.md rename to skills/compose/references/navigation-migration.md diff --git a/references/navigation.md b/skills/compose/references/navigation.md similarity index 100% rename from references/navigation.md rename to skills/compose/references/navigation.md diff --git a/references/networking-ktor-architecture.md b/skills/compose/references/networking-ktor-architecture.md similarity index 100% rename from references/networking-ktor-architecture.md rename to skills/compose/references/networking-ktor-architecture.md diff --git a/references/networking-ktor-auth.md b/skills/compose/references/networking-ktor-auth.md similarity index 100% rename from references/networking-ktor-auth.md rename to skills/compose/references/networking-ktor-auth.md diff --git a/references/networking-ktor-testing.md b/skills/compose/references/networking-ktor-testing.md similarity index 100% rename from references/networking-ktor-testing.md rename to skills/compose/references/networking-ktor-testing.md diff --git a/references/networking-ktor.md b/skills/compose/references/networking-ktor.md similarity index 100% rename from references/networking-ktor.md rename to skills/compose/references/networking-ktor.md diff --git a/references/paging-mvi-testing.md b/skills/compose/references/paging-mvi-testing.md similarity index 100% rename from references/paging-mvi-testing.md rename to skills/compose/references/paging-mvi-testing.md diff --git a/references/paging-offline.md b/skills/compose/references/paging-offline.md similarity index 100% rename from references/paging-offline.md rename to skills/compose/references/paging-offline.md diff --git a/references/paging.md b/skills/compose/references/paging.md similarity index 100% rename from references/paging.md rename to skills/compose/references/paging.md diff --git a/references/performance.md b/skills/compose/references/performance.md similarity index 100% rename from references/performance.md rename to skills/compose/references/performance.md diff --git a/references/resources.md b/skills/compose/references/resources.md similarity index 100% rename from references/resources.md rename to skills/compose/references/resources.md diff --git a/references/room-database.md b/skills/compose/references/room-database.md similarity index 100% rename from references/room-database.md rename to skills/compose/references/room-database.md diff --git a/references/testing.md b/skills/compose/references/testing.md similarity index 100% rename from references/testing.md rename to skills/compose/references/testing.md diff --git a/references/ui-ux.md b/skills/compose/references/ui-ux.md similarity index 100% rename from references/ui-ux.md rename to skills/compose/references/ui-ux.md