agentleFS
theyoungastronautstheyoungastronauts/polaris/skills/flutter-patterns/SKILL.md

flutter-patterns

Skill4 starsChanged 3 months agoMITView on GitHub

Flutter app conventions: Freezed models, Riverpod providers, a Dio service layer, typed Failures, pagination, and routing. Apply when writing or reviewing Flutter/Dart code.

6 other projects use a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

15 rules. 0 are common, 5 are distinctive, and 10 are very specific to this project.

  • 0

    Common

    Most of the 5 edited versions we read keep these.

  • 5

    Distinctive

    This author's own choices. Worth a look.

  • 10

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use Freezed with json_serializable.

    line 46
  2. Distinctive:

    Keep models flat — no nested domain/data split

    line 86
  3. Very specific:

    Use @riverpod codegen everywhere.

    line 157
  4. Very specific:

    After mutation, ref.invalidate() related list/detail providers

    line 268
  5. Very specific:

    Use AsyncValue.when() to handle loading/error/data states

    line 325
  6. Very specific:

    Prefer const constructors — all fields final

    line 360
  7. Very specific:

    Use ConsumerWidget only when the widget needs ref

    line 383
  8. Distinctive:

    Avoid deep nesting — extract sub-widgets at 3-4 levels

    line 384
  9. Very specific:

    Use flutter_secure_storage for token persistence

    line 458
  10. Very specific:

    Use image_picker for selection.

    line 597
  11. Very specific:

    Use the infinite_scroll_pagination package (v5) with a Riverpod provider exposing the PagingController.

    line 657
  12. Very specific:

    Use mocktail for mocking (not mockito)

    line 795
  13. Distinctive:

    Test behavior, not implementation — assert what the user sees

    line 798
  14. Distinctive:

    When starting Flutter frontend work from a backend integration summary:

    line 802
  15. Distinctive:

    Verify against the real API before polishing UI

    line 807

“Common” is measured: kept by at least 6 in 10 of the 5 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Skill: Flutter Patterns

Purpose

Guide Claude Code when implementing Flutter features in a Django-paired project. Follow these conventions unless the project's CLAUDE.md overrides them.

Project Structure

lib/
├── main.dart
├── config/
│   ├── env.dart                    # Compile-time env (--dart-define)
│   └── constants.dart              # App-wide constants
├── core/
│   ├── api/                        # Dio client, interceptors
│   ├── error/                      # Sealed Failure hierarchy
│   ├── router/                     # GoRouter, auth guard
│   ├── theme/                      # Material 3 ThemeData
│   ├── providers/                  # App-level (session, auth state)
│   ├── utils/                      # Validation, formatting
│   └── widgets/                    # BaseScreen, shared dialogs
├── features/
│   └── {feature}/
│       ├── models/                 # Freezed data classes
│       ├── services/               # Interface + Django impl
│       ├── providers/              # Riverpod state
│       ├── screens/                # Full-page widgets
│       └── widgets/                # Feature-specific widgets

Naming conventions:

  • Files: snake_case.dart (e.g. book_service_django.dart)
  • Classes: PascalCase (e.g. BookServiceDjango)
  • Features: singular noun (profile, book, auth)
  • Screens: {feature}_{action}_screen.dart (book_list_screen.dart, book_edit_screen.dart)
  • Generated files: co-located (book.freezed.dart, book.g.dart, book_providers.g.dart)

Models

Use Freezed with json_serializable. Models mirror API response shapes.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'book.freezed.dart';
part 'book.g.dart';

@freezed
abstract class Book with _$Book {
  const Book._();

  factory Book({
    @JsonKey(includeToJson: false) String? uuid,
    required String title,
    required String author,
    @JsonKey(name: 'published_date') required DateTime publishedDate,
    String? description,
    @JsonKey(name: 'cover_url') String? coverUrl,
  }) = _Book;

  factory Book.fromJson(Map<String, dynamic> json) => _$BookFromJson(json);

