Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
75 changes: 75 additions & 0 deletions .github/workflows/marketplace_workflow.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ folder that most agents use.
npx skills add flutter/skills --skill '*' --agent universal
```

### Install with Claude Code
Copy link
Copy Markdown
Contributor Author

@reidbaker reidbaker May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to test before merging use /plugin marketplace add flutter/skills@add-claude-plugin-marketplace

No code change required.


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:
Expand Down
11 changes: 11 additions & 0 deletions plugins/flutter-skills/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"]
}
1 change: 1 addition & 0 deletions plugins/flutter-skills/skills
7 changes: 7 additions & 0 deletions tool/marketplace_check/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
include: package:lints/recommended.yaml

analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
13 changes: 13 additions & 0 deletions tool/marketplace_check/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
144 changes: 144 additions & 0 deletions tool/marketplace_check/test/marketplace_plugin_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> _readJson(File file) {
expect(file.existsSync(), isTrue, reason: '${file.path} should exist');
return jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
}

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<Map<String, dynamic>>());

final plugins = marketplace['plugins'] as List<dynamic>;
expect(plugins, hasLength(1));

final plugin = plugins.single as Map<String, dynamic>;
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<dynamic>).single
as Map<String, dynamic>;
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<Directory>()
.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',
);
}
});
});
}
Loading