diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..2ad983f --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "flutter", + "owner": { + "name": "The Flutter Authors" + }, + "description": "Agent skills for Flutter app development, authored by the Flutter and Dart teams.", + "plugins": [ + { + "name": "flutter-skills", + "source": "./plugins/flutter-skills", + "description": "Task-based skills that teach coding agents happy-path Flutter workflows: widget and integration tests, widget previews, responsive layouts, declarative routing, localization, HTTP networking, JSON serialization, and layered architecture best practices.", + "category": "development" + } + ] +} diff --git a/.github/workflows/marketplace_workflow.yaml b/.github/workflows/marketplace_workflow.yaml new file mode 100644 index 0000000..0b10de2 --- /dev/null +++ b/.github/workflows/marketplace_workflow.yaml @@ -0,0 +1,75 @@ +name: marketplace +permissions: read-all + +on: + pull_request: + paths: + - '.github/workflows/marketplace_workflow.yaml' + - 'tool/marketplace_check/**' + - '.claude-plugin/**' + - 'plugins/**' + push: + branches: [ main ] + paths: + - '.github/workflows/marketplace_workflow.yaml' + - 'tool/marketplace_check/**' + - '.claude-plugin/**' + - 'plugins/**' + schedule: + - cron: '0 0 * * 0' # weekly + +defaults: + run: + working-directory: tool/marketplace_check + +jobs: + analyze_and_test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - run: dart pub get + + - run: dart analyze --fatal-infos + + - run: dart test + + formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - run: dart pub get + + - run: dart format --output=none --set-exit-if-changed . + + validate: + runs-on: ubuntu-latest + # Override the workflow-level working directory so the validator runs + # against the repository root, not tool/marketplace_check. + defaults: + run: + working-directory: . + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm install -g @anthropic-ai/claude-code + + # The canonical schema validator (unknown fields, duplicate names, path + # traversal, version mismatches). Runs offline; no API key needed. + - run: claude plugin validate . + + - run: claude plugin validate ./plugins/flutter-skills diff --git a/README.md b/README.md index 15a104d..7807f7f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,16 @@ folder that most agents use. npx skills add flutter/skills --skill '*' --agent universal ``` +### Install with Claude Code + +If you use [Claude Code](https://www.claude.com/product/claude-code), install the +skills as a plugin from the marketplace bundled in this repo: + +```bash +/plugin marketplace add flutter/skills +/plugin install flutter-skills@flutter +``` + ## Updating Skills To update, run the following command: diff --git a/plugins/flutter-skills/.claude-plugin/plugin.json b/plugins/flutter-skills/.claude-plugin/plugin.json new file mode 100644 index 0000000..14c0fe3 --- /dev/null +++ b/plugins/flutter-skills/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "flutter-skills", + "description": "Task-based skills that teach coding agents happy-path Flutter workflows: widget and integration tests, widget previews, responsive layouts, declarative routing, localization, HTTP networking, JSON serialization, and layered architecture best practices.", + "author": { + "name": "The Flutter Authors" + }, + "homepage": "https://github.com/flutter/skills", + "repository": "https://github.com/flutter/skills", + "license": "BSD-3-Clause", + "keywords": ["flutter", "dart", "mobile", "testing", "ui", "skills"] +} diff --git a/plugins/flutter-skills/skills b/plugins/flutter-skills/skills new file mode 120000 index 0000000..5dcab58 --- /dev/null +++ b/plugins/flutter-skills/skills @@ -0,0 +1 @@ +../../skills \ No newline at end of file diff --git a/tool/marketplace_check/analysis_options.yaml b/tool/marketplace_check/analysis_options.yaml new file mode 100644 index 0000000..d04adaf --- /dev/null +++ b/tool/marketplace_check/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/tool/marketplace_check/pubspec.yaml b/tool/marketplace_check/pubspec.yaml new file mode 100644 index 0000000..edd4932 --- /dev/null +++ b/tool/marketplace_check/pubspec.yaml @@ -0,0 +1,13 @@ +name: marketplace_check +description: >- + Tests that verify the Claude plugin marketplace wiring (marketplace.json, + the flutter-skills plugin manifest, and the skills symlink) stays correct. +publish_to: 'none' + +environment: + sdk: ^3.10.8 + +dev_dependencies: + lints: ^6.0.0 + path: ^1.9.1 + test: ^1.25.6 diff --git a/tool/marketplace_check/test/marketplace_plugin_test.dart b/tool/marketplace_check/test/marketplace_plugin_test.dart new file mode 100644 index 0000000..2cc1301 --- /dev/null +++ b/tool/marketplace_check/test/marketplace_plugin_test.dart @@ -0,0 +1,144 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// Walks up from [start] until it finds the directory that contains +/// `.claude-plugin/marketplace.json`, which marks the repository root. +/// +/// This lets the test run both from the repository root and from the +/// `tool/marketplace_check` working directory used by CI. +Directory _findRepoRoot(Directory start) { + var dir = start; + while (true) { + final marker = File(p.join(dir.path, '.claude-plugin', 'marketplace.json')); + if (marker.existsSync()) { + return dir; + } + final parent = dir.parent; + if (p.equals(parent.path, dir.path)) { + throw StateError( + 'Could not find .claude-plugin/marketplace.json walking up from ' + '${start.path}', + ); + } + dir = parent; + } +} + +Map _readJson(File file) { + expect(file.existsSync(), isTrue, reason: '${file.path} should exist'); + return jsonDecode(file.readAsStringSync()) as Map; +} + +void main() { + final repoRoot = _findRepoRoot(Directory.current); + final marketplaceFile = File( + p.join(repoRoot.path, '.claude-plugin', 'marketplace.json'), + ); + final pluginLink = p.join( + repoRoot.path, + 'plugins', + 'flutter-skills', + 'skills', + ); + + group('Claude plugin marketplace', () { + test('marketplace.json declares the flutter-skills plugin', () { + final marketplace = _readJson(marketplaceFile); + + expect(marketplace['name'], 'flutter'); + expect(marketplace['owner'], isA>()); + + final plugins = marketplace['plugins'] as List; + expect(plugins, hasLength(1)); + + final plugin = plugins.single as Map; + expect(plugin['name'], 'flutter-skills'); + + // Guards against regressing to `source: "./"`, which would copy the + // entire repo (tool/, resources/, .agents/) into every user's cache. + expect(plugin['source'], './plugins/flutter-skills'); + }); + + test('plugin source resolves to a directory with a plugin manifest', () { + final marketplace = _readJson(marketplaceFile); + final plugin = + (marketplace['plugins'] as List).single + as Map; + final source = plugin['source'] as String; + + expect(source, startsWith('./')); + expect(source, isNot(contains('..'))); + + final pluginDir = Directory(p.join(repoRoot.path, source)); + expect( + pluginDir.existsSync(), + isTrue, + reason: 'plugin source "$source" should exist', + ); + + final manifest = _readJson( + File(p.join(pluginDir.path, '.claude-plugin', 'plugin.json')), + ); + expect(manifest['name'], plugin['name']); + }); + + test('skills symlink resolves to the top-level skills/ directory', () { + expect( + FileSystemEntity.isLinkSync(pluginLink), + isTrue, + reason: + '"$pluginLink" must be a symlink. On Windows, ensure git checks ' + 'out symlinks (core.symlinks=true / Developer Mode).', + ); + + final resolvedLink = Link(pluginLink).resolveSymbolicLinksSync(); + final canonicalSkills = Directory( + p.join(repoRoot.path, 'skills'), + ).resolveSymbolicLinksSync(); + + expect( + p.equals(resolvedLink, canonicalSkills), + isTrue, + reason: + 'symlink should resolve to "$canonicalSkills", ' + 'got "$resolvedLink"', + ); + }); + + test('every top-level skill is reachable through the plugin symlink', () { + final canonicalSkills = Directory(p.join(repoRoot.path, 'skills')); + final skillNames = canonicalSkills + .listSync() + .whereType() + .map((entity) => p.basename(entity.path)) + .where( + (name) => File( + p.join(canonicalSkills.path, name, 'SKILL.md'), + ).existsSync(), + ) + .toList(); + + expect( + skillNames, + isNotEmpty, + reason: 'expected at least one skill in skills/', + ); + + for (final name in skillNames) { + final viaLink = File(p.join(pluginLink, name, 'SKILL.md')); + expect( + viaLink.existsSync(), + isTrue, + reason: 'skill "$name" should be reachable via the plugin symlink', + ); + } + }); + }); +}