  factory Book.empty() => Book(
        title: '',
        author: '',
        publishedDate: DateTime.now(),
      );

  bool get exists => uuid != null;
}

Conventions:

  • Freezed 3.x: annotate data classes abstract (unions use sealed); @freezed alone no longer generates the class body
  • const Book._() private constructor enables custom getters/methods
  • @JsonKey(name: ...) for snake_case API fields
  • @JsonKey(includeToJson: false) for server-managed fields like uuid (the public identifier — never an integer id)
  • factory Book.empty() for form initialization
  • bool get exists for create-vs-update logic
  • Keep models flat — no nested domain/data split

Services

Abstract interface defines the contract. One concrete implementation per backend.

// book_service.dart
abstract class BookService {
  Future<PaginatedResponse<Book>> list({required int page, int pageSize = 20});
  Future<Book> retrieve(String uuid);
  Future<Book> save(Book book);
  Future<void> delete(String uuid);
}
// book_service_django.dart
class BookServiceDjango implements BookService {
  final DioClient client;

  BookServiceDjango(this.client);

  @override
  Future<PaginatedResponse<Book>> list({required int page, int pageSize = 20}) async {
    final response = await client.get(
      '/api/v1/books/',
      queryParameters: {'page': page, 'page_size': pageSize, 'ordering': '-created_at'},
    );
    final results = (response['results'] as List)
        .map((json) => Book.fromJson(json))
        .toList();
    return PaginatedResponse(
      page: response['page'],
      count: response['count'],
      numPages: response['num_pages'],
      results: results,
    );
  }

  @override
  Future<Book> retrieve(String uuid) async {
    final response = await client.get('/api/v1/books/$uuid/');
    return Book.fromJson(response);
  }

  @override
  Future<Book> save(Book book) async {
    if (book.exists) {
      final response = await client.patch('/api/v1/books/${book.uuid}/', data: book.toJson());
      return Book.fromJson(response);
    }
    final response = await client.post('/api/v1/books/', data: book.toJson());
    return Book.fromJson(response);
  }

  @override
  Future<void> delete(String uuid) async {
    await client.delete('/api/v1/books/$uuid/');
  }
}

Conventions:

  • Services throw typed Failure exceptions (see Error Handling)
  • save() combines create + update using model.exists
  • No Either/fpdart — use AsyncValue for error handling at the provider level
  • One service per feature, one implementation per backend

Providers

Use @riverpod codegen everywhere. AsyncNotifier for stateful operations. Simple functional providers for reads.

Service binding

// book_service_provider.dart
@riverpod
BookService bookService(Ref ref) {
  return BookServiceDjango(ref.watch(dioClientProvider));
}

Detail provider (functional)

// book_detail_provider.dart
@riverpod
Future<Book> bookDetail(Ref ref, String uuid) async {
  return ref.watch(bookServiceProvider).retrieve(uuid);
}

List provider (AsyncNotifier)

// book_list_provider.dart
@riverpod
class BookList extends _$BookList {
  int _page = 1;

  @override
  Future<PaginatedResponse<Book>> build() => _fetch();

  Future<PaginatedResponse<Book>> _fetch() {
    return ref.read(bookServiceProvider).list(page: _page);
  }

  Future<void> loadPage(int page) async {
    _page = page;
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => _fetch());
  }

  Future<void> refresh() async {
    _page = 1;
    ref.invalidateSelf();
  }
}

Form provider (Notifier)

// book_form_provider.dart
@Riverpod(keepAlive: true)
class BookForm extends _$BookForm {
  @override
  Book build() => Book.empty();

  final formKey = GlobalKey<FormState>();
  final titleController = TextEditingController();
  final authorController = TextEditingController();

  void load(Book book) {
    state = book;
    titleController.text = book.title;
    authorController.text = book.author;
  }

  void reset() {
    state = Book.empty();
    titleController.clear();
    authorController.clear();
  }

  Future<bool> submit() async {
    if (!formKey.currentState!.validate()) return false;

    final book = state.copyWith(
      title: titleController.text,
      author: authorController.text,
    );

    try {
      final saved = await ref.read(bookServiceProvider).save(book);
      state = saved;
      reset();
      ref.invalidate(bookListProvider);
      if (saved.uuid != null) ref.invalidate(bookDetailProvider(saved.uuid!));
      return true;
    } on Failure catch (e) {
      // Surface error to UI via snackbar or dialog — don't swallow
      rethrow;
    }
  }

  Future<bool> delete() async {
    if (state.uuid == null) return false;
    await ref.read(bookServiceProvider).delete(state.uuid!);
    reset();
    ref.invalidate(bookListProvider);
    return true;
  }
}

Conventions:

  • @riverpod (lowercase) for auto-dispose providers — most reads
  • @Riverpod(keepAlive: true) for forms and auth state that survive navigation
  • AsyncValue.guard() for wrapping async calls in list/detail providers
  • Form providers own TextEditingControllers and GlobalKey<FormState>
  • After mutation, ref.invalidate() related list/detail providers

Screens

Screens extend BaseScreen, which provides Scaffold with responsive breakpoint support.

class BookListScreen extends BaseScreen {
  static String route() => '/books';

  const BookListScreen({super.key});

  @override
  AppBar? appBar(BuildContext context, WidgetRef ref) {
    return AppBar(
      title: const Text('Books'),
      actions: [
        IconButton(
          onPressed: () {
            ref.read(bookFormProvider.notifier).reset();
            context.push(BookEditScreen.routeNew());
          },
          icon: const Icon(Icons.add),
        ),
      ],
    );
  }

  @override
  Widget body(BuildContext context, WidgetRef ref) {
    return const BookInfiniteListWidget();
  }
}
class BookDetailScreen extends BaseScreen {
  final String bookUuid;
  static String route([String? uuid]) => '/books/${uuid ?? ':uuid'}';

  const BookDetailScreen({super.key, required this.bookUuid});

  @override
  Widget body(BuildContext context, WidgetRef ref) {
    final data = ref.watch(bookDetailProvider(bookUuid));
    return data.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (error, _) => Center(child: Text(error.toString())),
      data: (book) => BookDetailWidget(book: book),
    );
  }
}

Conventions:

  • Static route() method returns the path string
  • Consume providers with ref.watch() in body(), ref.read() for actions
  • Use AsyncValue.when() to handle loading/error/data states
  • Override bodyMd() / bodyLg() only when layout differs at breakpoints

BaseScreen

abstract class BaseScreen extends ConsumerWidget {
  final double horizontalPadding;
  final double verticalPadding;

  const BaseScreen({super.key, this.horizontalPadding = 16, this.verticalPadding = 8});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: appBar(context, ref),
      floatingActionButton: floatingActionButton(context, ref),
      body: Padding(
        padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: verticalPadding),
        child: body(context, ref),
      ),
    );
  }

  AppBar? appBar(BuildContext context, WidgetRef ref) => null;
  FloatingActionButton? floatingActionButton(BuildContext context, WidgetRef ref) => null;
  Widget body(BuildContext context, WidgetRef ref) => const SizedBox.shrink();
}

Widgets

Extraction rules

  • Extract when a widget exceeds ~40 lines or is reused
  • One widget per file in widgets/
  • Prefer const constructors — all fields final
  • Compose widgets, never inherit (except BaseScreen)

Consuming providers in widgets

class BookListTile extends ConsumerWidget {
  final Book book;
  const BookListTile({super.key, required this.book});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ListTile(
      title: Text(book.title),
      subtitle: Text(book.author),
      onTap: () => context.push(BookDetailScreen.route(book.uuid)),
    );
  }
}

Conventions:

  • Pass data down as constructor params when possible
  • Use ConsumerWidget only when the widget needs ref
  • Avoid deep nesting — extract sub-widgets at 3-4 levels

API Client

Singleton Dio instance with interceptor chain: auth, token refresh, logging.

The canonical DioClient is defined in the flutter-bootstrap skill at lib/core/api/dio_client.dart — don't re-implement it here. It exposes get/post/patch/delete, injects the bearer token, refreshes an expired access token against /api/v1/auth/refresh/ (reusing the stored refresh token, since the contract's refresh returns {access} only), and maps every DioException to a typed Failure.

Conventions:

  • Single Dio instance — no per-request construction
  • Timeouts configured at construction
  • Token refresh in the auth interceptor, not scattered across services — and a failed refresh clears the session and rejects the request rather than sending it unauthenticated
  • LogInterceptor gated behind kDebugMode — no raw print()
  • DioClient maps DioException to a typed Failure via mapDioException (see Error Handling) — services and providers only ever see Failures

Auth

JWT session with secure storage, auth state provider, and router guard.

Session provider

The canonical secureStorage provider and the keep-alive Session notifier live in the flutter-bootstrap skill at lib/core/providers/session_provider.dart — Session holds the SessionToken, persists it to flutter_secure_storage, and exposes initialize/setToken/clearToken. Reference it rather than re-declaring it.

Auth notifier

@Riverpod(keepAlive: true)
class Auth extends _$Auth {
  @override
  FutureOr<User?> build() async {
    await ref.read(sessionProvider.notifier).initialize();
    if (ref.read(sessionProvider) == null) return null;
    return ref.read(authServiceProvider).currentUser();
  }

  Future<void> login({required String email, required String password}) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final user = await ref.read(authServiceProvider).login(email: email, password: password);
      return user;
    });
  }

  Future<void> logout() async {
    await ref.read(authServiceProvider).logout();
    ref.read(sessionProvider.notifier).clearToken();
    state = const AsyncData(null);
  }
}

Route guard

@riverpod
GoRouter router(Ref ref) {
  final auth = ref.watch(authProvider);

  return GoRouter(
    initialLocation: '/books',
    redirect: (context, routerState) {
      final isLoggedIn = auth.valueOrNull != null;
      final isAuthRoute = routerState.matchedLocation.startsWith('/auth');

      if (!isLoggedIn && !isAuthRoute) return '/auth/login';
      if (isLoggedIn && isAuthRoute) return '/books';
      return null;
    },
    routes: [/* ... */],
  );
}

Conventions:

  • Use flutter_secure_storage for token persistence
  • Auth provider uses AsyncValue<User?> — null means logged out
  • Router watches auth provider and rebuilds on auth state changes
  • Session provider is keepAlive — survives navigation

Error Handling

Sealed Failure class hierarchy. Services throw typed failures. Providers surface via AsyncValue.error.

The canonical Failure hierarchy — sealed class Failure with ServerFailure/NetworkFailure/AuthFailure/ValidationFailure — is defined in the flutter-bootstrap skill at lib/core/error/failures.dart. Reference it rather than re-declaring the classes; the conversion helper below is the DioException→Failure mapping the client layer applies.

Converting DioException to Failure (in DioClient or a helper)

Failure mapDioException(DioException e) {
  if (e.type == DioExceptionType.connectionError ||
      e.type == DioExceptionType.connectionTimeout) {
    return const NetworkFailure();
  }
  final statusCode = e.response?.statusCode;
  if (statusCode == 401 || statusCode == 403) {
    return AuthFailure(e.response?.data?['detail'] ?? 'Authentication failed');
  }
  if (statusCode == 400) {
    return ValidationFailure(
      'Validation error',
      fieldErrors: _parseFieldErrors(e.response?.data),
    );
  }
  return ServerFailure(
    e.response?.data?['detail'] ?? 'Server error',
    statusCode: statusCode,
  );
}

Surfacing errors in UI

// In a screen or widget:
ref.listen(bookListProvider, (prev, next) {
  if (next is AsyncError) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(next.error.toString())),
    );
  }
});

Conventions:

  • Services catch DioException and throw typed Failure
  • Providers use AsyncValue.guard() — failures surface as AsyncError
  • UI uses ref.listen() or AsyncValue.when() to display errors
  • No Either/fpdart — AsyncValue handles the loading/error/data tri-state

Forms

AsyncNotifier-based form providers own controllers and validation. Submission returns bool for navigation.

Form provider pattern (see Providers section for full example)

Key points:

  • TextEditingController + GlobalKey<FormState> live on the notifier
  • load(Model) populates controllers from existing data
  • reset() clears everything for a fresh form
  • submit() validates, calls service, invalidates related providers, returns success
  • keepAlive: true so form state survives back-navigation during editing

Form widget

class BookFormWidget extends ConsumerWidget {
  const BookFormWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.read(bookFormProvider.notifier);
    final book = ref.watch(bookFormProvider);

    return Form(
      key: notifier.formKey,
      child: Column(
        children: [
          TextFormField(
            controller: notifier.titleController,
            validator: (v) => v == null || v.isEmpty ? 'Title required' : null,
            decoration: const InputDecoration(labelText: 'Title'),
          ),
          TextFormField(
            controller: notifier.authorController,
            decoration: const InputDecoration(labelText: 'Author'),
          ),
        ],
      ),
    );
  }
}

Edit screen with submit

class BookEditScreen extends BaseScreen {
  final String? bookUuid;
  static String route([String? uuid]) => '/books/edit/${uuid ?? ':uuid'}';
  static String routeNew() => '/books/new';

  const BookEditScreen({super.key, this.bookUuid});

  @override
  AppBar? appBar(BuildContext context, WidgetRef ref) {
    return AppBar(
      title: Text(bookUuid != null ? 'Edit Book' : 'New Book'),
      actions: [
        IconButton(
          onPressed: () async {
            final success = await ref.read(bookFormProvider.notifier).submit();
            if (success && context.mounted) context.pop();
          },
          icon: const Icon(Icons.check),
        ),
      ],
    );
  }

  @override
  Widget body(BuildContext context, WidgetRef ref) {
    return const SingleChildScrollView(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: BookFormWidget(),
      ),
    );
  }
}

Image upload

Use image_picker for selection. Upload to the backend's asset endpoint, receive a URL, store it on the model.

Future<void> pickAndUploadImage(WidgetRef ref) async {
  final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
  if (picked == null) return;

  final bytes = await picked.readAsBytes();
  final url = await ref.read(assetServiceProvider).upload(
    filename: picked.name,
    bytes: bytes,
  );
  // Store url on the form model via the form provider
}

Lists

Two patterns: paginated (page controls) and infinite scroll (load-more).

Paginated list provider

See the BookList AsyncNotifier in the Providers section. UI consumes with:

final data = ref.watch(bookListProvider);
data.when(
  loading: () => const CircularProgressIndicator(),
  error: (e, _) => Text(e.toString()),
  data: (paginated) => Column(
    children: [
      ListView.builder(
        shrinkWrap: true,
        itemCount: paginated.results.length,
        itemBuilder: (context, i) => BookListTile(book: paginated.results[i]),
      ),
      // Page controls
      Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          if (paginated.page > 1)
            TextButton(
              onPressed: () => ref.read(bookListProvider.notifier).loadPage(paginated.page - 1),
              child: const Text('Previous'),
            ),
          Text('Page ${paginated.page} of ${paginated.numPages}'),
          if (paginated.page < paginated.numPages)
            TextButton(
              onPressed: () => ref.read(bookListProvider.notifier).loadPage(paginated.page + 1),
              child: const Text('Next'),
            ),
        ],
      ),
    ],
  ),
);

Infinite scroll

Use the infinite_scroll_pagination package (v5) with a Riverpod provider exposing the PagingController. v5 drops appendPage/appendLastPage/addPageRequestListener — the controller is built from getNextPageKey + fetchPage, and the widget consumes a PagingState via PagingListener.

@Riverpod(keepAlive: true)
class BookInfiniteList extends _$BookInfiniteList {
  @override
  PagingController<int, Book> build() {
    final controller = PagingController<int, Book>(
      // Stop once a fetched page comes back empty; otherwise request page N+1.
      getNextPageKey: (state) =>
          state.lastPageIsEmpty ? null : state.nextIntPageKey,
      fetchPage: (page) async {
        final data = await ref.read(bookServiceProvider).list(page: page);
        return data.results;
      },
    );
    ref.onDispose(controller.dispose);
    return controller;
  }

  void refresh() => state.refresh();
}

Widget:

class BookInfiniteListWidget extends ConsumerWidget {
  const BookInfiniteListWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final controller = ref.watch(bookInfiniteListProvider);

    return RefreshIndicator(
      onRefresh: () async => ref.read(bookInfiniteListProvider.notifier).refresh(),
      child: PagingListener(
        controller: controller,
        builder: (context, state, fetchNextPage) => PagedListView<int, Book>(
          state: state,
          fetchNextPage: fetchNextPage,
          builderDelegate: PagedChildBuilderDelegate<Book>(
            itemBuilder: (context, book, index) => BookListTile(book: book),
            noItemsFoundIndicatorBuilder: (_) => const Center(child: Text('No books found')),
          ),
        ),
      ),
    );
  }
}

PaginatedResponse model

The canonical PaginatedResponse<T> — page, count, numPages, results, and canLoadMore — is defined in the flutter-bootstrap skill at lib/core/models/paginated_response.dart. It maps the contract's {page, count, num_pages, results} response shape (query params page/page_size). Reference it rather than re-declaring it.

Filtering and ordering

Pass filter/order params through the service and into query parameters:

Future<PaginatedResponse<Book>> list({
  required int page,
  int pageSize = 20,
  String? search,
  String ordering = '-created_at',
}) async {
  final response = await client.get('/api/v1/books/', queryParameters: {
    'page': page,
    'page_size': pageSize,
    'ordering': ordering,
    if (search != null) 'search': search,
  });
  // ... parse response
}

Testing

Unit tests (services and providers)

// Mock the service
class MockBookService extends Mock implements BookService {}

void main() {
  late MockBookService mockService;

  setUp(() {
    mockService = MockBookService();
  });

  test('retrieve returns book', () async {
    final book = Book(uuid: 'book-uuid-1', title: 'Test', author: 'Author', publishedDate: DateTime.now());
    when(() => mockService.retrieve('book-uuid-1')).thenAnswer((_) async => book);

    final result = await mockService.retrieve('book-uuid-1');
    expect(result.title, 'Test');
  });
}

Widget tests

void main() {
  testWidgets('BookListTile displays title and author', (tester) async {
    final book = Book(id: 1, title: 'My Book', author: 'Jane', publishedDate: DateTime.now());

    await tester.pumpWidget(
      ProviderScope(
        child: MaterialApp(home: Scaffold(body: BookListTile(book: book))),
      ),
    );

    expect(find.text('My Book'), findsOneWidget);
    expect(find.text('Jane'), findsOneWidget);
  });
}

Provider tests

void main() {
  test('bookDetail returns data', () async {
    final container = ProviderContainer(
      overrides: [
        bookServiceProvider.overrideWithValue(MockBookService()),
      ],
    );

    // Set up mock responses, then test provider output
  });
}

Conventions:

  • Use mocktail for mocking (not mockito)
  • Override service providers in tests — never mock Dio directly
  • Widget tests wrap with ProviderScope + MaterialApp
  • Test behavior, not implementation — assert what the user sees

Working from Integration Summaries

When starting Flutter frontend work from a backend integration summary:

  1. Generate Freezed model classes matching response shapes
  2. Build the service interface + Django implementation
  3. Wire up providers (service binding, list, detail, form)
  4. Build screens and widgets
  5. Verify against the real API before polishing UI
  6. Flag any discrepancies between the summary and actual API behavior

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.