Compare commits

...

7 Commits

Author SHA1 Message Date
mansi.kansara a79017810f chore: bump cursor_templates_version to 1.0.5 and update related files
- Updated cursor_templates_version in project-brief.yaml and related files to 1.0.5 for consistency.
- Enhanced CHANGELOG with new features and improvements for version 1.0.5, including MCP JSON generation and schema updates.
- Reflected version changes in pubspec.yaml and package_graph.json.
- Updated template version references in SKILL.md files across various test cases.

This release improves template reproducibility and aligns documentation with the latest changes.
2026-05-14 13:36:07 +05:30
mansi.kansara da64f769da feat(flutter-cursor-templates): introduce MCP integration and conventions in project brief
- Added optional MCP integration settings in project-brief.yaml, allowing for environment-based server configurations.
- Introduced conventions for strict package imports to enhance code organization and maintainability.
- Updated brief schema to validate new MCP properties and ensure correct usage.
- Implemented MCP JSON builder to generate .cursor/mcp.json based on project brief settings.
- Enhanced resolver to include MCP configuration in generated files when enabled.

This update improves integration capabilities and enforces coding standards across the project.
2026-05-14 13:33:13 +05:30
mansi.kansara 2ee257c630 feat(flutter-cursor-templates): add /debug, /verify, /explain skills and goldens.
Wire debug-issue, verify-change, and explain-code into the universal resolver with reasons, extend AGENTS.md slash list, cross-link build Phase 3 and Phase 6, refresh generator goldens for three stacks, and note cross-skill links in the build skill design spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 12:38:13 +05:30
mansi.kansara 44b5d814d4 chore: bump cursor_templates_version to 1.0.4 and update related files
- Updated cursor_templates_version in project-brief.yaml to 1.0.4 for reproducibility.
- Enhanced CHANGELOG with fixes and features for version 1.0.4, including improved template resolution for global installs.
- Updated pubspec.yaml and VERSION file to reflect the new version.
- Added new templates for AGENTS.md and lefthook.yaml to the generator.
- Adjusted tests to include new root-level files in the output.

This release addresses template resolution issues and introduces new repo-level configuration files.
2026-05-14 12:25:13 +05:30
mansi.kansara 3ee83389bd feat(build): add /build skill — universal TDD-first feature implementation command
Adds a Cursor slash command that implements any feature end-to-end:
deep research → TDD (Red/Green/Refactor) → integration test generation
with device pause gate → external setup checklist → verified PR.

Includes a FEATURE_REGISTRY covering 7 feature types (notifications,
auth, payments, deep links, analytics, storage, camera/media) with
per-type test scenarios, external setup steps, and files-to-touch.
Stack-aware: adapts to architecture, state management, backend,
platforms, and e2e tool from project-brief.yaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:13:32 +05:30
mansi.kansara 54c66efe9b chore: update README and CLI usage for cursor_gen, version bump to 1.0.1
- Changed CLI usage instructions from `dart run cursor_gen` to `cursor_gen` for global activation.
- Updated project-brief.yaml example and README to reflect new command usage.
- Added app_context section in project-brief.yaml for theme variants and RBAC roles.
- Fixed bundled template resolution for local and global installs to prevent 'Template not found' errors.
- Version bump to 1.0.1 with corresponding updates in CHANGELOG and pubspec.yaml.
2026-05-13 12:08:52 +05:30
mansi.kansara b05cdb7fbe fix(cursor_gen): satisfy pub publish validation
Add LICENSE (MIT), README, and CHANGELOG for the package root.
Import models.dart in the CLI for OverrideSnapshot.
Wrap tests in main() and add containsNot() helper for analyzer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 22:54:12 +05:30
264 changed files with 15257 additions and 620 deletions
+34 -17
View File
@@ -54,21 +54,37 @@ flutter-cursor-gen/
## Quick Start
### 1. Add to your Flutter project
### 1. Install the CLI globally
```yaml
# pubspec.yaml (dev dependency)
dev_dependencies:
cursor_gen:
git:
url: https://github.com/company/flutter-cursor-templates
path: generator
From pub.dev:
```bash
dart pub global activate cursor_gen
```
From this repository:
```bash
cd flutter-cursor-templates/generator
dart pub global activate --source path .
```
From Git:
```bash
dart pub global activate --source git https://github.com/company/flutter-cursor-templates --git-path generator
```
If `cursor_gen` is not found after activation, add Dart's global bin directory to your shell:
```bash
export PATH="$PATH":"$HOME/.pub-cache/bin"
```
### 2. Create your brief (interactive wizard)
```bash
dart run cursor_gen --wizard
cursor_gen --wizard
```
Or copy the single reference brief (every option is explained in comments):
@@ -80,7 +96,8 @@ cp path/to/flutter-cursor-gen/example-project/project-brief.yaml .
### 3. Generate your .cursor/ directory
```bash
dart run cursor_gen
cursor_gen --validate
cursor_gen
```
### 4. Commit and share with your team
@@ -95,13 +112,13 @@ git commit -m "chore: add cursor AI config for this project"
## All CLI commands
```bash
dart run cursor_gen # First-time setup
dart run cursor_gen --wizard # Interactive brief creator
dart run cursor_gen --validate # Validate brief without generating
dart run cursor_gen --refresh # Re-generate (preserves custom/ + CURSOR:CUSTOM blocks)
dart run cursor_gen --diff # Preview changes before refresh
dart run cursor_gen --check-updates # Check for template version updates
dart run cursor_gen --telemetry # Show usage analytics report
cursor_gen # First-time setup
cursor_gen --wizard # Interactive brief creator
cursor_gen --validate # Validate brief without generating
cursor_gen --refresh # Re-generate (preserves custom/ + CURSOR:CUSTOM blocks)
cursor_gen --diff # Preview changes before refresh
cursor_gen --check-updates # Check for template version updates
cursor_gen --telemetry # Show usage analytics report
```
---
@@ -0,0 +1,250 @@
# Design Spec: /build Skill — Universal Feature Implementation Command
**Date:** 2026-05-14
**Status:** Draft — Awaiting user review
**Scope:** A single Cursor slash command that orchestrates the full feature development lifecycle
---
## 1. Problem Statement
Today, implementing any feature (notifications, auth, payments, etc.) requires the user to manually:
- Specify TDD in the prompt
- Remember to include integration test scenarios
- Know which external services need configuring
- Know which files to touch for their specific stack
- Invoke the right skills in the right order
This leads to inconsistent implementations, missed test scenarios, and forgotten native setup steps.
**Goal:** One command — `/build implement [anything]` — that handles everything from research through PR, following every rule, skill, hook, and architecture pattern defined in the project.
---
## 2. Decision
**Form:** Cursor slash command skill file (`.md.tmpl` inside the generator's template system)
**Scope:** Universal + context-aware (reads `project-brief.yaml` to adapt behavior)
**Integration test gate:** Auto-generate `integration_test/` files + pause for device run
---
## 3. Architecture
### 3.1 File Placement
```
flutter-cursor-templates/
generator/
templates/
skills/
build/
SKILL.md.tmpl ← The skill (single new file)
test/
golden/
bloc-clean-firebase/skills/build/SKILL.md
riverpod-ff-supabase/skills/build/SKILL.md
getx-mvc-rest/skills/build/SKILL.md
```
The generator renders this to `.cursor/skills/build/SKILL.md` in the user's project — same pattern as every other skill.
### 3.2 Template Variables
| Placeholder | Source in project-brief.yaml |
|---|---|
| `{{PROJECT_NAME}}` | `project.name` |
| `{{ARCHITECTURE}}` | `stack.architecture` |
| `{{STATE_MANAGEMENT}}` | `stack.state_management` |
| `{{ROUTING}}` | `stack.routing` |
| `{{BACKEND}}` | `stack.backend` |
| `{{PLATFORMS_LIST}}` | `stack.platforms` |
| `{{CODEGEN_LIST}}` | `stack.codegen` |
| `{{TESTING_DEPTH}}` | `testing.depth` |
| `{{E2E_TOOL}}` | `testing.e2e_tool` |
| `{{FEATURES_LIST}}` | `features.modules` |
| `{{SPECIAL_FEATURES}}` | `features.special` |
| `{{FLAVORS_LIST}}` | `environments.flavors` |
| `{{CICD_TOOL}}` | `environments.cicd` |
| `{{PACKAGE_ID}}` | `project.package` |
Conditional blocks: `{{#if backend_firebase}}`, `{{#if platforms_ios}}`, `{{#if platforms_android}}`, `{{#if codegen_freezed}}`, `{{#if codegen_injectable}}`, `{{#if special_push_notifications}}`, `{{#if special_deep_linking}}`
---
## 4. The 7 Phases
### Phase 1: Context Loading
1. Read `project-brief.yaml` — extract stack, platforms, existing features
2. Parse user's request → match against FEATURE_REGISTRY keywords
3. Print context summary table (feature type, stack, platforms, rules loaded)
4. If feature already in `features.modules`, confirm extending vs. rebuilding
### Phase 2: Deep Research
1. Enumerate required packages + correct versions for current Flutter stable
2. Build complete file manifest: new files (by architecture pattern) + modified files
3. List all external service configuration required per platform
4. Print research results before touching any file
### Phase 3: TDD Implementation
**Invokes: `superpowers:test-driven-development`**
- **Red:** Write all failing unit + widget tests from FEATURE_REGISTRY scenarios. Run to confirm red. Show actual output.
- **Green:** Implement domain → data → presentation layers. Run tests to green. Show actual output.
- **Refactor:** Clean up per `flutter-core.mdc`. Run `flutter analyze`. Show output.
### Phase 4: Integration Test Generation
1. Create `integration_test/[feature_type]/` directory
2. Generate one test file per logical scenario cluster
3. Each file: standard boilerplate with `IntegrationTestWidgetsFlutterBinding` (or Patrol if `{{E2E_TOOL}} = patrol`)
4. Generate `integration_test/[feature]/README.md` — test matrix with run commands
**PAUSE GATE:** Print table of all integration test files, what they cover, whether hardware is required, and the exact `flutter test` commands. Wait for user to paste device output before Phase 6.
### Phase 5: External Setup Checklist
- Grouped by service/platform (Firebase, iOS, Android, backend)
- iOS/Android: table format — Step | Where | What | How to Verify
- Console steps: numbered checklist with exact menu paths and URLs
### Phase 6: Verification Gate
**Invokes: `superpowers:verification-before-completion`**
Required evidence (real output pasted by user):
- `flutter test test/features/[feature]/ --no-pub` → all tests pass
- `flutter analyze` → no issues
- Integration test device output from Phase 4
- `lefthook run pre-commit` → all hooks pass
If any failure: invoke `superpowers:systematic-debugging` before retrying.
### Phase 7: PR Preparation
**Invokes: `superpowers:finishing-a-development-branch`**
- Conventional commit: `feat([feature_type]): implement [feature] end-to-end`
- PR description: what built, test count, integration test matrix, external setup status
- CI/CD advice for `{{CICD_TOOL}}` (secret scoping per flavor)
---
## 5. FEATURE_REGISTRY
Seven feature types with full metadata:
| Feature Type | Keywords (sample) | Packages | Integration Test Scenarios |
|---|---|---|---|
| `notifications` | notification, push, FCM, APNs | firebase_messaging, flutter_local_notifications | foreground/background/killed receipt, tap redirect in all 3 states, payload parsing |
| `auth` | login, sign in, biometric, OAuth, JWT | firebase_auth, local_auth, flutter_secure_storage | full login flow, biometric + fallback, token refresh, cold start with session |
| `payments` | payment, Stripe, IAP, checkout | flutter_stripe, purchases_flutter | test-card checkout, 3DS, Apple/Google Pay, subscription restore |
| `deep_links` | deep link, universal link, app link | app_links, go_router | cold/background/foreground link handling, auth-guarded routes |
| `analytics` | analytics, event, tracking, screen view | firebase_analytics, mixpanel_flutter | event triggered on action, screen view on nav, opt-out disables tracking |
| `storage` | file upload, Firebase Storage, Hive, Isar | firebase_storage, isar, drift | upload + retrieve, offline cache, background upload restore |
| `camera_media` | camera, photo, QR, barcode, video | camera, image_picker, mobile_scanner | photo capture, gallery pick, QR decode, permission denied UI |
Each entry also specifies: `rules_to_load`, `files_to_touch` (new + modified), `external_setup` (per service/platform), `unit_test_scenarios`, `widget_test_scenarios`.
---
## 6. Integration Test Template Structure
```dart
// integration_test/[feature]/[scenario]_test.dart
// Generated by /build — {{PROJECT_NAME}}
// Run: flutter test integration_test/[feature]/[scenario]_test.dart -d <device_id>
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:{{PACKAGE_ID}}/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('[FeatureType] — [Scenario Name]', () {
setUp(() async { /* seed state / reset storage */ });
tearDown(() async { /* cleanup */ });
testWidgets(
'given [precondition], when [action], then [expected outcome]',
(tester) async {
app.main();
await tester.pumpAndSettle();
// test body
},
);
});
}
```
For `{{E2E_TOOL}} = patrol`: imports swap to `package:patrol`, uses `$` Patrol selector syntax.
---
## 7. Skill File Sections (exact headings)
```
# Build — {{PROJECT_NAME}}
## Usage
## FEATURE_REGISTRY
## Phase 1: Context Loading
## Phase 2: Deep Research
## Phase 3: TDD Implementation
## Phase 4: Integration Test Generation
## Phase 5: External Setup Checklist
## Phase 6: Verification Gate
## Phase 7: PR Preparation
## Rules applied every phase
## Code generation notes
```
---
## 8. Cross-Skill Orchestration
The `/build` skill orchestrates existing skills internally:
- Invokes `/scaffold-feature` patterns to create the initial file skeleton (Phase 3b)
- Borrows `/generate-tests` naming and structure conventions (Phase 3a)
- Invokes `/generate-api-client` if `api_docs.format != none` and feature needs API (Phase 2)
- References `/deploy` checklist for flavor-scoped secret handling (Phase 7)
Generated alongside `/build` (same `cursor_gen` bundle) for lifecycle support:
- **`/debug`** (`debug-issue` skill) — structured BugReport + evidence before fixes; use when Phase 6 checks fail with unclear errors or weak reproduction.
- **`/verify`** (`verify-change` skill) — pasted-evidence gate for tests/analyze/hooks without walking the full seven-phase build doc; use for small PRs or post-fix sanity checks.
- **`/explain`** (`explain-code` skill) — read-only walkthrough of code paths; use when research needs human answers about runtime or native behavior before implementation.
The generated `build` skill template also cross-links **Phase 3** (TDD loop) and **Phase 6** (verification failures) to **`/debug`** and **`/verify`** so agents escalate without re-reading the full seven-phase doc.
---
## 9. Rules Applied Every Phase
Always active (no conditional):
- `.cursor/rules/flutter-core.mdc`
- `.cursor/rules/security-standards.mdc`
- `.cursor/rules/project-context.mdc`
- Feature-type rules from `FEATURE_REGISTRY.rules_to_load`
---
## 10. Enhancements / Out of Scope for V1
- Reference project URL fetching and pattern extraction (Phase 1) — included in V1
- Multi-feature requests spanning 2+ feature types — detect and prompt decomposition
- Automatic CI secret rotation advice — Phase 7, advisory only
- Web platform integration test support — flagged as TODO in generated README
- Visual companion for architecture diagrams — post-V1
---
## 11. Verification
End-to-end test of the skill:
1. Run `cursor_gen --wizard` on a test project → choose `bloc/clean/firebase` stack
2. Confirm `.cursor/skills/build/SKILL.md` is generated
3. In Cursor, type `/build implement notification module end-to-end`
4. Verify Phase 1 prints correct context table from project-brief.yaml
5. Verify Phase 3 tests are red before implementation, green after
6. Verify Phase 4 generates `integration_test/notifications/` with 10 test files
7. Verify Phase 5 checklist lists Firebase Console + APNs steps
8. Verify Phase 6 requires real output before proceeding
9. Run golden tests: `dart test test/generator_test.dart` — all stacks must match golden SKILL.md
+1 -1
View File
@@ -1,6 +1,6 @@
# .cursor/custom/ — project-specific overrides
This directory is never modified by `dart run cursor_gen` or `--refresh`.
This directory is never modified by `cursor_gen` or `--refresh`.
Use it for team-only rules, vendor SDK notes, or policies that should not live in the shared template repo.
+23 -2
View File
@@ -1,6 +1,6 @@
# =============================================================================
# project-brief.yaml — SINGLE REFERENCE EXAMPLE for cursor_gen
# Copy to your Flutter repo root, edit values, then: dart run cursor_gen
# Copy to your Flutter repo root, edit values, then: cursor_gen
# Schema / IDE hints: flutter-cursor-templates/generator/brief-schema.json
# =============================================================================
#
@@ -13,7 +13,7 @@
# -----------------------------------------------------------------------------
# Template pin (Pillar 1) — bump when you intentionally upgrade template output
# -----------------------------------------------------------------------------
cursor_templates_version: "1.0.0"
cursor_templates_version: "1.0.5"
# -----------------------------------------------------------------------------
# project — identity and rough size (affects rule tone / scaffolding hints)
@@ -92,6 +92,16 @@ references:
- "https://github.com/example/acme-design-tokens"
local_paths: []
# -----------------------------------------------------------------------------
# app_context — themes & RBAC (mirrored to .cursor/cursor-gen-metadata.json)
# -----------------------------------------------------------------------------
# theme_variants: subset of [ light, dark, high_contrast ]; omit section or use [] → loader defaults to [light, dark]
# roles_enabled / role_names: use named roles when the app has RBAC
# app_context:
# theme_variants: ["light", "dark", "high_contrast"]
# roles_enabled: true
# role_names: ["customer", "merchant", "admin"]
features:
# modules: high-level feature areas in YOUR lib/ (names are project-specific)
modules: ["auth", "home", "catalog", "cart", "checkout", "profile", "orders"]
@@ -106,6 +116,17 @@ localization:
enabled: true
locales: ["en", "es", "fr"]
# -----------------------------------------------------------------------------
# Integrations & conventions (optional)
# -----------------------------------------------------------------------------
integrations:
mcp:
enabled: false # true → .cursor/mcp.json (secrets only via env vars)
preset: auto # auto | minimal
conventions:
strict_package_imports: false
# -----------------------------------------------------------------------------
# Telemetry (Pillar 6) — local-only generation / usage log under .cursor/
# -----------------------------------------------------------------------------
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## [1.0.4] - 2026-05-13
### Fixed
- Global `dart pub global` installs: locate `templates/` from the package root containing `pubspec.yaml` (not under `lib/`).
## [1.0.3] - 2026-05-13
### Fixed
- Template directory resolution for `dart pub global` snapshot installs; fail fast when the bundle is missing instead of emitting placeholders.
### Added
- Generated repo-root `AGENTS.md` and `lefthook.yaml` (from `templates/root/`).
## [1.0.2] - 2026-05-13
### Changed
- Interactive wizard collects the remaining `project-brief.yaml` fields (design, API docs, features, conditional E2E tool and locales) and pins `cursor_templates_version` from a single `kCursorTemplatesVersion` constant.
## [1.0.1] - 2026-05-13
### Fixed
- Resolve bundled templates for local, Git, and hosted/global installs so generated files contain real content instead of `Template not found` placeholders.
### Changed
- Document global `cursor_gen` installation and usage across the README and generated guidance.
- Include a package-local template copy for pub.dev publishing.
## [1.0.0] - 2025-01-01
### Added
- Initial release of flutter-cursor-templates
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.0.4
@@ -0,0 +1,316 @@
{
"configVersion": 2,
"packages": [
{
"name": "_fe_analyzer_shared",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/_fe_analyzer_shared-88.0.0",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "analyzer",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/analyzer-8.1.1",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "ansi_styles",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/ansi_styles-0.3.2+1",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "args",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/args-2.7.0",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "async",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/async-2.13.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "boolean_selector",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "cli_config",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/cli_config-0.2.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "collection",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/collection-1.19.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "convert",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/convert-3.1.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "coverage",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/coverage-1.15.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "crypto",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/crypto-3.0.7",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "file",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/file-7.0.1",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "frontend_server_client",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/frontend_server_client-4.0.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "glob",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/glob-2.1.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "http",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/http-1.6.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "http_multi_server",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "http_parser",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/http_parser-4.1.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "io",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/io-1.0.5",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "lints",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/lints-3.0.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "logging",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/logging-1.3.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "matcher",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/matcher-0.12.19",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "meta",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/meta-1.18.2",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "mime",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/mime-2.0.0",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "node_preamble",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/node_preamble-2.0.2",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "package_config",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/package_config-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "pool",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/pool-1.5.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "pub_semver",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "shelf",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf-1.4.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "shelf_packages_handler",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf_packages_handler-3.0.2",
"packageUri": "lib/",
"languageVersion": "2.17"
},
{
"name": "shelf_static",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf_static-1.1.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "shelf_web_socket",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf_web_socket-3.0.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "source_map_stack_trace",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/source_map_stack_trace-2.1.2",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "source_maps",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/source_maps-0.10.13",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "source_span",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/source_span-1.10.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "stack_trace",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "stream_channel",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "string_scanner",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "term_glyph",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "test",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/test-1.31.0",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "test_api",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/test_api-0.7.11",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "test_core",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/test_core-0.6.17",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "typed_data",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/typed_data-1.4.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "vm_service",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/vm_service-15.2.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "watcher",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/watcher-1.2.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "web",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/web-1.1.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "web_socket",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/web_socket-1.0.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "web_socket_channel",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/web_socket_channel-3.0.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "webkit_inspection_protocol",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/webkit_inspection_protocol-1.2.1",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "yaml",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/yaml-3.1.3",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "cursor_gen",
"rootUri": "../",
"packageUri": "lib/",
"languageVersion": "3.3"
}
],
"generator": "pub",
"generatorVersion": "3.11.3",
"flutterRoot": "file:///Users/maheshlalwani.devgmail.com/Documents/flutter",
"flutterVersion": "3.41.5",
"pubCache": "file:///Users/maheshlalwani.devgmail.com/.pub-cache"
}
@@ -0,0 +1,470 @@
{
"roots": [
"cursor_gen"
],
"packages": [
{
"name": "cursor_gen",
"version": "1.0.5",
"dependencies": [
"ansi_styles",
"args",
"collection",
"crypto",
"http",
"path",
"yaml"
],
"devDependencies": [
"lints",
"test"
]
},
{
"name": "lints",
"version": "3.0.0",
"dependencies": []
},
{
"name": "test",
"version": "1.31.0",
"dependencies": [
"analyzer",
"async",
"boolean_selector",
"collection",
"coverage",
"http_multi_server",
"io",
"matcher",
"node_preamble",
"package_config",
"path",
"pool",
"shelf",
"shelf_packages_handler",
"shelf_static",
"shelf_web_socket",
"source_span",
"stack_trace",
"stream_channel",
"test_api",
"test_core",
"typed_data",
"web_socket_channel",
"webkit_inspection_protocol",
"yaml"
]
},
{
"name": "ansi_styles",
"version": "0.3.2+1",
"dependencies": []
},
{
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "crypto",
"version": "3.0.7",
"dependencies": [
"typed_data"
]
},
{
"name": "http",
"version": "1.6.0",
"dependencies": [
"async",
"http_parser",
"meta",
"web"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "args",
"version": "2.7.0",
"dependencies": []
},
{
"name": "webkit_inspection_protocol",
"version": "1.2.1",
"dependencies": [
"logging"
]
},
{
"name": "web_socket_channel",
"version": "3.0.3",
"dependencies": [
"async",
"crypto",
"stream_channel",
"web",
"web_socket"
]
},
{
"name": "typed_data",
"version": "1.4.0",
"dependencies": [
"collection"
]
},
{
"name": "test_core",
"version": "0.6.17",
"dependencies": [
"analyzer",
"args",
"async",
"boolean_selector",
"collection",
"coverage",
"frontend_server_client",
"glob",
"io",
"meta",
"package_config",
"path",
"pool",
"source_map_stack_trace",
"source_maps",
"source_span",
"stack_trace",
"stream_channel",
"test_api",
"vm_service",
"yaml"
]
},
{
"name": "test_api",
"version": "0.7.11",
"dependencies": [
"async",
"boolean_selector",
"collection",
"meta",
"source_span",
"stack_trace",
"stream_channel",
"string_scanner",
"term_glyph"
]
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "shelf_web_socket",
"version": "3.0.0",
"dependencies": [
"shelf",
"stream_channel",
"web_socket_channel"
]
},
{
"name": "shelf_static",
"version": "1.1.3",
"dependencies": [
"convert",
"http_parser",
"mime",
"path",
"shelf"
]
},
{
"name": "shelf_packages_handler",
"version": "3.0.2",
"dependencies": [
"path",
"shelf",
"shelf_static"
]
},
{
"name": "shelf",
"version": "1.4.2",
"dependencies": [
"async",
"collection",
"http_parser",
"path",
"stack_trace",
"stream_channel"
]
},
{
"name": "pool",
"version": "1.5.2",
"dependencies": [
"async",
"stack_trace"
]
},
{
"name": "package_config",
"version": "2.2.0",
"dependencies": [
"path"
]
},
{
"name": "node_preamble",
"version": "2.0.2",
"dependencies": []
},
{
"name": "matcher",
"version": "0.12.19",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "io",
"version": "1.0.5",
"dependencies": [
"meta",
"path",
"string_scanner"
]
},
{
"name": "http_multi_server",
"version": "3.2.2",
"dependencies": [
"async"
]
},
{
"name": "coverage",
"version": "1.15.0",
"dependencies": [
"args",
"cli_config",
"glob",
"logging",
"meta",
"package_config",
"path",
"source_maps",
"stack_trace",
"vm_service",
"yaml"
]
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"source_span",
"string_scanner"
]
},
{
"name": "async",
"version": "2.13.1",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "analyzer",
"version": "8.1.1",
"dependencies": [
"_fe_analyzer_shared",
"collection",
"convert",
"crypto",
"glob",
"meta",
"package_config",
"path",
"pub_semver",
"source_span",
"watcher",
"yaml"
]
},
{
"name": "web",
"version": "1.1.1",
"dependencies": []
},
{
"name": "meta",
"version": "1.18.2",
"dependencies": []
},
{
"name": "http_parser",
"version": "4.1.2",
"dependencies": [
"collection",
"source_span",
"string_scanner",
"typed_data"
]
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
},
{
"name": "web_socket",
"version": "1.0.1",
"dependencies": [
"web"
]
},
{
"name": "vm_service",
"version": "15.2.0",
"dependencies": []
},
{
"name": "source_maps",
"version": "0.10.13",
"dependencies": [
"source_span"
]
},
{
"name": "source_map_stack_trace",
"version": "2.1.2",
"dependencies": [
"path",
"source_maps",
"stack_trace"
]
},
{
"name": "glob",
"version": "2.1.3",
"dependencies": [
"async",
"collection",
"file",
"path",
"string_scanner"
]
},
{
"name": "frontend_server_client",
"version": "4.0.0",
"dependencies": [
"async",
"path"
]
},
{
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "mime",
"version": "2.0.0",
"dependencies": []
},
{
"name": "convert",
"version": "3.1.2",
"dependencies": [
"typed_data"
]
},
{
"name": "cli_config",
"version": "0.2.0",
"dependencies": [
"args",
"yaml"
]
},
{
"name": "watcher",
"version": "1.2.1",
"dependencies": [
"async",
"path"
]
},
{
"name": "pub_semver",
"version": "2.2.0",
"dependencies": [
"collection"
]
},
{
"name": "_fe_analyzer_shared",
"version": "88.0.0",
"dependencies": [
"meta"
]
},
{
"name": "file",
"version": "7.0.1",
"dependencies": [
"meta",
"path"
]
}
],
"configVersion": 1
}
@@ -0,0 +1,34 @@
# Changelog
## 1.0.5
- Generate `.cursor/mcp.json` from the project brief (MCP presets, backend-specific servers, env placeholders only — no secrets in the repo).
- Brief schema, validation, and resolver updates aligned with the current template set.
- Refreshed bundled templates (rules, agents, hooks, skills, commands, onboarding, root tooling).
## 1.0.4
- Fix package-root detection for template resolution: walk up from the resolved `package:cursor_gen/...` file until `pubspec.yaml` is found (global installs were incorrectly using `lib/templates`).
## 1.0.3
- Resolve template bundle by sentinel file (`rules/universal/flutter-core.mdc.tmpl`); prefer package `templates/` first for global installs; exit with a clear error instead of writing placeholder-only output.
- Generate repo-root `AGENTS.md` and `lefthook.yaml` from `templates/root/`.
- Golden tests now `await` async golden comparison so repo-root goldens are written reliably.
## 1.0.2
- Wizard aligns with full brief schema; shared `kCursorTemplatesVersion` for lock file and generated brief.
## 1.0.1
- Fix default template resolution so generated projects use bundled templates instead of writing `Template not found` placeholders.
- Include templates in the published package so hosted/global installs can generate real content.
- Add a regression test for the generated `hooks/arch-guard.ts` content.
- Improve installation docs for global `cursor_gen` usage.
## 1.0.0
- Initial published release of the `cursor_gen` CLI.
- Generates `.cursor/` rules and related config from `project-brief.yaml`.
- Supports validate, generate, refresh (preserve customizations), diff preview, wizard, and update checks.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cursor_gen contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,98 @@
# cursor_gen
CLI that generates project-specific [Cursor](https://cursor.com) AI configuration for Flutter projects from a `project-brief.yaml` file (stack, architecture, routing, backends, testing, and more).
## Requirements
- Dart SDK `>=3.3.0 <4.0.0`
## Install
Install the CLI globally so you can run `cursor_gen` from any Flutter project.
**From pub.dev:**
```bash
dart pub global activate cursor_gen
```
**From Git:**
```bash
dart pub global activate --source git https://github.com/company/flutter-cursor-templates --git-path generator
```
**From a local checkout:**
```bash
cd flutter-cursor-templates/generator
dart pub global activate --source path .
```
If your shell cannot find `cursor_gen`, add Dart's global bin directory to your `PATH`:
```bash
export PATH="$PATH":"$HOME/.pub-cache/bin"
```
For zsh, add that line to `~/.zshrc` and restart your terminal.
**From a private Pub registry:** configure access first, then activate from that registry:
```bash
dart pub token add https://your-registry.example.com
dart pub global activate cursor_gen --hosted-url=https://your-registry.example.com
```
## Setup In Your Flutter Project
Create a `project-brief.yaml`:
```bash
cursor_gen --wizard
```
Or copy the example brief and edit it:
```bash
cp path/to/flutter-cursor-templates/example-project/project-brief.yaml .
```
Validate the brief, then generate `.cursor/`:
```bash
cursor_gen --validate
cursor_gen
```
## Usage
```bash
cursor_gen --help
cursor_gen --validate --brief project-brief.yaml
cursor_gen --brief project-brief.yaml --output .cursor
cursor_gen --refresh # regenerate while preserving custom/ and CURSOR:CUSTOM blocks
cursor_gen --wizard # interactive project-brief.yaml creator
```
## Minimal `project-brief.yaml`
```yaml
project:
name: MyApp
package: com.example.myapp
scale: medium
stack:
state_management: bloc
architecture: clean
routing: gorouter
platforms: [ios, android]
backend: firebase
auth: firebase_auth
```
Use `brief-schema.json` in this package for IDE validation and autocomplete of the full schema.
## Repository
For full monorepo layout, template library, and architecture notes, see the parent repository README.
@@ -1,9 +1,13 @@
#!/usr/bin/env dart
// cursor_gen — Flutter Cursor AI config generator
// Usage: dart run cursor_gen [options]
// Usage: cursor_gen [options]
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:args/args.dart';
import 'package:path/path.dart' as p;
import '../src/models.dart';
import '../src/brief_loader.dart';
import '../src/resolver.dart';
import '../src/renderer.dart';
@@ -17,15 +21,33 @@ import '../src/logger.dart';
Future<void> main(List<String> arguments) async {
final parser = ArgParser()
..addFlag('help', abbr: 'h', negatable: false, help: 'Show usage')
..addFlag('validate', abbr: 'v', negatable: false, help: 'Validate project-brief.yaml without writing files')
..addFlag('refresh', abbr: 'r', negatable: false, help: 'Re-generate .cursor/ preserving custom/ and CURSOR:CUSTOM markers')
..addFlag('check-updates', negatable: false, help: 'Check if newer template version is available')
..addFlag('diff', negatable: false, help: 'Preview what would change before refreshing')
..addFlag('wizard', abbr: 'w', negatable: false, help: 'Interactive wizard to create project-brief.yaml')
..addFlag('telemetry', negatable: false, help: 'Show telemetry / rule-trigger report')
..addOption('brief', abbr: 'b', defaultsTo: 'project-brief.yaml', help: 'Path to project-brief.yaml')
..addOption('output', abbr: 'o', defaultsTo: '.cursor', help: 'Output directory')
..addOption('templates', defaultsTo: '', help: 'Override template library path');
..addFlag('validate',
abbr: 'v',
negatable: false,
help: 'Validate project-brief.yaml without writing files')
..addFlag('refresh',
abbr: 'r',
negatable: false,
help:
'Re-generate .cursor/ preserving custom/ and CURSOR:CUSTOM markers')
..addFlag('check-updates',
negatable: false, help: 'Check if newer template version is available')
..addFlag('diff',
negatable: false, help: 'Preview what would change before refreshing')
..addFlag('wizard',
abbr: 'w',
negatable: false,
help: 'Interactive wizard to create project-brief.yaml')
..addFlag('telemetry',
negatable: false, help: 'Show telemetry / rule-trigger report')
..addOption('brief',
abbr: 'b',
defaultsTo: 'project-brief.yaml',
help: 'Path to project-brief.yaml')
..addOption('output',
abbr: 'o', defaultsTo: '.cursor', help: 'Output directory')
..addOption('templates',
defaultsTo: '', help: 'Override template library path');
final ArgResults args;
try {
@@ -84,8 +106,9 @@ Future<void> main(List<String> arguments) async {
final versionStatus = await VersionManager.check(brief: brief);
if (versionStatus.hasUpdate) {
Logger.warn('\n⚠ Template update available: ${versionStatus.currentVersion}${versionStatus.latestVersion}');
Logger.warn(' Run: dart run cursor_gen --check-updates for full diff\n');
Logger.warn(
'\n⚠ Template update available: ${versionStatus.currentVersion}${versionStatus.latestVersion}');
Logger.warn(' Run: cursor_gen --check-updates for full diff\n');
}
if (args['diff'] as bool) {
@@ -95,9 +118,11 @@ Future<void> main(List<String> arguments) async {
_printBanner();
Logger.info('Generating .cursor/ for ${brief.projectName}');
Logger.dim(' Stack: ${brief.stateManagement} + ${brief.architecture} + ${brief.backends.join("+")}');
Logger.dim(
' Stack: ${brief.stateManagement} + ${brief.architecture} + ${brief.backends.join("+")}');
Logger.dim(' Platform: ${brief.platforms.join(", ")}');
Logger.dim(' Codegen: ${brief.codegenTools.isEmpty ? "none" : brief.codegenTools.join(", ")}\n');
Logger.dim(
' Codegen: ${brief.codegenTools.isEmpty ? "none" : brief.codegenTools.join(", ")}\n');
final isRefresh = args['refresh'] as bool;
final overrideSnapshot = await OverrideManager.snapshot(outputDir);
@@ -105,51 +130,72 @@ Future<void> main(List<String> arguments) async {
final templateFiles = Resolver.resolve(brief);
Logger.info('Resolved ${templateFiles.length} template files');
final templateDir = templateSrc.isEmpty ? _defaultTemplateDir() : templateSrc;
final templateDir = await _resolveTemplateDir(templateSrc);
final rendered = await Renderer.render(
brief: brief, templateFiles: templateFiles, templateSrc: templateDir,
brief: brief,
templateFiles: templateFiles,
templateSrc: templateDir,
);
await _writeOutput(outputDir, rendered, overrideSnapshot, isRefresh);
await VersionManager.writeLock(outputDir: outputDir, briefPath: briefPath, brief: brief);
await Telemetry.record(projectName: brief.projectName, outputDir: outputDir, templateFiles: templateFiles);
await _writeMetadataJson(outputDir, brief);
await VersionManager.writeLock(
outputDir: outputDir, briefPath: briefPath, brief: brief);
await Telemetry.record(
projectName: brief.projectName,
outputDir: outputDir,
templateFiles: templateFiles);
Logger.success('\n✔ Done! ${rendered.length} files written to $outputDir/');
final underCursor =
rendered.keys.where((k) => !k.startsWith('__root__/')).length;
final atRoot = rendered.length - underCursor;
Logger.success(
'\n✔ Done! $underCursor file(s) under $outputDir/'
'${atRoot > 0 ? ", $atRoot at project root" : ""}.');
if (overrideSnapshot.customFiles.isNotEmpty) {
Logger.success('${overrideSnapshot.customFiles.length} custom override(s) preserved untouched');
Logger.success(
'${overrideSnapshot.customFiles.length} custom override(s) preserved untouched');
}
Logger.dim('\n Next: commit .cursor/ to git so all teammates get the same config.');
Logger.dim(
'\n Next: commit .cursor/ to git so all teammates get the same config.');
}
Future<void> _runDiff(dynamic brief, String outputDir, String templateSrc) async {
Future<void> _runDiff(
dynamic brief, String outputDir, String templateSrc) async {
final templateFiles = Resolver.resolve(brief);
final rendered = await Renderer.render(
brief: brief, templateFiles: templateFiles,
templateSrc: templateSrc.isEmpty ? _defaultTemplateDir() : templateSrc,
brief: brief,
templateFiles: templateFiles,
templateSrc: await _resolveTemplateDir(templateSrc),
);
Logger.info('Diff preview (no files written):\n');
for (final entry in rendered.entries) {
final existing = File('$outputDir/${entry.key}');
final existing = File(_resolvedOutputPath(outputDir, entry.key));
if (!existing.existsSync()) {
Logger.success(' + ${entry.key}');
} else {
final current = await existing.readAsString();
if (current != entry.value) Logger.warn(' ~ ${entry.key}');
else Logger.dim(' = ${entry.key}');
if (current != entry.value)
Logger.warn(' ~ ${entry.key}');
else
Logger.dim(' = ${entry.key}');
}
}
}
Future<void> _writeOutput(
String outputDir, Map<String, String> rendered,
OverrideSnapshot snapshot, bool isRefresh,
String outputDir,
Map<String, String> rendered,
OverrideSnapshot snapshot,
bool isRefresh,
) async {
for (final entry in rendered.entries) {
final file = File('$outputDir/${entry.key}');
final file = File(_resolvedOutputPath(outputDir, entry.key));
await file.parent.create(recursive: true);
if (isRefresh && file.existsSync()) {
final merged = OverrideManager.mergeCustomSections(
newContent: entry.value, existingContent: await file.readAsString(),
newContent: entry.value,
existingContent: await file.readAsString(),
);
await file.writeAsString(merged);
} else {
@@ -159,9 +205,79 @@ Future<void> _writeOutput(
await OverrideManager.restoreCustomFolder(snapshot, outputDir);
}
String _defaultTemplateDir() {
final scriptDir = Platform.script.toFilePath();
return scriptDir.replaceAll(RegExp(r'bin[/\\]cursor_gen\.dart$'), 'templates');
Future<void> _writeMetadataJson(String outputDir, ProjectBrief brief) async {
final path = p.join(outputDir, 'cursor-gen-metadata.json');
final file = File(path);
await file.parent.create(recursive: true);
final encoder = JsonEncoder.withIndent(' ');
await file.writeAsString(encoder.convert(brief.toMetadataMap()));
}
const _templateSentinel = 'rules/universal/flutter-core.mdc.tmpl';
Future<String> _resolveTemplateDir(String override) async {
if (override.isNotEmpty) {
final dir = p.normalize(override);
_assertTemplateBundle(dir);
return dir;
}
final tried = <String>[];
for (final dir in await _templateDirCandidates()) {
tried.add(dir);
if (File(p.join(dir, _templateSentinel)).existsSync()) return dir;
}
Logger.error('cursor_gen: No template bundle found (missing $_templateSentinel). Tried:');
for (final t in tried) Logger.error('$t');
Logger.error(
'Use: cursor_gen --templates /path/to/cursor_gen/templates (folder containing rules/, hooks/, …)');
exit(1);
}
void _assertTemplateBundle(String dir) {
if (!File(p.join(dir, _templateSentinel)).existsSync()) {
Logger.error('Invalid template directory: $dir ($_templateSentinel not found)');
exit(1);
}
}
Future<List<String>> _templateDirCandidates() async {
final binDir = p.dirname(Platform.script.toFilePath());
final out = <String>[];
final packageUri = await Isolate.resolvePackageUri(
Uri.parse('package:cursor_gen/src/renderer.dart'),
);
if (packageUri != null && packageUri.isScheme('file')) {
final packageRoot = _packageRootDir(packageUri.toFilePath());
if (packageRoot.isNotEmpty) {
out.add(p.normalize(p.join(packageRoot, 'templates')));
}
}
// Published layout: <package>/templates next to bin/
out.add(p.normalize(p.join(binDir, '..', 'templates')));
// Monorepo: flutter-cursor-templates/templates
out.add(p.normalize(p.join(binDir, '..', '..', 'templates')));
return out;
}
/// From e.g. `.../cursor_gen-1.0.3/lib/src/renderer.dart`, walk up to the directory that contains `pubspec.yaml`.
String _packageRootDir(String resolvedLibFile) {
var dir = p.dirname(resolvedLibFile);
while (true) {
if (File(p.join(dir, 'pubspec.yaml')).existsSync()) return dir;
final parent = p.dirname(dir);
if (parent == dir) return '';
dir = parent;
}
}
String _resolvedOutputPath(String outputDir, String relativeKey) {
if (relativeKey.startsWith('__root__/')) {
final name = relativeKey.substring('__root__/'.length);
return p.normalize(p.join(p.dirname(p.absolute(outputDir)), name));
}
return p.normalize(p.join(p.absolute(outputDir), relativeKey));
}
void _printBanner() {
@@ -8,7 +8,7 @@
"cursor_templates_version": {
"type": "string",
"description": "Pillar 1: Pin to template version for reproducibility",
"examples": ["1.0.0"]
"examples": ["1.0.5"]
},
"project": {
"type": "object",
@@ -91,10 +91,109 @@
"locales": { "type": "array", "items": { "type": "string" } }
}
},
"design": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["figma_mcp", "figma_manual", "native_ref", "html_ref", "none"]
},
"figma_url": { "type": "string" }
}
},
"api_docs": {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["openapi", "postman", "markdown", "none"]
},
"path": { "type": "string" }
}
},
"references": {
"type": "object",
"description": "Other repos or local paths agents should treat as product context",
"properties": {
"repos": {
"type": "array",
"items": { "type": "string" },
"description": "Git remote URLs (https or ssh)"
},
"local_paths": {
"type": "array",
"items": { "type": "string" },
"description": "Repo-relative paths (e.g. monorepo packages) or other local references"
}
}
},
"features": {
"type": "object",
"properties": {
"modules": { "type": "array", "items": { "type": "string" } },
"special": { "type": "array", "items": { "type": "string" } }
}
},
"app_context": {
"type": "object",
"description": "Theme targets and optional RBAC labels — mirrored to cursor-gen-metadata.json on generate",
"properties": {
"theme_variants": {
"type": "array",
"items": {
"type": "string",
"enum": ["light", "dark", "high_contrast"]
},
"description": "Supported theme tokens; omit or leave empty to default to light + dark in the loader"
},
"roles_enabled": {
"type": "boolean",
"default": false,
"description": "When true, list concrete roles in role_names"
},
"role_names": {
"type": "array",
"items": { "type": "string" },
"description": "Product role identifiers when roles_enabled is true"
}
}
},
"telemetry_opt_in": {
"type": "boolean",
"description": "Pillar 6: Opt-in local telemetry for rule trigger analytics",
"default": false
},
"integrations": {
"type": "object",
"description": "Optional third-party integrations for generated Cursor config",
"properties": {
"mcp": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "When true, emit .cursor/mcp.json with env-placeholder server stubs only"
},
"preset": {
"type": "string",
"enum": ["auto", "minimal"],
"default": "auto",
"description": "minimal = empty mcpServers; auto = brief-derived stubs (still no committed secrets)"
}
}
}
}
},
"conventions": {
"type": "object",
"properties": {
"strict_package_imports": {
"type": "boolean",
"default": false,
"description": "When true, flutter-core rule enforces package: imports across feature boundaries"
}
}
}
}
}
@@ -0,0 +1,405 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
url: "https://pub.dev"
source: hosted
version: "88.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
ansi_styles:
dependency: "direct main"
description:
name: ansi_styles
sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a"
url: "https://pub.dev"
source: hosted
version: "0.3.2+1"
args:
dependency: "direct main"
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
collection:
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
lints:
dependency: "direct dev"
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
meta:
dependency: transitive
description:
name: meta
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
url: "https://pub.dev"
source: hosted
version: "1.18.2"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: "direct main"
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.0 <4.0.0"
@@ -1,6 +1,6 @@
name: cursor_gen
description: A CLI tool that generates project-specific Cursor AI configurations for Flutter projects.
version: 1.0.0
version: 1.0.5
homepage: https://github.com/company/flutter-cursor-templates
environment:
@@ -9,7 +9,6 @@ environment:
dependencies:
args: ^2.4.2
yaml: ^3.1.2
json_schema: ^6.0.0
path: ^1.9.0
http: ^1.2.0
crypto: ^3.0.3
@@ -7,7 +7,7 @@ class BriefLoader {
final file = File(path);
if (!file.existsSync()) {
throw FileSystemException('project-brief.yaml not found at $path. '
'Run: dart run cursor_gen --wizard to create one.');
'Run: cursor_gen --wizard to create one.');
}
final content = await file.readAsString();
final yaml = loadYaml(content) as YamlMap;
@@ -19,14 +19,17 @@ class BriefLoader {
final design = yaml['design'] as YamlMap? ?? YamlMap();
final apiDocs = yaml['api_docs'] as YamlMap? ?? YamlMap();
final refs = yaml['references'] as YamlMap? ?? YamlMap();
final appCtx = yaml['app_context'] as YamlMap? ?? YamlMap();
final features = yaml['features'] as YamlMap? ?? YamlMap();
final l10n = yaml['localization'] as YamlMap? ?? YamlMap();
final integrations = yaml['integrations'] as YamlMap? ?? YamlMap();
final mcp = integrations['mcp'] as YamlMap? ?? YamlMap();
final conventions = yaml['conventions'] as YamlMap? ?? YamlMap();
// Parse backends (can be "firebase+rest" shorthand or list)
final backendRaw = stack['backend']?.toString() ?? 'rest';
final backends = backendRaw.contains('+')
? backendRaw.split('+')
: [backendRaw];
final backends =
backendRaw.contains('+') ? backendRaw.split('+') : [backendRaw];
return ProjectBrief(
projectName: project['name']?.toString() ?? 'MyApp',
@@ -67,6 +70,19 @@ class BriefLoader {
cursorTemplatesVersion: yaml['cursor_templates_version']?.toString(),
telemetryOptIn: yaml['telemetry_opt_in'] as bool? ?? false,
themeVariants: () {
final t = _toStringList(appCtx['theme_variants']);
if (t == null || t.isEmpty) return ['light', 'dark'];
return t;
}(),
rolesEnabled: appCtx['roles_enabled'] as bool? ?? false,
roleNames: _toStringList(appCtx['role_names']) ?? [],
mcpConfigEnabled: mcp['enabled'] as bool? ?? false,
mcpPreset: mcp['preset']?.toString() ?? 'auto',
strictPackageImports:
conventions['strict_package_imports'] as bool? ?? false,
);
}
@@ -0,0 +1,88 @@
// mcp_json.dart — Builds .cursor/mcp.json from project brief (env placeholders only).
import 'dart:convert';
import 'models.dart';
class McpJsonBuilder {
/// JSON string for Cursor MCP config. Never embed secrets — use `\${VAR}` in strings.
static String build(ProjectBrief brief) {
if (brief.mcpPreset == 'minimal') {
return const JsonEncoder.withIndent(' ')
.convert(<String, dynamic>{'mcpServers': <String, dynamic>{}});
}
final servers = <String, Map<String, dynamic>>{
'filesystem': {
'command': 'npx',
'args': ['-y', '@modelcontextprotocol/server-filesystem', '.'],
'description': 'Read/write project files under the workspace root',
},
'flutter-docs': {
'command': 'npx',
'args': ['-y', '@modelcontextprotocol/server-fetch'],
'env': {'BASE_URL': 'https://api.flutter.dev/flutter'},
'description': 'Flutter API documentation lookup',
},
'dart-pub': {
'command': 'npx',
'args': ['-y', '@modelcontextprotocol/server-fetch'],
'env': {'BASE_URL': 'https://pub.dev/api'},
'description': 'Pub.dev package metadata and versions',
},
'github': {
'command': 'npx',
'args': ['-y', '@modelcontextprotocol/server-github'],
'env': {'GITHUB_TOKEN': r'${GITHUB_TOKEN}'},
'description': 'GitHub issues and PR context (requires GITHUB_TOKEN)',
},
};
if (brief.backends.contains('firebase')) {
servers['firebase'] = {
'command': 'npx',
'args': ['-y', '@modelcontextprotocol/server-firebase'],
'env': {'FIREBASE_PROJECT': r'${FIREBASE_PROJECT_ID}'},
'description':
'Firebase project context (configure FIREBASE_PROJECT_ID)',
};
}
if (brief.designSource == 'figma_mcp') {
servers['figma'] = {
'url': 'https://mcp.figma.com/mcp',
'type': 'http',
'headers': {
'Authorization': r'Bearer ${FIGMA_ACCESS_TOKEN}',
},
'description':
'Figma MCP — set FIGMA_ACCESS_TOKEN (never commit the value)',
};
}
if (brief.backends.contains('supabase')) {
servers['supabase'] = {
'command': 'npx',
'args': ['-y', '@supabase/mcp-server-supabase@latest'],
'env': {
'SUPABASE_ACCESS_TOKEN': r'${SUPABASE_ACCESS_TOKEN}',
},
'description': 'Supabase MCP — token from env only',
};
}
if (brief.apiDocsFormat == 'openapi' && brief.apiDocsPath.isNotEmpty) {
servers['openapi-ref'] = {
'command': 'npx',
'args': ['-y', '@modelcontextprotocol/server-fetch'],
'env': {
'OPENAPI_SPEC_URL': r'${OPENAPI_SPEC_URL}',
},
'description':
'Optional fetch MCP — point OPENAPI_SPEC_URL at a hosted spec or file:// URL you expose locally',
};
}
return const JsonEncoder.withIndent(' ').convert({'mcpServers': servers});
}
}
@@ -52,6 +52,22 @@ class ProjectBrief {
// Telemetry opt-in (Pillar 6)
final bool telemetryOptIn;
/// Theme variants to support (subset of light, dark, high_contrast).
final List<String> themeVariants;
/// App-level RBAC: when true, [roleNames] should list concrete roles.
final bool rolesEnabled;
final List<String> roleNames;
/// When true, emit `.cursor/mcp.json` with safe env-placeholder servers.
final bool mcpConfigEnabled;
/// When [mcpConfigEnabled]: `minimal` (empty `mcpServers`) or `auto` (stubs from brief).
final String mcpPreset;
/// When true, generated flutter-core rule enforces package imports (no cross-feature relatives).
final bool strictPackageImports;
const ProjectBrief({
required this.projectName,
required this.packageId,
@@ -80,7 +96,37 @@ class ProjectBrief {
required this.locales,
this.cursorTemplatesVersion,
this.telemetryOptIn = false,
this.themeVariants = const ['light', 'dark'],
this.rolesEnabled = false,
this.roleNames = const [],
this.mcpConfigEnabled = false,
this.mcpPreset = 'auto',
this.strictPackageImports = false,
});
/// Local snapshot for tooling (written as `cursor-gen-metadata.json` under the output dir).
Map<String, dynamic> toMetadataMap({DateTime? generatedAt}) {
final at = (generatedAt ?? DateTime.now()).toUtc();
return {
'schema_version': 1,
'generated_at': at.toIso8601String(),
'project': {
'name': projectName,
'package': packageId,
'description': description,
'scale': scale,
},
'references': {
'repos': List<String>.from(referenceRepos),
'local_paths': List<String>.from(localPaths),
},
'app_context': {
'theme_variants': List<String>.from(themeVariants),
'roles_enabled': rolesEnabled,
'role_names': List<String>.from(roleNames),
},
};
}
}
class ValidationResult {
@@ -2,6 +2,7 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'mcp_json.dart';
import 'models.dart';
class Renderer {
@@ -10,21 +11,32 @@ class Renderer {
required List<String> templateFiles,
required String templateSrc,
}) async {
final context = _buildContext(brief);
final baseContext = _buildContext(brief);
final output = <String, String>{};
for (final key in templateFiles) {
if (key == 'config/mcp-json') {
output['mcp.json'] = McpJsonBuilder.build(brief);
continue;
}
final outPath = _outputPath(key);
final tmplPath = _templatePath(templateSrc, key);
final file = File(tmplPath);
if (!file.existsSync()) {
// Gracefully skip missing optional templates with a placeholder
output[_outputPath(key)] = _missingTemplatePlaceholder(key);
output[outPath] = _missingTemplatePlaceholder(key);
continue;
}
var content = await file.readAsString();
content = _substituteAll(content, context);
_checkUnreplacedPlaceholders(content, key); // Pillar 3: validate no broken {{VAR}}
output[_outputPath(key)] = content;
final ctx = Map<String, String>.from(baseContext);
if (key.startsWith('rules/features/')) {
final slug = p.basename(key);
ctx['FEATURE_MODULE'] = slug;
ctx['FEATURE_MODULE_TITLE'] = _titleCase(slug);
}
content = _substituteAll(content, ctx);
_checkUnreplacedPlaceholders(content, key);
output[outPath] = content;
}
return output;
}
@@ -47,7 +59,8 @@ class Renderer {
'AUTH': _displayName(brief.auth),
'AUTH_RAW': brief.auth,
'PLATFORMS_LIST': brief.platforms.join(', '),
'CODEGEN_LIST': brief.codegenTools.isEmpty ? 'none' : brief.codegenTools.join(', '),
'CODEGEN_LIST':
brief.codegenTools.isEmpty ? 'none' : brief.codegenTools.join(', '),
'FLAVORS_LIST': brief.flavors.join(', '),
'CICD_TOOL': _displayName(brief.cicd),
'CICD_RAW': brief.cicd,
@@ -59,14 +72,45 @@ class Renderer {
'FIGMA_URL': brief.figmaUrl,
'API_DOCS_FORMAT': brief.apiDocsFormat,
'API_DOCS_PATH': brief.apiDocsPath,
'REFERENCE_REPOS': brief.referenceRepos.join('\n- '),
'GIT_REFS_BLOCK': _gitRefsBlock(brief),
'LOCAL_PATHS_BLOCK': _localPathsBlock(brief),
'THEME_SUMMARY': _themeSummary(brief),
'ROLES_SUMMARY': _rolesSummary(brief),
'HIGH_CONTRAST_NOTE': _highContrastNote(brief),
'HIGH_CONTRAST_UX_LINE': _highContrastUxLine(brief),
'ARCH_IMPORT_RULES': _archImportRules(brief.architecture),
'TEST_PATTERN': _testPattern(brief.stateManagement),
'LOCALES_LIST': brief.locales.join(', '),
'TEMPLATE_VERSION': '1.0.0',
'TEMPLATE_VERSION': '1.0.5',
'IMPORT_POLICY_BLOCK': _importPolicyBlock(brief.strictPackageImports),
};
}
static String _titleCase(String slug) {
if (slug.isEmpty) return slug;
return slug
.split('_')
.where((w) => w.isNotEmpty)
.map((w) => '${w[0].toUpperCase()}${w.substring(1)}')
.join(' ');
}
static String _importPolicyBlock(bool strictPackage) {
if (strictPackage) {
return '''
### Imports (strict — `conventions.strict_package_imports: true`)
- Use `package:<your_pubspec_name>/...` imports everywhere in `lib/` and `test/` — **no** relative `../` across feature boundaries (the brief `project.package` id is often the app bundle id; prefer the **pubspec.yaml `name`** for Dart imports)
- Barrel files (`index.dart`) at feature roots; do not wildcard re-export third-party packages
''';
}
return '''
### Imports (default)
- Order: `dart:` → `package:` → relative
- Relative imports are allowed **within** the same feature directory; use `package:` imports for cross-feature code
- Never import another feature's internals from outside that feature
''';
}
static String _substituteAll(String content, Map<String, String> ctx) {
for (final entry in ctx.entries) {
content = content.replaceAll('{{${entry.key}}}', entry.value);
@@ -80,31 +124,52 @@ class Renderer {
if (matches.isNotEmpty) {
final vars = matches.map((m) => m.group(0)).toSet();
// Print warning but don't fail — let golden tests catch it
stderr.writeln('⚠ Unreplaced placeholder(s) in $key: ${vars.join(", ")}');
stderr
.writeln('⚠ Unreplaced placeholder(s) in $key: ${vars.join(", ")}');
}
}
static String _templatePath(String templateSrc, String key) {
// skills use SKILL.md.tmpl, everything else uses .mdc.tmpl
final ext = key.startsWith('skills/') ? 'SKILL.md.tmpl' : '.mdc.tmpl';
final dir = key.startsWith('skills/') ? p.dirname(key) : '';
if (key.startsWith('root/')) {
return p.join(templateSrc, '$key.tmpl');
}
if (key.startsWith('skills/')) {
final skillName = p.basename(key);
return p.join(templateSrc, 'skills', skillName, 'SKILL.md.tmpl');
}
if (key.startsWith('hooks/')) {
final hookFile = p.basename(key).replaceAll('-', '.');
// special: hooks-json → hooks.json.tmpl
if (key.endsWith('hooks-json')) {
return p.join(templateSrc, 'hooks', 'hooks.json.tmpl');
}
return p.join(templateSrc, 'hooks', '${p.basename(key)}.ts.tmpl');
}
if (key.startsWith('commands/')) {
final name = p.basename(key);
return p.join(templateSrc, 'commands', '$name.md.tmpl');
}
if (key == 'onboarding/ONBOARDING') {
return p.join(templateSrc, 'onboarding', 'ONBOARDING.md.tmpl');
}
if (key.startsWith('telemetry/')) {
final leaf = p.basename(key);
if (leaf == 'gitignore') {
return p.join(templateSrc, 'telemetry', 'gitignore.tmpl');
}
if (leaf == 'log-sh') {
return p.join(templateSrc, 'telemetry', 'log.sh.tmpl');
}
}
if (key.startsWith('rules/features/')) {
return p.join(templateSrc, 'rules', 'features', '_stub.mdc.tmpl');
}
return p.join(templateSrc, '$key.mdc.tmpl');
}
static String _outputPath(String key) {
if (key.startsWith('root/')) {
return '__root__/${key.substring(5)}';
}
if (key.startsWith('skills/')) {
final name = p.basename(key);
return 'skills/$name/SKILL.md';
@@ -113,6 +178,22 @@ class Renderer {
if (key.endsWith('hooks-json')) return 'hooks/hooks.json';
return 'hooks/${p.basename(key)}.ts';
}
if (key.startsWith('commands/')) {
final name = p.basename(key);
return 'commands/$name.md';
}
if (key == 'onboarding/ONBOARDING') {
return 'ONBOARDING.md';
}
if (key == 'telemetry/gitignore') {
return 'telemetry/.gitignore';
}
if (key == 'telemetry/log-sh') {
return 'telemetry/log.sh';
}
if (key == 'config/mcp-json') {
return 'mcp.json';
}
return '${key.replaceAll('rules/', 'rules/')}.mdc';
}
@@ -149,6 +230,56 @@ class Renderer {
return names[raw] ?? raw;
}
static String _gitRefsBlock(ProjectBrief brief) {
if (brief.referenceRepos.isEmpty) {
return '_No Git repository URLs listed._ Add entries under `references.repos` in project-brief.yaml when other repos are part of the product context.';
}
return brief.referenceRepos.map((u) => '- $u').join('\n');
}
static String _localPathsBlock(ProjectBrief brief) {
if (brief.localPaths.isEmpty) {
return '_No local paths listed._ Add monorepo packages or sibling folders under `references.local_paths` in project-brief.yaml when relevant.';
}
return brief.localPaths.map((path) => '- `$path`').join('\n');
}
static String _themeSummary(ProjectBrief brief) {
if (brief.themeVariants.isEmpty) {
return '*(none specified — defaults not applied in brief; check YAML)*';
}
return brief.themeVariants.map(_themeLabel).join(', ');
}
static String _themeLabel(String token) {
switch (token) {
case 'high_contrast':
return 'high contrast';
default:
return token;
}
}
static String _rolesSummary(ProjectBrief brief) {
if (!brief.rolesEnabled) {
return 'Not enabled (`app_context.roles_enabled: false`).';
}
if (brief.roleNames.isEmpty) {
return 'Enabled but **no role names** — add `app_context.role_names` in project-brief.yaml.';
}
return brief.roleNames.join(', ');
}
static String _highContrastNote(ProjectBrief brief) {
if (!brief.themeVariants.contains('high_contrast')) return '';
return '\n- **High contrast:** validate contrast, borders, and focus in the high-contrast theme alongside light/dark (WCAG).\n';
}
static String _highContrastUxLine(ProjectBrief brief) {
if (!brief.themeVariants.contains('high_contrast')) return '';
return '\n- **High contrast theme:** validate loading, empty, and error states; never rely on color alone for meaning (use icons/text/semantics).';
}
static String _archImportRules(String arch) {
switch (arch) {
case 'clean':
@@ -8,6 +8,9 @@ class Resolver {
static List<String> resolve(ProjectBrief brief) {
final files = <String>[];
// ── Meta: rule authoring (first — governs other .mdc files) ─────────
files.add('rules/universal/rule-authoring');
// ── Universal (every project) ──────────────────────────────────────
files.addAll([
'rules/universal/flutter-core',
@@ -15,6 +18,11 @@ class Resolver {
'rules/universal/project-context',
]);
// ── Theming tokens (when high contrast or extra variants) ───────────
if (_emitThemingRule(brief)) {
files.add('rules/theming/theming');
}
// ── Security (Pillar 5: always-on, not just for large/auth projects) ──
files.add('rules/security/security-standards');
@@ -28,11 +36,19 @@ class Resolver {
files.add('rules/architecture/${brief.architecture}');
// ── Backend — one or more ─────────────────────────────────────────
for (final b in brief.backends) files.add('rules/backend/$b');
for (final b in brief.backends) {
files.add('rules/backend/$b');
}
if (brief.specialFeatures.contains('realtime')) {
files.add('rules/backend/realtime');
}
// ── Push / deep linking ───────────────────────────────────────────
if (brief.specialFeatures.contains('push_notifications') ||
brief.specialFeatures.contains('deep_linking')) {
files.add('rules/integrations/push-deeplink');
}
// ── Routing — exactly one ─────────────────────────────────────────
files.add('rules/routing/${brief.routing}');
@@ -52,20 +68,47 @@ class Resolver {
files.add('rules/codegen/codegen-$tool');
}
// ── Hooks (Pillar 4) — tied to codegen, not state_management ─────
if (brief.codegenTools.isNotEmpty) {
files.addAll([
'hooks/hooks-json',
'hooks/flutter-analyze',
'hooks/grind-tests',
'hooks/arch-guard',
]);
}
// ── Localization ──────────────────────────────────────────────────
if (brief.i18nEnabled) {
files.add('rules/i18n/localization');
}
// ── CI/CD + flavours ─────────────────────────────────────────────
if (brief.cicd != 'none' || brief.flavors.length > 1) {
files.add('rules/cicd/cicd');
}
// ── Feature-scoped rule stubs ─────────────────────────────────────
final featureKeys = <String>{};
for (final m in brief.featureModules) {
final k = featureRuleKey(m);
if (k != null) featureKeys.add(k);
}
files.addAll(featureKeys);
// ── Skills ────────────────────────────────────────────────────────
files.addAll([
'skills/scaffold-feature',
'skills/scaffold-screen',
'skills/generate-tests',
'skills/build',
'skills/debug-issue',
'skills/verify-change',
'skills/explain-code',
]);
if (brief.apiDocsFormat != 'none') files.add('skills/generate-api-client');
if (brief.flavors.length > 1) files.add('skills/create-flavor');
if (brief.cicd.isNotEmpty) files.add('skills/deploy');
if (brief.cicd != 'none') files.add('skills/deploy');
// ── Agents ────────────────────────────────────────────────────────
files.addAll([
@@ -83,17 +126,51 @@ class Resolver {
files.add('agents/migration-agent');
}
// ── Hooks ─────────────────────────────────────────────────────────
// ── Cursor workspace extras (reference architecture) ────────────────
files.addAll([
'hooks/hooks-json',
'hooks/flutter-analyze',
'hooks/grind-tests',
'hooks/arch-guard',
'root/.cursorignore',
'root/tool/cursor_audit.sh',
'onboarding/ONBOARDING',
'commands/build',
'commands/debug-issue',
'commands/verify-change',
'commands/explain-code',
]);
if (brief.mcpConfigEnabled) {
files.add('config/mcp-json');
}
if (brief.telemetryOptIn) {
files.addAll([
'telemetry/gitignore',
'telemetry/log-sh',
'rules/telemetry/usage-logging',
]);
}
files.addAll(['root/AGENTS.md', 'root/lefthook.yaml']);
return files;
}
/// `lib/features/<slug>/` ↔ `rules/features/<slug>.mdc`
static String? featureRuleKey(String module) {
var s = module
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), '_');
s = s.replaceAll(RegExp(r'_+'), '_');
s = s.replaceAll(RegExp(r'(^_+|_+$)'), '');
if (s.isEmpty) return null;
return 'rules/features/$s';
}
static bool _emitThemingRule(ProjectBrief brief) {
return brief.themeVariants.contains('high_contrast') ||
brief.themeVariants.length > 2;
}
/// Returns human-readable description of why each file was included
static Map<String, String> resolveWithReasons(ProjectBrief brief) {
final resolved = resolve(brief);
@@ -105,21 +182,100 @@ class Resolver {
}
static String _reason(String key, ProjectBrief brief) {
if (key == 'rules/universal/rule-authoring') {
return 'Always included — meta rules for authoring .cursor/rules';
}
if (key.contains('universal')) return 'Always included';
if (key.contains('security')) return 'Always included — Pillar 5';
if (key == 'rules/theming/theming') {
return 'Theme variants include high contrast or extended set';
}
if (key.contains('security')) {
return 'Always included — Pillar 5';
}
if (key.contains('error-handling')) return 'Always included';
if (key.contains('state-management')) return 'Matches stack.state_management: ${brief.stateManagement}';
if (key.contains('architecture')) return 'Matches stack.architecture: ${brief.architecture}';
if (key.contains('routing')) return 'Matches stack.routing: ${brief.routing}';
if (key.contains('testing-e2e')) return 'testing.depth includes e2e';
if (key.contains('testing')) return 'Matches state_management testing patterns';
if (key.contains('platform')) return 'Matches stack.platforms';
if (key.contains('codegen')) return 'Matches stack.codegen';
if (key.contains('i18n')) return 'localization.enabled: true';
if (key.contains('migration')) return 'state_management is GetX — migration guidance included';
if (key.contains('security-agent')) return 'scale: ${brief.scale} or auth is configured';
if (key.contains('api-client')) return 'api_docs.format is set';
if (key.contains('realtime')) return 'features.special contains realtime';
if (key.contains('state-management')) {
return 'Matches stack.state_management: ${brief.stateManagement}';
}
if (key.contains('architecture')) {
return 'Matches stack.architecture: ${brief.architecture}';
}
if (key == 'rules/integrations/push-deeplink') {
return 'features.special includes push_notifications or deep_linking';
}
if (key.contains('routing')) {
return 'Matches stack.routing: ${brief.routing}';
}
if (key.contains('testing-e2e')) {
return 'testing.depth includes e2e';
}
if (key.contains('testing')) {
return 'Matches state_management testing patterns';
}
if (key.contains('platform')) {
return 'Matches stack.platforms';
}
if (key.startsWith('hooks/')) {
return 'stack.codegen non-empty — Cursor hooks for analyze, boundaries, and tests';
}
if (key.contains('codegen')) {
return 'Matches stack.codegen';
}
if (key.contains('i18n')) {
return 'localization.enabled: true';
}
if (key == 'rules/cicd/cicd') {
return 'environments.cicd is set or multiple flavors';
}
if (key.startsWith('rules/features/')) {
return 'Listed under features.modules in project-brief.yaml';
}
if (key.contains('migration')) {
return 'state_management is GetX — migration guidance included';
}
if (key.contains('security-agent')) {
return 'scale: ${brief.scale} or auth is configured';
}
if (key == 'skills/build') {
return 'Always included — universal TDD-first feature implementation command';
}
if (key == 'skills/debug-issue') {
return 'Always included — structured bug triage and evidence-first debugging';
}
if (key == 'skills/verify-change') {
return 'Always included — pre-PR verification checklist without full /build lifecycle';
}
if (key == 'skills/explain-code') {
return 'Always included — explain-only walkthrough of code paths and stack behavior';
}
if (key.contains('api-client')) {
return 'api_docs.format is set';
}
if (key.contains('realtime')) {
return 'features.special contains realtime';
}
if (key == 'root/.cursorignore') {
return 'Root ignore patterns for Cursor indexing';
}
if (key == 'root/tool/cursor_audit.sh') {
return 'Maintenance script for rule drift checks';
}
if (key == 'onboarding/ONBOARDING') {
return 'Team onboarding for Cursor layout and slash skills';
}
if (key.startsWith('commands/')) {
return 'Project slash command → skill mapping';
}
if (key == 'config/mcp-json') {
return 'integrations.mcp.enabled: true in project-brief.yaml';
}
if (key == 'telemetry/gitignore' ||
key == 'telemetry/log-sh' ||
key == 'rules/telemetry/usage-logging') {
return 'telemetry_opt_in: true — local usage logging helpers';
}
if (key.startsWith('root/')) {
return 'Repo-level companion files';
}
return 'Included';
}
}
@@ -5,8 +5,6 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import 'logger.dart';
const _telemetryFile = '.cursor/telemetry.json';
class Telemetry {
/// Record a generation event (local-only, opt-in)
static Future<void> record({
@@ -48,7 +46,8 @@ class Telemetry {
static Future<void> printReport({required String outputDir}) async {
final file = File(p.join(outputDir, 'telemetry.json'));
if (!file.existsSync()) {
Logger.warn('No telemetry data found. Ensure telemetry_opt_in: true in project-brief.yaml');
Logger.warn(
'No telemetry data found. Ensure telemetry_opt_in: true in project-brief.yaml');
return;
}
final data = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
@@ -65,15 +64,21 @@ class Telemetry {
final ruleCount = templates.where((t) => t.startsWith('rules/')).length;
final agentCount = templates.where((t) => t.startsWith('agents/')).length;
final skillCount = templates.where((t) => t.startsWith('skills/')).length;
Logger.dim(' Rules: $ruleCount | Agents: $agentCount | Skills: $skillCount');
Logger.dim(
' Rules: $ruleCount | Agents: $agentCount | Skills: $skillCount');
}
Logger.info('\n💡 Quarterly template quality review checklist:');
Logger.dim(' □ Review AI code review comments — which rules are most violated?');
Logger.dim('Ask teams: which agents are actually consulted vs. ignored?');
Logger.dim(' □ Check if generated rules reduced hallucinations vs. last quarter');
Logger.dim('Identify brief combinations that produce the most AI errors');
Logger.dim(' □ Update templates based on feedback, bump version in CHANGELOG.md');
Logger.dim(
'Review AI code review comments — which rules are most violated?');
Logger.dim(
'Ask teams: which agents are actually consulted vs. ignored?');
Logger.dim(
' □ Check if generated rules reduced hallucinations vs. last quarter');
Logger.dim(
' □ Identify brief combinations that produce the most AI errors');
Logger.dim(
' □ Update templates based on feedback, bump version in CHANGELOG.md');
}
static Map<String, dynamic> _emptyData(String projectName) => {
@@ -81,6 +86,7 @@ class Telemetry {
'totalGenerations': 0,
'lastGeneratedAt': '',
'generations': [],
'note': 'Local-only telemetry. Never sent anywhere. opt-in via telemetry_opt_in: true.',
'note':
'Local-only telemetry. Never sent anywhere. opt-in via telemetry_opt_in: true.',
};
}
@@ -5,24 +5,60 @@ import 'package:yaml/yaml.dart';
import 'models.dart';
class Validator {
static const _validStateManagement = {'bloc', 'riverpod', 'getx', 'hooks_riverpod'};
static const _validArchitectures = {'clean', 'feature_first', 'mvvm', 'mvc', 'layered'};
static const _validStateManagement = {
'bloc',
'riverpod',
'getx',
'hooks_riverpod'
};
static const _validArchitectures = {
'clean',
'feature_first',
'mvvm',
'mvc',
'layered'
};
static const _validRouting = {'gorouter', 'getx_nav', 'auto_route'};
static const _validBackends = {'firebase', 'supabase', 'rest'};
static const _validAuth = {'firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'};
static const _validAuth = {
'firebase_auth',
'supabase_auth',
'jwt_rest',
'oauth2',
'none'
};
static const _validPlatforms = {'ios', 'android', 'web', 'desktop'};
static const _validCodegen = {'freezed', 'json_serializable', 'injectable', 'retrofit'};
static const _validCodegen = {
'freezed',
'json_serializable',
'injectable',
'retrofit'
};
static const _validScale = {'small', 'medium', 'large'};
static const _validTestingDepth = {'unit_widget', 'integration', 'e2e', 'full'};
static const _validTestingDepth = {
'unit_widget',
'integration',
'e2e',
'full'
};
static const _validCicd = {'codemagic', 'github_actions', 'fastlane', 'none'};
static const _validE2eTools = {'patrol', 'maestro'};
static const _validDesignSource = {'figma_mcp', 'figma_manual', 'native_ref', 'html_ref', 'none'};
static const _validDesignSource = {
'figma_mcp',
'figma_manual',
'native_ref',
'html_ref',
'none'
};
static const _validApiFormats = {'openapi', 'postman', 'markdown', 'none'};
static const _validThemeVariants = {'light', 'dark', 'high_contrast'};
static const _validMcpPresets = {'auto', 'minimal'};
static Future<ValidationResult> validateFile(String path) async {
final file = File(path);
if (!file.existsSync()) {
return ValidationResult(isValid: false, errors: ['File not found: $path']);
return ValidationResult(
isValid: false, errors: ['File not found: $path']);
}
final content = await file.readAsString();
final yaml = loadYaml(content) as YamlMap;
@@ -39,9 +75,15 @@ class Validator {
final project = yaml['project'] as YamlMap?;
final stack = yaml['stack'] as YamlMap?;
final envs = yaml['environments'] as YamlMap?;
final testing = yaml['testing'] as YamlMap?;
final design = yaml['design'] as YamlMap?;
final apiDocs = yaml['api_docs'] as YamlMap?;
final appCtx = yaml['app_context'] as YamlMap?;
if (project == null) { errors.add('Missing required section: project'); }
else {
if (project == null) {
errors.add('Missing required section: project');
} else {
if (project['name'] == null) errors.add('project.name is required');
if (project['package'] == null) errors.add('project.package is required');
if (project['scale'] != null && !_validScale.contains(project['scale'])) {
@@ -49,8 +91,9 @@ class Validator {
}
}
if (stack == null) { errors.add('Missing required section: stack'); }
else {
if (stack == null) {
errors.add('Missing required section: stack');
} else {
_validateField(stack, 'state_management', _validStateManagement, errors);
_validateField(stack, 'architecture', _validArchitectures, errors);
_validateField(stack, 'routing', _validRouting, errors);
@@ -61,7 +104,8 @@ class Validator {
if (platforms != null && platforms is YamlList) {
for (final p in platforms) {
if (!_validPlatforms.contains(p.toString())) {
errors.add('stack.platforms contains unknown value: $p. Valid: ${_validPlatforms.join(", ")}');
errors.add(
'stack.platforms contains unknown value: $p. Valid: ${_validPlatforms.join(", ")}');
}
}
}
@@ -71,7 +115,8 @@ class Validator {
if (codegen != null && codegen is YamlList) {
for (final c in codegen) {
if (!_validCodegen.contains(c.toString())) {
warnings.add('stack.codegen contains unknown value: $c. Known: ${_validCodegen.join(", ")}');
warnings.add(
'stack.codegen contains unknown value: $c. Known: ${_validCodegen.join(", ")}');
}
}
}
@@ -79,13 +124,70 @@ class Validator {
// Cross-validation: GetX nav requires GetX SM
if (stack['routing']?.toString() == 'getx_nav' &&
stack['state_management']?.toString() != 'getx') {
warnings.add('stack.routing is getx_nav but state_management is not getx. This is unusual.');
warnings.add(
'stack.routing is getx_nav but state_management is not getx. This is unusual.');
}
// Cross-validation: web platform warnings
final platformsList = (stack['platforms'] as YamlList?)?.map((e) => e.toString()).toList() ?? [];
final platformsList = (stack['platforms'] as YamlList?)
?.map((e) => e.toString())
.toList() ??
[];
if (platformsList.contains('web')) {
warnings.add('Web platform detected: ensure you avoid dart:io imports (use dart:html or flutter_web_plugins)');
warnings.add(
'Web platform detected: ensure you avoid dart:io imports (use dart:html or flutter_web_plugins)');
}
}
if (envs != null) {
_validateField(envs, 'cicd', _validCicd, warnings,
prefix: 'environments');
}
if (testing != null) {
_validateField(testing, 'depth', _validTestingDepth, errors,
prefix: 'testing');
_validateField(testing, 'e2e_tool', _validE2eTools, warnings,
prefix: 'testing');
}
if (design != null) {
_validateField(design, 'source', _validDesignSource, warnings,
prefix: 'design');
}
if (apiDocs != null) {
_validateField(apiDocs, 'format', _validApiFormats, warnings,
prefix: 'api_docs');
}
if (appCtx != null) {
final themes = appCtx['theme_variants'];
for (final t in _yamlStrings(themes)) {
if (!_validThemeVariants.contains(t)) {
warnings.add(
'app_context.theme_variants contains unknown value: $t (expected: ${_validThemeVariants.join(", ")})');
}
}
if (appCtx['roles_enabled'] == true) {
final names = appCtx['role_names'];
final list = names is YamlList
? names
: names is List
? names
: const [];
if (list.isEmpty) {
warnings.add(
'app_context.roles_enabled is true but role_names is empty');
}
}
}
final integrations = yaml['integrations'] as YamlMap?;
if (integrations != null) {
final mcp = integrations['mcp'] as YamlMap?;
if (mcp != null) {
final preset = mcp['preset']?.toString();
if (preset != null && !_validMcpPresets.contains(preset)) {
warnings.add(
'integrations.mcp.preset "$preset" is not valid. Use: ${_validMcpPresets.join(", ")}');
}
}
}
@@ -103,26 +205,55 @@ class Validator {
if (brief.projectName.isEmpty) errors.add('project.name is required');
if (brief.packageId.isEmpty) errors.add('project.package is required');
if (!_validStateManagement.contains(brief.stateManagement)) {
errors.add('stack.state_management "${brief.stateManagement}" is not valid. Use: ${_validStateManagement.join(", ")}');
errors.add(
'stack.state_management "${brief.stateManagement}" is not valid. Use: ${_validStateManagement.join(", ")}');
}
if (!_validArchitectures.contains(brief.architecture)) {
errors.add('stack.architecture "${brief.architecture}" is not valid. Use: ${_validArchitectures.join(", ")}');
errors.add(
'stack.architecture "${brief.architecture}" is not valid. Use: ${_validArchitectures.join(", ")}');
}
for (final b in brief.backends) {
if (!_validBackends.contains(b)) {
errors.add('stack.backend contains "$b". Valid values: ${_validBackends.join(", ")}');
errors.add(
'stack.backend contains "$b". Valid values: ${_validBackends.join(", ")}');
}
}
for (final t in brief.themeVariants) {
if (!_validThemeVariants.contains(t)) {
warnings.add(
'app_context.theme_variants contains unknown value: $t (expected: ${_validThemeVariants.join(", ")})');
}
}
if (brief.rolesEnabled && brief.roleNames.isEmpty) {
warnings.add('roles_enabled is true but role_names is empty');
}
if (!_validMcpPresets.contains(brief.mcpPreset)) {
warnings.add(
'integrations.mcp.preset "${brief.mcpPreset}" is not valid. Use: ${_validMcpPresets.join(", ")}');
}
return ValidationResult(isValid: errors.isEmpty, errors: errors, warnings: warnings);
return ValidationResult(
isValid: errors.isEmpty, errors: errors, warnings: warnings);
}
static Iterable<String> _yamlStrings(dynamic value) sync* {
if (value == null) return;
if (value is YamlList) {
for (final e in value) yield e.toString();
} else if (value is List) {
for (final e in value) yield e.toString();
} else {
yield value.toString();
}
}
static void _validateField(
YamlMap map, String field, Set<String> valid, List<String> output,
) {
{String prefix = 'stack'}) {
final val = map[field]?.toString();
if (val != null && !valid.contains(val)) {
output.add('stack.$field "$val" is not valid. Use: ${valid.join(", ")}');
output
.add('$prefix.$field "$val" is not valid. Use: ${valid.join(", ")}');
}
}
}
@@ -8,17 +8,19 @@ import 'models.dart';
import 'logger.dart';
const _lockFileName = '.cursor-gen-lock.json';
const _currentVersion = '1.0.0';
/// Current flutter-cursor-templates bundle version (lock file, wizard, --check-updates).
const kCursorTemplatesVersion = '1.0.5';
class VersionManager {
/// Check if the project's locked version differs from the current template version
static Future<VersionStatus> check({required ProjectBrief brief}) async {
final locked = brief.cursorTemplatesVersion ?? 'unset';
final hasUpdate = locked != _currentVersion && locked != 'unset';
final hasUpdate = locked != kCursorTemplatesVersion && locked != 'unset';
return VersionStatus(
hasUpdate: hasUpdate,
currentVersion: locked,
latestVersion: _currentVersion,
latestVersion: kCursorTemplatesVersion,
);
}
@@ -30,7 +32,7 @@ class VersionManager {
}) async {
final briefHash = await _fileHash(briefPath);
final lock = {
'templateVersion': _currentVersion,
'templateVersion': kCursorTemplatesVersion,
'generatedAt': DateTime.now().toIso8601String(),
'briefHash': briefHash,
'projectName': brief.projectName,
@@ -43,7 +45,7 @@ class VersionManager {
'codegenTools': brief.codegenTools,
},
'note': 'Auto-generated by cursor_gen. Do not edit manually. '
'Run: dart run cursor_gen --check-updates to see available updates.',
'Run: cursor_gen --check-updates to see available updates.',
};
final file = File(p.join(outputDir, _lockFileName));
await file.parent.create(recursive: true);
@@ -66,23 +68,25 @@ class VersionManager {
}
final lock = await readLock('.cursor');
if (lock == null) {
Logger.warn('No lock file found. Run: dart run cursor_gen first.');
Logger.warn('No lock file found. Run: cursor_gen first.');
return;
}
final lockedVersion = lock['templateVersion'] as String? ?? 'unknown';
Logger.info('Template version check:');
Logger.info(' Locked: $lockedVersion');
Logger.info(' Latest: $_currentVersion');
Logger.info(' Latest: $kCursorTemplatesVersion');
if (lockedVersion == _currentVersion) {
if (lockedVersion == kCursorTemplatesVersion) {
Logger.success(' ✔ You are on the latest template version.');
} else {
Logger.warn(' ⚠ Update available!');
Logger.info('\nTo update:');
Logger.info(' 1. Update cursor_templates_version in project-brief.yaml to "$_currentVersion"');
Logger.info(' 2. Run: dart run cursor_gen --diff (preview changes)');
Logger.info(' 3. Run: dart run cursor_gen --refresh (apply updates)');
Logger.info('\nChangelog: see CHANGELOG.md in flutter-cursor-templates repo');
Logger.info(
' 1. Update cursor_templates_version in project-brief.yaml to "$kCursorTemplatesVersion"');
Logger.info(' 2. Run: cursor_gen --diff (preview changes)');
Logger.info(' 3. Run: cursor_gen --refresh (apply updates)');
Logger.info(
'\nChangelog: see CHANGELOG.md in flutter-cursor-templates repo');
}
}
@@ -2,62 +2,172 @@
import 'dart:io';
import 'logger.dart';
import 'version_manager.dart';
class Wizard {
static Future<void> run({required String outputPath}) async {
Logger.info('\n🧙 cursor_gen Interactive Wizard');
Logger.info('' * 42);
Logger.info('Answer the questions below to generate your project-brief.yaml.\n');
Logger.info(
'Answer the questions below to generate your project-brief.yaml.\n');
final answers = <String, dynamic>{};
// Project basics
answers['name'] = _ask('Project name', hint: 'ShopEasy');
answers['package'] = _ask('Package ID', hint: 'com.company.shopeasy');
answers['description'] = _ask('Short description', hint: 'E-commerce app with real-time inventory');
answers['scale'] = _askChoice('Project scale', ['small', 'medium', 'large'], defaultIdx: 1);
answers['description'] = _ask('Short description',
hint: 'E-commerce app with real-time inventory');
answers['scale'] = _askChoice('Project scale', ['small', 'medium', 'large'],
defaultIdx: 1);
Logger.info('');
// Stack
answers['state_management'] = _askChoice('State management',
['bloc', 'riverpod', 'getx', 'hooks_riverpod'], defaultIdx: 1);
answers['architecture'] = _askChoice('Architecture',
['clean', 'feature_first', 'mvvm', 'mvc', 'layered'], defaultIdx: 0);
answers['routing'] = _askChoice('Routing',
['gorouter', 'getx_nav', 'auto_route'], defaultIdx: 0);
answers['state_management'] = _askChoice(
'State management', ['bloc', 'riverpod', 'getx', 'hooks_riverpod'],
defaultIdx: 1);
answers['architecture'] = _askChoice(
'Architecture', ['clean', 'feature_first', 'mvvm', 'mvc', 'layered'],
defaultIdx: 0);
answers['routing'] = _askChoice(
'Routing', ['gorouter', 'getx_nav', 'auto_route'],
defaultIdx: 0);
answers['backend'] = _askChoice('Backend(s)',
['firebase', 'supabase', 'rest', 'firebase+rest', 'supabase+rest'], defaultIdx: 2);
['firebase', 'supabase', 'rest', 'firebase+rest', 'supabase+rest'],
defaultIdx: 2);
answers['auth'] = _askChoice('Auth method',
['firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'], defaultIdx: 4);
['firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'],
defaultIdx: 4);
Logger.info('');
// Platforms (Pillar 4)
answers['platforms'] = _askMultiChoice('Target platforms',
['ios', 'android', 'web', 'desktop'], defaults: [0, 1]);
answers['platforms'] = _askMultiChoice(
'Target platforms', ['ios', 'android', 'web', 'desktop'],
defaults: [0, 1]);
// Codegen (Pillar 4)
answers['codegen'] = _askMultiChoice('Code generation tools (optional)',
['freezed', 'json_serializable', 'injectable', 'retrofit'], defaults: []);
answers['codegen'] = _askMultiChoice('Code generation tools (optional)', [
'freezed',
'json_serializable',
'injectable',
'retrofit'
], defaults: []);
Logger.info('');
// References & app UX
final reposRaw =
_ask('Git reference repo URLs (comma-separated, optional)', hint: '');
answers['reference_repos'] = _splitComma(reposRaw);
final localRaw =
_ask('Local reference paths (comma-separated, optional)', hint: '');
answers['local_paths'] = _splitComma(localRaw);
answers['theme_variants'] = _askMultiChoice(
'Theme variants to support',
['light', 'dark', 'high_contrast'],
defaults: [0, 1]);
final rolesOn =
_askBool('Does the app support user roles?', defaultYes: false);
answers['roles_enabled'] = rolesOn;
if (rolesOn) {
final raw =
_ask('Role names (comma-separated)', hint: 'admin,member,guest');
answers['role_names'] = _splitComma(raw);
} else {
answers['role_names'] = <String>[];
}
Logger.info('');
// Features
final modulesRaw = _ask(
'Feature modules (comma-separated, optional)', hint: '');
answers['feature_modules'] = _splitComma(modulesRaw);
answers['special_features'] = _askMultiChoice(
'Special capabilities (optional)',
[
'realtime',
'push_notifications',
'deep_linking',
'offline_first',
],
defaults: []);
Logger.info('');
// Environments
final flavorsInput = _ask('Build flavors (comma-separated)', hint: 'dev,staging,prod');
final flavorsInput =
_ask('Build flavors (comma-separated)', hint: 'dev,staging,prod');
answers['flavors'] = flavorsInput.split(',').map((e) => e.trim()).toList();
answers['cicd'] = _askChoice('CI/CD', ['github_actions', 'codemagic', 'fastlane', 'none'], defaultIdx: 0);
answers['cicd'] = _askChoice(
'CI/CD', ['github_actions', 'codemagic', 'fastlane', 'none'],
defaultIdx: 0);
// Testing
answers['testing_depth'] = _askChoice('Testing depth',
['unit_widget', 'integration', 'e2e', 'full'], defaultIdx: 0);
answers['testing_depth'] = _askChoice(
'Testing depth', ['unit_widget', 'integration', 'e2e', 'full'],
defaultIdx: 0);
final testingDepth = answers['testing_depth'] as String;
if (testingDepth == 'e2e' || testingDepth == 'full') {
answers['e2e_tool'] = _askChoice(
'E2E tool', ['patrol', 'maestro'], defaultIdx: 0);
} else {
answers['e2e_tool'] = 'patrol';
}
Logger.info('');
// Design (validator-aligned sources)
answers['design_source'] = _askChoice('Design source', [
'figma_mcp',
'figma_manual',
'native_ref',
'html_ref',
'none',
], defaultIdx: 4);
final designSource = answers['design_source'] as String;
if (designSource == 'figma_mcp' || designSource == 'figma_manual') {
answers['figma_url'] =
_ask('Figma file URL', hint: 'https://www.figma.com/design/...');
} else {
answers['figma_url'] = '';
}
Logger.info('');
// API docs
answers['api_docs_format'] = _askChoice(
'API docs format', ['openapi', 'postman', 'markdown', 'none'],
defaultIdx: 3);
final apiFmt = answers['api_docs_format'] as String;
if (apiFmt != 'none') {
answers['api_docs_path'] =
_ask('Path to API spec (repo-relative)', hint: 'docs/api.yaml');
} else {
answers['api_docs_path'] = '';
}
Logger.info('');
// Localization
final l10n = _askBool('Enable localization / i18n?', defaultYes: false);
answers['i18n'] = l10n;
if (l10n) {
final locRaw =
_ask('Locale codes (comma-separated)', hint: 'en');
var locales = _splitComma(locRaw);
if (locales.isEmpty) locales = ['en'];
answers['locales'] = locales;
} else {
answers['locales'] = <String>['en'];
}
// Telemetry opt-in (Pillar 6)
final telemetry = _askBool('\nOpt in to local telemetry (rule trigger logging, stored locally)?', defaultYes: false);
final telemetry = _askBool(
'\nOpt in to local telemetry (rule trigger logging, stored locally)?',
defaultYes: false);
answers['telemetry'] = telemetry;
Logger.info('');
@@ -65,7 +175,7 @@ class Wizard {
final file = File(outputPath);
await file.writeAsString(yaml);
Logger.success('✔ project-brief.yaml written to $outputPath');
Logger.info('\nNext: dart run cursor_gen --validate → dart run cursor_gen');
Logger.info('\nNext: cursor_gen --validate → cursor_gen');
}
static String _ask(String label, {String hint = ''}) {
@@ -75,7 +185,8 @@ class Wizard {
return input.isEmpty ? hint : input;
}
static String _askChoice(String label, List<String> options, {int defaultIdx = 0}) {
static String _askChoice(String label, List<String> options,
{int defaultIdx = 0}) {
Logger.info(' $label:');
for (var i = 0; i < options.length; i++) {
final marker = i == defaultIdx ? '' : '';
@@ -85,11 +196,13 @@ class Wizard {
final input = stdin.readLineSync()?.trim() ?? '';
if (input.isEmpty) return options[defaultIdx];
final idx = int.tryParse(input);
if (idx != null && idx >= 1 && idx <= options.length) return options[idx - 1];
if (idx != null && idx >= 1 && idx <= options.length)
return options[idx - 1];
return options[defaultIdx];
}
static List<String> _askMultiChoice(String label, List<String> options, {List<int> defaults = const []}) {
static List<String> _askMultiChoice(String label, List<String> options,
{List<int> defaults = const []}) {
Logger.info(' $label (comma-separated numbers, or leave blank):');
for (var i = 0; i < options.length; i++) {
final marker = defaults.contains(i) ? '' : '';
@@ -99,7 +212,8 @@ class Wizard {
stdout.write(' Choices [$defaultStr]: ');
final input = stdin.readLineSync()?.trim() ?? '';
if (input.isEmpty) return defaults.map((d) => options[d]).toList();
return input.split(',')
return input
.split(',')
.map((s) => int.tryParse(s.trim()))
.where((i) => i != null && i >= 1 && i <= options.length)
.map((i) => options[i! - 1])
@@ -114,17 +228,40 @@ class Wizard {
return input == 'y' || input == 'yes';
}
static List<String> _splitComma(String s) => s
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
static String _yamlQStringList(List<String> xs) {
if (xs.isEmpty) return '';
return xs.map((e) => '"${_yamlEsc(e)}"').join(', ');
}
static String _yamlEsc(String s) =>
s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
static String _buildYaml(Map<String, dynamic> a) {
final platforms = (a['platforms'] as List).map((p) => '"$p"').join(', ');
final codegen = (a['codegen'] as List).map((c) => '"$c"').join(', ');
final flavors = (a['flavors'] as List).map((f) => '"$f"').join(', ');
final refRepos = List<String>.from(a['reference_repos'] as List);
final refLocals = List<String>.from(a['local_paths'] as List);
final themes = List<String>.from(a['theme_variants'] as List);
final roles = List<String>.from(a['role_names'] as List);
final featureMods = List<String>.from(a['feature_modules'] as List);
final specialFeats = List<String>.from(a['special_features'] as List);
final locales = List<String>.from(a['locales'] as List);
final figmaUrl = a['figma_url'] as String;
final apiPath = a['api_docs_path'] as String;
return '''# project-brief.yaml — cursor_gen configuration
# Generated by cursor_gen --wizard
# Run: dart run cursor_gen to generate .cursor/
# Run: dart run cursor_gen --refresh to update after changes
# Run: cursor_gen to generate .cursor/
# Run: cursor_gen --refresh to update after changes
# Pillar 1: Pin to template version for reproducibility
cursor_templates_version: "1.0.0"
cursor_templates_version: "$kCursorTemplatesVersion"
project:
name: "${a['name']}"
@@ -151,27 +288,32 @@ environments:
testing:
depth: "${a['testing_depth']}"
e2e_tool: "patrol"
e2e_tool: "${a['e2e_tool']}"
design:
source: "none"
figma_url: ""
source: "${a['design_source']}"
figma_url: "${_yamlEsc(figmaUrl)}"
api_docs:
format: "none"
path: ""
format: "${a['api_docs_format']}"
path: "${_yamlEsc(apiPath)}"
references:
repos: []
local_paths: []
repos: [${_yamlQStringList(refRepos)}]
local_paths: [${_yamlQStringList(refLocals)}]
app_context:
theme_variants: [${_yamlQStringList(themes)}]
roles_enabled: ${a['roles_enabled']}
role_names: [${_yamlQStringList(roles)}]
features:
modules: []
special: []
modules: [${_yamlQStringList(featureMods)}]
special: [${_yamlQStringList(specialFeats)}]
localization:
enabled: ${a['i18n']}
locales: ["en"]
locales: [${_yamlQStringList(locales)}]
# Pillar 6: Opt-in local telemetry (logs rule trigger frequency locally)
telemetry_opt_in: ${a['telemetry']}
@@ -0,0 +1,34 @@
---
name: api-client-gen
description: "Generates type-safe API clients for {{PROJECT_NAME}} from {{API_DOCS_FORMAT}} spec. Ask: '@api-client-gen generate client for /products endpoint'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, write_file, list_files]
---
You are an API client generator for **{{PROJECT_NAME}}**.
API docs: `{{API_DOCS_PATH}}` (format: {{API_DOCS_FORMAT}})
## Generation steps
1. Read the API spec at `{{API_DOCS_PATH}}`
2. For the requested endpoint(s), generate:
- Request DTO (`@JsonSerializable` or Freezed)
- Response DTO (`@JsonSerializable` or Freezed)
- Repository method with error handling
- Dio/Retrofit client method (if Retrofit in codegen)
## Output structure
```dart
// data/models/product_dto.dart
@freezed
class ProductDto with _$ProductDto {
factory ProductDto({...}) = _ProductDto;
factory ProductDto.fromJson(Map<String, dynamic> json) => _$ProductDtoFromJson(json);
}
// data/datasources/product_remote_datasource.dart
class ProductRemoteDataSource {
final Dio _dio;
Future<List<ProductDto>> getProducts() async { ... }
}
```
@@ -0,0 +1,51 @@
---
name: code-reviewer
description: "Reviews {{PROJECT_NAME}} code for {{STATE_MANAGEMENT}} patterns, {{ARCHITECTURE}} boundaries, and {{BACKEND}} usage. Ask: 'Review this code' or '@code-reviewer check PR'"
model: claude-opus-4-5
context: auto
allowed-tools: [read_file, list_files]
---
You are a senior Flutter engineer reviewing code for **{{PROJECT_NAME}}**.
## Your review checklist
### {{STATE_MANAGEMENT}} patterns
- Are state classes immutable and sealed?
- Is state management correctly separated from UI logic?
- Are streams/subscriptions properly cancelled in dispose()?
- Check for anti-patterns specific to {{STATE_MANAGEMENT}}
### {{ARCHITECTURE}} boundaries
{{ARCH_IMPORT_RULES}}
- Flag any violation of these import rules immediately
### {{BACKEND}} usage
- Are all exceptions caught and mapped to domain errors?
- Are streams vs futures used appropriately?
- Are connections/subscriptions disposed correctly?
### Security (always check)
- No hardcoded API keys or secrets
- No PII logged to console or crash reporters
- Sensitive data using flutter_secure_storage, not SharedPreferences
- All user inputs validated before sending to backend
### General Flutter
- `const` used where possible
- `dispose()` overridden for all controllers/subscriptions
- No `print()` in production paths
- Loading/empty/error states all handled
## Output format
For each issue found:
```
[SEVERITY: critical/major/minor] File:line — Issue description
WHY: Why this matters
FIX: Specific fix recommendation
```
Severity guide:
- **critical**: Security issue, data loss risk, crash potential
- **major**: Incorrect pattern, boundary violation, missing error handling
- **minor**: Style, naming, optimization opportunity
@@ -0,0 +1,42 @@
---
name: migration-agent
description: "Migrates GetX controllers to Riverpod Notifiers for {{PROJECT_NAME}}. Consult before adding any new GetX code — suggest Riverpod equivalent. Ask: '@migration-agent migrate [feature]'"
model: claude-opus-4-5
context: fork
allowed-tools: [read_file, write_file, list_files]
---
You are a Flutter migration specialist for **{{PROJECT_NAME}}**.
This project currently uses GetX ({{ARCHITECTURE}}). Your goal is incremental,
safe migration to Riverpod without breaking existing features.
## Migration mapping
| GetX | Riverpod equivalent |
|------|---------------------|
| `GetxController` with `.obs` | `AsyncNotifier` or `Notifier` |
| `Obx()` | `ref.watch()` in `ConsumerWidget` |
| `Get.find<Controller>()` | `ref.read(provider.notifier)` |
| `Get.toNamed()` | `context.go()` (after GoRouter migration) |
| `GetxController.onInit()` | `build()` method in `AsyncNotifier` |
| `GetxController.onClose()` | `ref.onDispose()` |
## Migration process (feature by feature)
1. **Read** the existing `GetxController` in full
2. **Write tests** for the existing GetX version first (if none exist)
3. **Map** `.obs` variables → state class fields
4. **Write** the new `AsyncNotifier` with equivalent logic
5. **Write tests** for the Riverpod version using `ProviderContainer`
6. **Migrate** the View: `GetView<C>` → `ConsumerWidget`, `Obx()` → `ref.watch()`
7. **Verify** all tests pass for both old and new
8. **Remove** GetX code from that feature
## When NOT to migrate (mark with TODO: MIGRATE-LATER)
- Controller shared across 5+ screens (high blast radius — plan separately)
- Feature ships in the next sprint (postpone — don't hold up a release)
- No tests exist AND you can't write them first (write GetX tests first)
## Output per migration
1. New Riverpod provider file
2. Updated ConsumerWidget screen file
3. Test file for the new provider
4. Diff showing what GetX code is removed
@@ -0,0 +1,47 @@
---
name: security-agent
description: "Deep security review for {{PROJECT_NAME}}. Consult for auth flows, payment screens, and sensitive data handling. Ask: '@security-agent review auth flow'"
model: claude-opus-4-5
context: fork
allowed-tools: [read_file, list_files]
---
You are a mobile security expert conducting a deep review for **{{PROJECT_NAME}}**.
> Note: This agent provides deep security analysis.
> The `security-standards.mdc` rule provides always-on enforcement.
> This agent is for detailed consultations on specific security concerns.
## Deep review focus areas
### Auth flow ({{AUTH}})
- Token storage: is `flutter_secure_storage` used for ALL tokens?
- Token refresh: is refresh handled atomically (no race condition)?
- Session expiry: does the app handle 401 gracefully without data loss?
- Certificate pinning: configured and tested?
### Data at rest
- SQLite/Hive encryption: sensitive DBs encrypted?
- Cache poisoning: cached API responses validated before use?
- Keychain/Keystore usage for cryptographic keys
### Network security
- All endpoints HTTPS — any http:// URLs?
- Certificate validation — any `badCertificateCallback: true`?
- Sensitive data in URL params/query strings?
- Request/response logging in production? (must be off)
### Code injection risks
- Dynamic code execution patterns
- WebView usage — JavaScript interface security
- Deep link parameter validation (no path traversal)
## Output format
For each finding:
```
[RISK: Critical/High/Medium/Low]
LOCATION: File / function
ISSUE: Detailed description
CVSS-like impact: Confidentiality/Integrity/Availability
REMEDIATION: Specific code fix
```
@@ -0,0 +1,33 @@
---
name: test-writer
description: "Writes {{STATE_MANAGEMENT}} unit tests for {{PROJECT_NAME}}. Ask: 'Write tests for [class]' or '@test-writer generate tests'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, write_file, list_files]
---
You are a Flutter test engineer for **{{PROJECT_NAME}}** using **{{STATE_MANAGEMENT}}**.
## Test pattern to follow
```dart
{{TEST_PATTERN}}
```
## When asked to write tests:
1. Read the source file completely
2. Identify all public methods and state transitions
3. Write tests for:
- Happy path (successful operation)
- Error path (failure, exception handling)
- Edge cases (empty data, boundary values)
4. Use `mocktail` for all mocking
5. Follow `Given/When/Then` naming: `'given X, when Y, then emits Z'`
## File placement
- Unit tests: `test/features/[feature]/[file]_test.dart`
- Widget tests: `test/features/[feature]/[screen]_widget_test.dart`
- Coverage target: 80% minimum for business logic classes
## Output
Write the complete test file, ready to run. Include all imports.
After writing, run: `dart test path/to/test_file.dart`
@@ -0,0 +1,47 @@
---
name: ui-validator
description: "Validates {{PROJECT_NAME}} UI against design system and UX standards. Ask: 'Validate this screen' or '@ui-validator check'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, list_files]
---
You are a UI/UX validator for **{{PROJECT_NAME}}**.
## Validate every screen for:
### State coverage ({{STATE_MANAGEMENT}})
- Loading state: shows shimmer (NOT spinner unless brief)
- Empty state: shows illustration + CTA (NOT blank screen)
- Error state: shows message + retry button (NOT toast-only)
- Data state: renders content correctly
### Accessibility
- All interactive widgets have semantic labels
- Minimum touch targets: 48×48dp
- Sufficient color contrast (4.5:1 minimum)
### Responsive layout
- No hardcoded pixel widths
- Tested at 375px and 414px viewport widths
- `SafeArea` used correctly on iOS
### Platform-specific ({{PLATFORMS_LIST}})
{{#if web}}
- No dart:io imports in web-targeted code
- PWA-compatible (no native-only APIs without fallbacks)
{{/if}}
{{#if ios}}
- Safe area respected (notch, Dynamic Island)
- iOS Human Interface Guidelines followed
{{/if}}
### Security (UI layer)
- No credentials shown in plaintext
- Sensitive screens wrapped with screenshot prevention
## Output
List each violation with:
- Location (file:widget)
- What's wrong
- How to fix it
@@ -0,0 +1 @@
End-to-end feature implementation (research, TDD, integration tests, verification). Follow the workflow and constraints in `@file:.cursor/skills/build/SKILL.md`. Use `project-brief.yaml` as the source of truth for stack and platforms.
@@ -0,0 +1 @@
Structured bug triage and evidence-first debugging. Follow `@file:.cursor/skills/debug-issue/SKILL.md`. Gather reproduction steps, logs, and failing commands before proposing fixes.
@@ -0,0 +1 @@
Explain-only walkthrough of code paths and stack behavior (no edits). Follow `@file:.cursor/skills/explain-code/SKILL.md`. Do not modify source files unless the user explicitly asks.
@@ -0,0 +1 @@
Pre-PR verification checklist (analyze, tests, hooks) without full /build lifecycle. Follow `@file:.cursor/skills/verify-change/SKILL.md` for the change in scope.
@@ -0,0 +1,141 @@
#!/usr/bin/env ts-node
// arch-guard.ts — Pre-commit hook: enforces {{ARCHITECTURE}} import boundaries
// Generated for {{PROJECT_NAME}} ({{ARCHITECTURE}} architecture)
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
interface ArchRule {
sourcePattern: RegExp;
forbiddenImports: RegExp[];
message: string;
}
// Architecture-specific rules for {{ARCHITECTURE}}
const ARCH_RULES: ArchRule[] = [
...getArchRules()
];
function getArchRules(): ArchRule[] {
const arch = '{{ARCH_RAW}}';
if (arch === 'clean') {
return [
{
sourcePattern: /features\/\w+\/domain\//,
forbiddenImports: [/features\/\w+\/data\//, /features\/\w+\/presentation\//],
message: 'domain/ must not import from data/ or presentation/',
},
{
sourcePattern: /features\/\w+\/presentation\//,
forbiddenImports: [/features\/\w+\/data\//],
message: 'presentation/ must not import from data/ directly (use domain interfaces)',
},
];
}
if (arch === 'feature_first') {
return [
{
// A feature file must not import from another feature
sourcePattern: /features\/(\w+)\//,
forbiddenImports: [], // checked dynamically below
message: 'Features must not import from other feature folders',
},
];
}
if (arch === 'mvvm') {
return [
{
sourcePattern: /viewmodels\//,
forbiddenImports: [/package:flutter\//, /widgets\//],
message: 'ViewModels must not import Flutter widgets — they must be plain Dart testable',
},
];
}
return [];
}
function getChangedDartFiles(): string[] {
try {
const result = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' });
return result.split('\n').filter(f => f.endsWith('.dart') && fs.existsSync(f));
} catch {
return [];
}
}
function checkFile(filePath: string): string[] {
const violations: string[] = [];
const content = fs.readFileSync(filePath, 'utf8');
const imports = content.match(/^import\s+'[^']+'/gm) ?? [];
for (const rule of ARCH_RULES) {
if (!rule.sourcePattern.test(filePath)) continue;
// Feature-first cross-feature check
if ('{{ARCH_RAW}}' === 'feature_first') {
const srcFeatureMatch = filePath.match(/features\/(\w+)\//);
if (srcFeatureMatch) {
const srcFeature = srcFeatureMatch[1];
for (const imp of imports) {
const impFeatureMatch = imp.match(/features\/(\w+)\//);
if (impFeatureMatch && impFeatureMatch[1] !== srcFeature) {
violations.push(
`${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` +
` ${YELLOW}${rule.message}${RESET}\n` +
` Import: ${imp}\n` +
` Fix: Move shared code to core/ or shared/`
);
}
}
}
continue;
}
// Standard forbidden import check
for (const imp of imports) {
for (const forbidden of rule.forbiddenImports) {
if (forbidden.test(imp)) {
violations.push(
`${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` +
` ${YELLOW}${rule.message}${RESET}\n` +
` Import: ${imp}`
);
}
}
}
}
return violations;
}
const changedFiles = getChangedDartFiles();
if (changedFiles.length === 0) {
console.log(`${GREEN}✔ arch-guard: no Dart files changed${RESET}`);
process.exit(0);
}
console.log(`🏛 arch-guard checking ${changedFiles.length} file(s) for {{ARCHITECTURE}} violations...`);
const allViolations: string[] = [];
for (const file of changedFiles) {
allViolations.push(...checkFile(file));
}
if (allViolations.length > 0) {
console.error(`\n${RED}✘ Architecture boundary violations detected:${RESET}\n`);
for (const v of allViolations) console.error(v + '\n');
console.error(`Total: ${allViolations.length} violation(s). Fix before committing.`);
process.exit(1);
}
console.log(`${GREEN}✔ arch-guard: no violations found${RESET}`);
@@ -0,0 +1,32 @@
#!/usr/bin/env ts-node
// flutter-analyze.ts — Pre-commit hook: runs dart analyze and dart format check
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
function run(cmd: string): { stdout: string; code: number } {
try {
const stdout = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
return { stdout, code: 0 };
} catch (e: any) {
return { stdout: e.stdout ?? e.message, code: e.status ?? 1 };
}
}
console.log('🔍 Running flutter analyze...');
const analyze = run('flutter analyze --no-congratulate');
if (analyze.code !== 0) {
console.error(`${RED}✘ flutter analyze failed:\n${analyze.stdout}${RESET}`);
process.exit(1);
}
console.log('🎨 Checking dart format...');
const format = run('dart format --output=none --set-exit-if-changed lib/ test/');
if (format.code !== 0) {
console.error(`${RED}✘ Unformatted files detected. Run: dart format lib/ test/${RESET}`);
process.exit(1);
}
console.log(`${GREEN}✔ flutter analyze + dart format passed${RESET}`);
@@ -0,0 +1,16 @@
#!/usr/bin/env ts-node
// grind-tests.ts — Pre-push hook: runs flutter test
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
console.log('🧪 Running flutter test...');
try {
execSync('flutter test --coverage', { stdio: 'inherit' });
console.log(`${GREEN}✔ All tests passed${RESET}`);
} catch {
console.error(`${RED}✘ Tests failed. Fix failing tests before pushing.${RESET}`);
process.exit(1);
}
@@ -0,0 +1,19 @@
{
"hooks": [
{
"name": "pre-commit: flutter analyze",
"command": "npx ts-node .cursor/hooks/flutter-analyze.ts",
"events": ["pre-commit"]
},
{
"name": "pre-commit: arch guard",
"command": "npx ts-node .cursor/hooks/arch-guard.ts",
"events": ["pre-commit"]
},
{
"name": "pre-push: run tests",
"command": "npx ts-node .cursor/hooks/grind-tests.ts",
"events": ["pre-push"]
}
]
}
@@ -0,0 +1,20 @@
# Cursor — {{PROJECT_NAME}}
## Quick start
1. Open this repo in **Cursor** so `.cursor/rules/` and `.cursor/skills/` load automatically.
2. Read **`rules/universal/rule-authoring.mdc`** — how we maintain AI rules.
3. Stack and product context live in **`project-brief.yaml`** at the repo root; regenerate `.cursor/` with `cursor_gen` after changing it.
4. **Slash skills** (primary workflows): open the Command Palette and use the project commands, or reference:
- `.cursor/skills/build/SKILL.md` — end-to-end feature implementation
- `.cursor/skills/debug-issue/SKILL.md` — structured debugging
- `.cursor/skills/verify-change/SKILL.md` — pre-PR verification
- `.cursor/skills/explain-code/SKILL.md` — explain-only walkthroughs
5. **Agents** (`.cursor/agents/*.mdc`) are reusable reviewer personas — attach when asking for code review or focused passes.
6. **Custom overrides**: files under `.cursor/custom/` and `CURSOR:CUSTOM` blocks in generated files are preserved on `cursor_gen --refresh` (see generator docs).
## Optional
- **`integrations.mcp.enabled`** in `project-brief.yaml` — generates `.cursor/mcp.json` with env-based server entries (no secrets in git).
- **`telemetry_opt_in: true`** — emits local telemetry helpers under `.cursor/telemetry/` (never commit usage logs if you enable logging).
- Run **`bash tool/cursor_audit.sh`** from the project root periodically to catch stale feature rules and overly broad `alwaysApply` usage.
@@ -0,0 +1,27 @@
# Build artefacts
build/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
*.g.dart
*.freezed.dart
*.gr.dart
*.config.dart
# Secrets
.env
.env.*
firebase_options.dart
google-services.json
GoogleService-Info.plist
# Large binary assets
assets/fonts/
assets/videos/
*.aab
*.apk
*.ipa
# IDE
.idea/
*.iml
@@ -0,0 +1,7 @@
# {{PROJECT_NAME}}
Repo-level notes for AI assistants. Authoritative stack and conventions are in `project-brief.yaml` and `.cursor/` (regenerate with `cursor_gen` from the project root).
- **Package:** `{{PACKAGE_ID}}`
- **Onboarding:** `.cursor/ONBOARDING.md` — layout, slash commands → skills, MCP opt-in, audits
- **Slash skills** (see `.cursor/skills/`): `/build`, `/debug`, `/verify`, `/explain` (see `.cursor/commands/*.md`)
@@ -0,0 +1,6 @@
# {{PROJECT_NAME}} — generated by cursor_gen; adjust commands to your repo
# Optional: run `bash tool/cursor_audit.sh` after changing features.modules or rule files.
pre-commit:
commands:
flutter-analyze:
run: flutter analyze
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# {{PROJECT_NAME}} — Cursor rule hygiene (generated by cursor_gen)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CURSOR="${ROOT}/.cursor"
RULES="${CURSOR}/rules"
echo "== cursor_audit (${ROOT}) =="
if [[ ! -d "$CURSOR" ]]; then
echo "ERROR: missing ${CURSOR}"
exit 1
fi
if [[ -d "$RULES" ]]; then
ac="$(grep -R "alwaysApply: true" "$RULES" --include='*.mdc' 2>/dev/null | wc -l | tr -d ' ')"
echo "alwaysApply: true occurrences in .cursor/rules: ${ac}"
echo " (expect a small number — meta/safety; prefer scoped globs for domain rules)"
else
echo "WARN: no ${RULES}"
fi
if [[ -d "$RULES/features" ]]; then
shopt -s nullglob
for f in "$RULES/features"/*.mdc; do
base="$(basename "$f" .mdc)"
if [[ ! -d "${ROOT}/lib/features/${base}" ]] && [[ ! -d "${ROOT}/lib/feature_${base}" ]]; then
echo "WARN: rules/features/${base}.mdc has no obvious lib/features/${base} folder — update features.modules or lib layout"
fi
done
shopt -u nullglob
fi
echo "OK — review warnings above after brief or folder renames"
@@ -0,0 +1,53 @@
---
description: "Clean Architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Clean Architecture — {{PROJECT_NAME}}
## Layer structure
```
lib/features/[feature]/
├── domain/
│ ├── entities/ ← Pure Dart, no framework imports
│ ├── repositories/ ← Abstract interfaces only
│ └── usecases/ ← Single-responsibility business operations
├── data/
│ ├── datasources/ ← Remote (API) + Local (cache) implementations
│ ├── models/ ← DTOs with fromJson/toJson (can use Freezed)
│ └── repositories/ ← Implements domain/repositories interfaces
└── presentation/
├── bloc/ or notifiers/
├── pages/
└── widgets/
```
## Import rules (STRICTLY ENFORCED by arch-guard hook)
{{ARCH_IMPORT_RULES}}
## UseCase pattern
```dart
// One UseCase = one operation = one method
class GetProductsUseCase {
final ProductRepository _repository;
const GetProductsUseCase(this._repository);
Future<Either<AppError, List<Product>>> call(ProductFilter filter) =>
_repository.getProducts(filter);
}
```
## Entity rules
- Entities are pure Dart — zero Flutter or framework imports
- Entities are immutable — use `final` fields + factory constructors
- Entities NEVER have `fromJson`/`toJson` — that belongs in the data layer model
## Repository rules
- Domain defines the **interface** (abstract class)
- Data layer **implements** it
- Use `Either<Failure, T>` or `Result<T>` return types — never throw in domain
## Dependency injection
- Use `injectable` + `get_it` if `codegen` includes `injectable`
- All UseCases injected into BLoC/Notifier via constructor
- `DataSource → Repository → UseCase → Bloc` dependency direction
@@ -0,0 +1,49 @@
---
description: "Feature-First architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Feature-First Architecture — {{PROJECT_NAME}}
## Folder structure
```
lib/
features/
auth/
auth_screen.dart
auth_provider.dart (or auth_bloc.dart)
auth_repository.dart
auth_model.dart
widgets/
login_form.dart
social_login_button.dart
home/
home_screen.dart
home_provider.dart
...
core/
di/ ← Dependency injection setup
network/ ← HTTP client, interceptors
storage/ ← Local storage abstraction
widgets/ ← Shared widgets used by 3+ features
theme/ ← App theme, colors, text styles
routing/ ← Router config
shared/
models/ ← Models shared across features
utils/ ← Pure utility functions
```
## Import rules (STRICTLY ENFORCED by arch-guard hook)
{{ARCH_IMPORT_RULES}}
## Feature isolation rules
- A feature folder is self-contained: its own screen, state, model, repository
- Features MUST NOT import from other feature folders
- Shared code MUST be extracted to `core/` or `shared/` before sharing
- The 3-feature rule: if 3+ features need the same widget → move it to `core/widgets/`
## File naming within a feature
- `[feature]_screen.dart` — the main screen widget
- `[feature]_provider.dart` — Riverpod providers (or `[feature]_bloc.dart`)
- `[feature]_repository.dart` — data fetching + caching
- `[feature]_model.dart` — data model (Freezed or plain Dart)
@@ -0,0 +1,17 @@
---
description: "Layered architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Layered Architecture — {{PROJECT_NAME}}
## Layers (top → bottom)
```
Presentation → Service/BLoC → Repository → Data Source
```
## Rules
- Dependencies flow downward only — upper layers depend on lower layers
- Each layer communicates via interfaces (abstract classes)
- Data transformations happen at layer boundaries (DTOs → domain models)
{{ARCH_IMPORT_RULES}}
@@ -0,0 +1,20 @@
---
description: "MVC architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# MVC Architecture — {{PROJECT_NAME}}
## Layer responsibilities
- **Model**: Data + business logic. Pure Dart.
- **View**: Renders UI, observes Controller state. No business logic.
- **Controller** (GetX): Connects Model ↔ View. Manages state transitions.
## Import rules
{{ARCH_IMPORT_RULES}}
## Controller rules
- Controllers are injected via `Binding`, never created in widgets
- One Controller per feature screen (not per widget)
- Controllers fetch data in `onInit()`, clean up in `onClose()`
- All reactive state marked with `.obs`
@@ -0,0 +1,37 @@
---
description: "MVVM architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# MVVM Architecture — {{PROJECT_NAME}}
## Layer responsibilities
- **View** (Widget): Renders UI, dispatches user actions to ViewModel. Zero business logic.
- **ViewModel**: Holds UI state, calls Model/Repository, exposes streams/notifiers. Zero Flutter imports.
- **Model**: Plain Dart data structures + repository interfaces.
## Import rules
{{ARCH_IMPORT_RULES}}
## ViewModel rules
```dart
// ViewModel has NO Flutter imports — testable with plain dart test
class AuthViewModel extends ChangeNotifier {
final AuthRepository _repository;
AuthViewModel(this._repository);
AuthViewState _state = const AuthViewState.initial();
AuthViewState get state => _state;
Future<void> login(String email, String password) async {
_state = const AuthViewState.loading();
notifyListeners();
final result = await _repository.login(email, password);
_state = result.fold(
(error) => AuthViewState.error(error.message),
(user) => AuthViewState.success(user),
);
notifyListeners();
}
}
```
@@ -0,0 +1,49 @@
---
description: "Firebase conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Firebase Standards — {{PROJECT_NAME}}
## Firestore
- Collection names: `camelCase` plural — `users`, `products`, `orderItems`
- Document IDs: use Firebase auto-IDs unless a natural key exists
- **Streams vs Futures**: use `snapshots()` for live data, `get()` for one-time reads
- Always handle `FirebaseException` explicitly — catch by `e.code` not generic `Exception`
- Paginate large collections with `startAfterDocument` — never fetch unbounded collections
```dart
// ✅ Stream-based real-time listener
Stream<List<Product>> watchProducts() {
return _firestore.collection('products')
.where('isActive', isEqualTo: true)
.snapshots()
.map((snap) => snap.docs.map(Product.fromDoc).toList());
}
// ✅ Handle FirebaseException by code
try {
await _firestore.collection('orders').add(order.toMap());
} on FirebaseException catch (e) {
switch (e.code) {
case 'permission-denied': throw AppError.authError('Insufficient permissions');
case 'unavailable': throw AppError.networkError();
default: throw AppError.unknown(e);
}
}
```
## Firebase Auth
- Always use `authStateChanges()` stream — never cache auth state locally
- Handle all error codes: `user-not-found`, `wrong-password`, `email-already-in-use`, `network-request-failed`
- Sign-out: clear all local state AND call `FirebaseAuth.instance.signOut()`
## Cloud Functions
- Call via `FirebaseFunctions.instance.httpsCallable('functionName')`
- Handle `FirebaseFunctionsException` with `.code` and `.message`
- Never expose internal errors to client — functions return structured error responses
## Security (complement to security-standards.mdc)
- Firestore Security Rules must be tested with the emulator before deploying
- No `allow read, write: if true` — even in development
- Rule coverage: every collection must have explicit rules
@@ -0,0 +1,25 @@
---
description: "Real-time feature conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Real-time Features — {{PROJECT_NAME}}
## Connection management
- Always expose a `connectionState` stream — UI must show "offline" indicator
- Implement exponential backoff for reconnection (1s, 2s, 4s, 8s, max 60s)
- Cancel all subscriptions in `dispose()` — memory leaks are the #1 bug in real-time apps
## Offline-first strategy
- Cache last known state locally (Hive, Drift, or Isar)
- Show stale data with a "last updated" timestamp while reconnecting
- Queue mutations offline, replay on reconnect (use `connectivity_plus`)
## WebSocket / SSE
- Use `web_socket_channel` for WebSocket — never raw `dart:io` WebSocket
- Implement heartbeat/ping to detect dead connections
- Parse and validate all incoming messages — never trust raw server data
## UI indicators
- Show a persistent banner when offline: "You're offline — changes will sync when reconnected"
- Animate the banner away on reconnection — don't just hide it abruptly
@@ -0,0 +1,52 @@
---
description: "REST API conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# REST API Standards — {{PROJECT_NAME}}
## HTTP client setup (Dio)
```dart
// In core/network/dio_client.dart
Dio createDioClient({required AppConfig config}) {
final dio = Dio(BaseOptions(
baseUrl: config.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
));
dio.interceptors.addAll([
AuthInterceptor(tokenStorage: getIt<TokenStorage>()),
LoggingInterceptor(), // debug builds only
RetryInterceptor(dio), // 3 retries on network errors
]);
return dio;
}
```
## DTOs (Data Transfer Objects)
- DTOs live in the `data/` layer — **never** pass raw JSON maps to the domain layer
- Use Freezed for DTOs if `codegen` includes `json_serializable` or `freezed`
- `fromJson` factory must handle null fields gracefully — API response fields are not guaranteed
## Error handling
```dart
// Map HTTP errors → AppError domain types
AppError _mapDioError(DioException e) => switch (e.type) {
DioExceptionType.connectionTimeout => const NetworkError(statusCode: null),
DioExceptionType.receiveTimeout => const NetworkError(statusCode: null),
DioExceptionType.badResponse => _mapStatusCode(e.response?.statusCode),
DioExceptionType.connectionError => const NetworkError(statusCode: null),
_ => UnknownError(e),
};
```
## API versioning
- Base URL includes version: `https://api.{{PROJECT_NAME}}.com/v1/`
- When upgrading API version, keep old version working until all clients migrate
## Auth token interceptor
- Inject `Authorization: Bearer <token>` automatically on every request
- On 401: refresh token once, retry original request, then logout if refresh fails
- On 403: map to `AppError.authError('Insufficient permissions')`
@@ -0,0 +1,52 @@
---
description: "Supabase conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Supabase Standards — {{PROJECT_NAME}}
## Row Level Security (RLS) awareness
- **ALWAYS** assume RLS is enabled — never write queries that assume full table access
- Test queries in the Supabase dashboard before implementing in Flutter
- If a query returns empty unexpectedly, check RLS policies first
## Queries
```dart
// ✅ Type-safe query with error handling
Future<List<Product>> getProducts() async {
final response = await _supabase
.from('products')
.select('id, name, price, category_id, categories(name)')
.eq('is_active', true)
.order('created_at', ascending: false)
.limit(50);
return response.map(Product.fromMap).toList();
}
```
## Realtime subscriptions
```dart
StreamSubscription<List<Map<String, dynamic>>>? _sub;
void watchOrders(String userId) {
_sub = _supabase
.from('orders')
.stream(primaryKey: ['id'])
.eq('user_id', userId)
.listen(
(data) => _updateOrders(data),
onError: (e) => _handleError(e),
);
}
@override
void dispose() {
_sub?.cancel(); // ALWAYS cancel in dispose()
super.dispose();
}
```
## Auth session
- Use `supabase.auth.onAuthStateChange` stream — never poll auth state
- Persist session: `supabase-flutter` handles this automatically via secure storage
- `session.accessToken` expires — check `session.isExpired` before sensitive operations
@@ -0,0 +1,29 @@
---
description: CI/CD, flavours, and quality gates for {{PROJECT_NAME}}
globs: [".github/**", "codemagic.yaml", "Makefile", "pubspec.yaml", "fastlane/**"]
alwaysApply: false
---
# CI/CD & flavours — {{PROJECT_NAME}}
## Context
Pipeline and flavour setup must stay aligned with how the app is built and released.
## Flavours
- Documented in `project-brief.yaml`: **{{FLAVORS_LIST}}**
- Use `--flavor` / `-t lib/main_<flavour>.dart` (or your projects entrypoints) consistently across local, CI, and store builds
- Configuration via `--dart-define` / `--dart-define-from-file` — **never** hardcode secrets in source
## Quality gates (recommended stages)
- **Lint:** `dart format --set-exit-if-changed .` and `flutter analyze`
- **Unit + widget:** `flutter test` (with coverage if the team tracks it)
- **Goldens:** fixed device/locale; CI fails on drift unless intentionally updated
- **Smoke build:** e.g. `flutter build apk` or `flutter build ios --no-codesign` for the primary flavour
- **E2E:** when `testing.depth` includes e2e — {{E2E_TOOL}} on CI devices or Firebase Test Lab
## Cursor integration
- After substantive edits: run analyze and relevant tests before PR
- For release-oriented tasks, use the **deploy** skill at `.cursor/skills/deploy/SKILL.md` when present
## CI/CD tool
- Selected in brief: **{{CICD_TOOL}}** (`{{CICD_RAW}}`)
@@ -0,0 +1,64 @@
---
description: "Freezed code generation conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Freezed Standards — {{PROJECT_NAME}}
## When to use Freezed
- All domain entities and data models — use `@freezed`
- Union types / sealed classes — use `@freezed` with multiple constructors
- BLoC states and events — use `@freezed`
## Model pattern
```dart
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
required double price,
@Default(0) int stockCount,
String? imageUrl,
}) = _Product;
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
}
```
## Union type pattern
```dart
@freezed
sealed class ProductState with _$ProductState {
const factory ProductState.initial() = ProductInitial;
const factory ProductState.loading() = ProductLoading;
const factory ProductState.loaded(List<Product> products) = ProductLoaded;
const factory ProductState.error(String message) = ProductError;
}
// Usage — exhaustive switch
final widget = state.when(
initial: () => const SizedBox.shrink(),
loading: () => const ProductShimmer(),
loaded: (products) => ProductList(products: products),
error: (msg) => ErrorWidget(message: msg),
);
```
## Critical rules
- **NEVER** edit `.freezed.dart` or `.g.dart` files — they are generated
- Run `dart run build_runner build --delete-conflicting-outputs` after changes
- Run `dart run build_runner watch` during development
- Add `*.freezed.dart` and `*.g.dart` to `.gitignore` (or commit them — team must decide)
- Always define `copyWith` via Freezed — never write manual `copyWith`
## Patterns to avoid
```dart
// ❌ Manual copyWith — replaced by Freezed
Product copyWith({String? name, double? price}) => Product(
id: id, name: name ?? this.name, price: price ?? this.price,
);
// ✅ Let Freezed generate it
product.copyWith(name: 'New Name', price: 9.99)
```
@@ -0,0 +1,42 @@
---
description: "Injectable dependency injection conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Injectable (get_it) Standards — {{PROJECT_NAME}}
## Setup
```dart
// lib/core/di/injection.dart
@InjectableInit()
void configureDependencies() => getIt.init();
```
## Annotations
```dart
@singleton // One instance for app lifetime
@lazySingleton // Created on first access (preferred for most services)
@injectable // New instance each time (use sparingly)
@factoryMethod // Custom factory logic
```
## Example
```dart
@lazySingleton
class ProductRepository {
final DioClient _client;
const ProductRepository(this._client); // Constructor injection
}
@injectable
class GetProductsUseCase {
final ProductRepository _repo;
const GetProductsUseCase(this._repo);
}
```
## Rules
- Run `dart run build_runner build` after adding/modifying `@injectable` annotations
- **NEVER** use `getIt<T>()` in widget `build()` methods — inject via constructor or provider
- Use `@module` for third-party registrations (Dio, SharedPreferences, etc.)
- Register environment-specific implementations with `@Environment('dev')` / `@Environment('prod')`
@@ -0,0 +1,37 @@
---
description: "json_serializable conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# json_serializable Standards — {{PROJECT_NAME}}
## Model annotation
```dart
@JsonSerializable(explicitToJson: true) // explicitToJson for nested objects
class ProductDto {
final String id;
final String name;
@JsonKey(name: 'unit_price') // snake_case API → camelCase Dart
final double unitPrice;
@JsonKey(defaultValue: false)
final bool isActive;
final DateTime createdAt; // auto-converted from ISO 8601 string
const ProductDto({
required this.id,
required this.name,
required this.unitPrice,
required this.isActive,
required this.createdAt,
});
factory ProductDto.fromJson(Map<String, dynamic> json) => _$ProductDtoFromJson(json);
Map<String, dynamic> toJson() => _$ProductDtoToJson(this);
}
```
## Critical rules
- **NEVER** edit `*.g.dart` files
- Use `@JsonKey(defaultValue: ...)` for nullable API fields — API contracts change
- Use `explicitToJson: true` whenever the model has nested objects
- Null safety: API fields not guaranteed to be non-null should be `String?` not `String`
@@ -0,0 +1,30 @@
---
description: "Retrofit (Dio) API client conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Retrofit Standards — {{PROJECT_NAME}}
## API client definition
```dart
@RestApi()
abstract class ProductApiClient {
factory ProductApiClient(Dio dio, {String? baseUrl}) = _ProductApiClient;
@GET('/products')
Future<List<ProductDto>> getProducts(@Query('category') String? category);
@GET('/products/{id}')
Future<ProductDto> getProduct(@Path('id') String id);
@POST('/products')
Future<ProductDto> createProduct(@Body() CreateProductDto dto);
}
```
## Rules
- **NEVER** edit `*.g.dart` files
- Run `dart run build_runner build` after modifying API client
- All DTOs used in Retrofit must have `fromJson`/`toJson` (via `json_serializable` or Freezed)
- Handle `DioException` in the repository layer — never let it reach the presentation layer
- Use `@Headers({'Content-Type': 'application/json'})` at class level, not per-method
@@ -0,0 +1,67 @@
---
description: "Global error handling strategy for {{PROJECT_NAME}} — always applied"
alwaysApply: true
---
# Global Error Handling — {{PROJECT_NAME}}
## Flutter error boundaries
Configure in `main.dart` — do this ONCE and never bypass it:
```dart
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
// TODO: Send to crash reporter (Sentry/Firebase Crashlytics)
crashReporter.recordFlutterError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
// TODO: Send to crash reporter
crashReporter.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
```
## Error types hierarchy
Define a sealed class for domain errors — never throw raw exceptions in business logic:
```dart
sealed class AppError {
const AppError();
}
class NetworkError extends AppError { final int? statusCode; const NetworkError({this.statusCode}); }
class AuthError extends AppError { final String reason; const AuthError(this.reason); }
class NotFoundError extends AppError { final String resource; const NotFoundError(this.resource); }
class UnknownError extends AppError { final Object cause; const UnknownError(this.cause); }
```
## Repository layer
- Wrap ALL external calls in try/catch and return `Either<AppError, T>` or `Result<T>`
- NEVER let raw exceptions bubble to the presentation layer
- Log at the repository layer, not the UI layer
## Presentation layer
- Every async widget MUST handle error state explicitly — no silent failures
- Show user-friendly error messages: map `AppError` subtype → readable string
- Provide a "Try again" action for recoverable errors (network, timeout)
- For fatal errors (auth expired), redirect to login — never show a dead screen
## Crash reporting
- Integrate Sentry or Firebase Crashlytics before first TestFlight / Play beta
- Set `user` context on crash reporter after login (id only, no PII)
- Add `breadcrumbs` for key user actions to aid reproduction
## Logging strategy
```
Level | Use case
DEBUG | Development only (strip from release)
INFO | Key user flows (login, purchase, etc.)
WARNING | Recoverable errors, fallbacks used
ERROR | Unrecoverable errors, unexpected states
```
- Use `logger` package — never bare `print()`
- Logger instance per class: `final _log = Logger('ClassName');`
@@ -0,0 +1,21 @@
---
description: "Feature module {{FEATURE_MODULE}} — contracts, boundaries, and ownership (fill after design)"
globs: ["lib/**/{{FEATURE_MODULE}}/**", "test/**/{{FEATURE_MODULE}}/**"]
alwaysApply: false
---
# Feature — {{FEATURE_MODULE_TITLE}}
## Context
This stub was generated from `features.modules` in `project-brief.yaml`. Use it to capture **public contracts** (routes, DTOs, events) and **dependencies** for `{{FEATURE_MODULE}}` so agents do not invent cross-feature wiring.
## Constraints
- List external dependencies (other features, packages, backend endpoints) explicitly
- Document invariants (auth required, idempotency, offline behavior) when known
- Update or delete this file when the module is removed or renamed — run `bash tool/cursor_audit.sh` to catch drift
## Patterns
- Link to key entry points: primary screen(s), state holder(s), repository interface(s)
## Anti-patterns
- Empty file left forever — either fill it or delete the module entry from the brief
@@ -0,0 +1,55 @@
---
description: "Localization / i18n conventions for {{PROJECT_NAME}}"
globs: ["lib/l10n/**", "lib/**/*.dart", "test/**/*.dart"]
alwaysApply: false
---
# Localization Standards — {{PROJECT_NAME}}
## Supported locales: {{LOCALES_LIST}}
## Setup
- Use Flutter's built-in `AppLocalizations` (generated from `.arb` files)
- ARB files: `lib/l10n/app_en.arb`, `lib/l10n/app_fr.arb`, etc.
- Never hardcode user-facing strings — always use `context.l10n.stringKey`
## AppLocalizations access
```dart
// In widgets:
final l10n = AppLocalizations.of(context)!;
Text(l10n.welcomeMessage) // ✅
// Extension for convenience:
extension LocalizationX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}
Text(context.l10n.welcomeMessage) // ✅
```
## ARB file format
```json
{
"welcomeMessage": "Welcome back, {name}!",
"@welcomeMessage": {
"description": "Shown on home screen after login",
"placeholders": {
"name": { "type": "String", "example": "Alice" }
}
},
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemCount": {
"placeholders": {
"count": { "type": "int" }
}
}
}
```
## Rules
- **NEVER** hardcode user-facing strings as string literals in widget files
- **Technical keys** (route paths, storage/analytics keys, JSON fields): one shared module (e.g. `lib/core/constants/app_strings.dart`) — no duplicated literals across features
- All new strings added to ALL locale ARB files simultaneously — broken translations break builds
- Use ICU message format for plurals and gendered strings
- Date/time formatting: use `intl` package `DateFormat` — never `date.toString()`
- Currency: use `NumberFormat.currency(locale: locale)` — never manual formatting
- RTL support: wrap with `Directionality` where needed; test with Arabic locale
@@ -0,0 +1,21 @@
---
description: Push notifications and deep linking for {{PROJECT_NAME}}
globs: ["lib/**/*.dart", "android/**", "ios/**", "test/**/*.dart"]
alwaysApply: false
---
# Push & deep linking — {{PROJECT_NAME}}
## Context
Special capabilities from `project-brief.yaml`: **{{SPECIAL_FEATURES}}**. Native and server configuration must stay consistent.
## Constraints
- **Push:** request permissions through the platform flow; handle denial gracefully; no silent failures on token registration
- **Routing:** deep links and notification taps MUST go through **{{ROUTING}}** (no ad-hoc `Navigator` stacks for inbound links)
- **Payloads:** map FCM/APNs/Supabase payloads to domain models in the data layer — no JSON parsing scattered in widgets
- **iOS:** exercise push on a **physical device** when possible (simulator limitations)
- **Android:** declare required permissions explicitly; target API behaviour for POST_NOTIFICATIONS where applicable
## Testing
- Unit-test payload → domain mapping and routing targets
- Integration/E2E: cold start, background, and foreground tap-to-open flows when `testing.depth` allows
@@ -0,0 +1,34 @@
---
description: "Android-specific conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Android Platform Standards — {{PROJECT_NAME}}
## Target SDK
- `compileSdkVersion` / `targetSdkVersion`: 34 (Android 14) minimum for new apps
- `minSdkVersion`: 21 (Android 5.0) unless brief specifies otherwise
- Update `android/app/build.gradle` when bumping target SDK
## Permissions
- Declare only needed permissions in `AndroidManifest.xml`
- Runtime permissions: use `permission_handler` — never skip rationale step
- Android 13+: granular media permissions (`READ_MEDIA_IMAGES` not `READ_EXTERNAL_STORAGE`)
- Android 14+: `FOREGROUND_SERVICE_TYPE` required for foreground services
## Adaptive icons
- Provide both foreground and background layers in `android/app/src/main/res/`
- Test on dark theme, coloured theme, and themed icons (Android 13+)
## Deep links / App Links
- Verify domain ownership: `.well-known/assetlinks.json` on your server
- Test with: `adb shell am start -a android.intent.action.VIEW -d "https://{{PROJECT_NAME}}.com/products/123"`
## ProGuard / R8
- Obfuscation rules in `android/app/proguard-rules.pro`
- Keep rules for: Dio, Freezed models, `@JsonKey` annotated classes
- Test release build thoroughly — obfuscation can break reflection-based code
## Notifications
- Create notification channels before showing any notification (Android 8+)
- Notification icons must be monochrome on Android 5+
@@ -0,0 +1,36 @@
---
description: "Flutter Desktop conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Flutter Desktop Standards — {{PROJECT_NAME}}
## Window management
- Use `window_manager` package for window title, size, minimize/maximize controls
- Set minimum window size to prevent unusable UI: `windowManager.setMinimumSize(const Size(800, 600))`
- Remember window position/size across sessions using `shared_preferences`
## Keyboard shortcuts
- All primary actions must have keyboard shortcuts
- Use `Shortcuts` widget + `Actions` widget for app-wide shortcuts
- Follow platform conventions: `Cmd+S` (macOS), `Ctrl+S` (Windows/Linux) for save
## Context menus
- Right-click context menus: use `ContextMenuRegion` widget
- Desktop users expect right-click everywhere
## Platform-specific behavior
```dart
if (Platform.isMacOS) {
// macOS: use mac_window_manager for traffic lights placement
} else if (Platform.isWindows) {
// Windows: custom title bar with min/max/close buttons
} else if (Platform.isLinux) {
// Linux: respect window manager decorations
}
```
## File system
- `path_provider` provides platform-appropriate directories
- `file_picker` for open/save dialogs — never hardcode paths
- Handle file permission errors gracefully (especially on macOS with sandboxing)
@@ -0,0 +1,35 @@
---
description: "iOS-specific conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# iOS Platform Standards — {{PROJECT_NAME}}
## Platform-specific imports
- Use `dart:io` checks: `if (Platform.isIOS)` for conditional code
- iOS-only plugins: declare in `pubspec.yaml` with platform filter
- **NEVER** call `dart:io` directly in shared code — use `platform_channel` or `universal_io`
## iOS-specific requirements
- Minimum deployment target: iOS 13.0 (or as specified in `ios/Podfile`)
- Privacy manifests: `ios/Runner/PrivacyInfo.xcprivacy` — required for App Store since 2024
- Required `Info.plist` keys before using:
- Camera: `NSCameraUsageDescription`
- Photo library: `NSPhotoLibraryUsageDescription`
- Location: `NSLocationWhenInUseUsageDescription`
- Notifications: handled via `permission_handler`
## Push notifications (iOS)
- Configure APNs certificates in Xcode signing & capabilities
- Request permission with `permission_handler` — show rationale screen first
- Handle foreground vs background vs terminated app states separately
- Test on a physical device — iOS simulator does not support push
## Safe area
- Always wrap root scaffold with `SafeArea` or use `MediaQuery.of(context).padding`
- Dynamic Island / notch: test on iPhone 14 Pro and iPhone 15 Pro simulators
## App Store compliance
- Screenshot: use `ScreenshotController` to exclude sensitive screens
- Sign in with Apple: required if any third-party social login is offered
- IPv6 compatibility required — no IPv4-only network code
@@ -0,0 +1,45 @@
---
description: "Flutter Web conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Flutter Web Standards — {{PROJECT_NAME}}
## Critical: No dart:io on web
- **NEVER** import `dart:io` in shared code — it crashes on web
- Use `dart:html` or `universal_io` for platform-specific I/O
- Use `path_provider` alternatives: `universal_html` for web file access
- Check: `kIsWeb` constant before any platform-specific code
```dart
// ✅ Platform-safe
import 'package:universal_io/io.dart';
// ❌ Crashes on web
import 'dart:io';
```
## Web rendering
- Choose renderer carefully:
- `canvaskit` — pixel-perfect, larger initial load, better for graphics
- `html` — smaller load, uses DOM elements, inconsistent rendering
- Set in `index.html`: `flutterWebRenderer: "canvaskit"`
## PWA requirements
- Update `web/manifest.json`: name, icons (192×192, 512×512), theme_color
- Service worker: configure for offline caching of app shell
- Test with Chrome DevTools → Lighthouse → PWA audit
## Web-specific rendering caveats
- `BackdropFilter` has limited support on `html` renderer
- `Canvas` operations differ between renderers — test both
- Text selection differs — use `SelectableText` not `Text` where appropriate
- Scrollbars appear automatically on web — style or hide with `ScrollbarTheme`
## URL strategy
- Use `usePathUrlStrategy()` in `main.dart` for clean URLs (no `#`)
- Configure server to redirect all paths to `index.html` (SPA routing)
## Performance
- Lazy-load routes — use `GoRouter` deferred loading
- Initial load budget: < 3MB (canvaskit) or < 1MB (html renderer)
@@ -0,0 +1,31 @@
---
description: "Auto Route conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Auto Route Standards — {{PROJECT_NAME}}
## Route definitions
```dart
@AutoRouterConfig()
class AppRouter extends $AppRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: HomeRoute.page, initial: true),
AutoRoute(page: ProductRoute.page, path: '/products/:id'),
AutoRoute(page: LoginRoute.page, guards: [AuthGuard]),
];
}
```
## Navigation
```dart
context.router.push(ProductRoute(id: product.id));
context.router.pop();
context.router.replace(HomeRoute());
```
## Rules
- Always use typed `Route` classes — never string paths
- Guards implement `AutoRouteGuard`
- **NEVER** use `Navigator.push` directly
@@ -0,0 +1,32 @@
---
description: "GetX Navigation conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# GetX Navigation — {{PROJECT_NAME}}
## Named routes
```dart
// app_pages.dart — central route definitions
abstract class AppPages {
static const initial = Routes.home;
static final routes = [
GetPage(name: Routes.home, page: () => const HomeView(), binding: HomeBinding()),
GetPage(name: Routes.product, page: () => const ProductView(), binding: ProductBinding()),
];
}
// Navigate — always use named routes
Get.toNamed(Routes.product, arguments: product); // push
Get.offAllNamed(Routes.home); // replace stack
Get.back(); // pop
```
## Bindings
- Every route has a `Binding` class that creates and injects dependencies
- **NEVER** use `Get.put()` in a widget — only in Bindings
- Use `Get.lazyPut()` for deferred creation
## Rules
- **NEVER** use `Navigator.push/pop`
- All route strings in `lib/core/routing/routes.dart` as constants
@@ -0,0 +1,61 @@
---
description: "GoRouter conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# GoRouter Standards — {{PROJECT_NAME}}
## Typed routes (mandatory)
```dart
// Define typed routes — never use string paths directly in navigation calls
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
const HomeRoute();
@override Widget build(BuildContext ctx, GoRouterState state) => const HomeScreen();
}
@TypedGoRoute<ProductRoute>(path: '/products/:id')
class ProductRoute extends GoRouteData {
final String id;
const ProductRoute({required this.id});
@override Widget build(BuildContext ctx, GoRouterState state) => ProductScreen(id: id);
}
// Navigate with type safety
const ProductRoute(id: product.id).go(context); // ✅
context.go('/products/${product.id}'); // ❌ — don't do this
```
## Auth guard
```dart
// Redirect logic in router config
redirect: (context, state) {
final isLoggedIn = ref.read(authProvider).isAuthenticated;
final isLoginRoute = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoginRoute) return '/login';
if (isLoggedIn && isLoginRoute) return '/';
return null;
},
```
## Shell routes for bottom navigation
```dart
ShellRoute(
builder: (ctx, state, child) => MainScaffold(child: child),
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
],
)
```
## Deep links
- Register URL schemes in `Info.plist` (iOS) and `AndroidManifest.xml`
- Test deep links with: `adb shell am start -a android.intent.action.VIEW -d "app://{{PACKAGE_ID}}/products/123"`
- Handle `GoRouter.of(context).routerDelegate.currentConfiguration` for dynamic links
## Rules
- **NEVER** use `Navigator.push/pop` — use `context.go()`, `context.push()`, `context.pop()`
- All routes declared in one file: `lib/core/routing/app_router.dart`
- `BlocProvider`s for route-level blocs created inside the `builder` of each route
@@ -0,0 +1,109 @@
---
description: "Security standards for {{PROJECT_NAME}} — ALWAYS APPLIED on every file write"
alwaysApply: true
---
# Security Standards — {{PROJECT_NAME}}
> **Pillar 5**: Security is an always-on rule, not just a reactive agent.
> These rules apply to EVERY file write, regardless of feature or context.
## Credential & secret management
- **NEVER** hardcode API keys, tokens, or secrets in source code
- **NEVER** commit `.env` files — use `.gitignore` and document required vars in `.env.example`
- API keys belong in: flavored `--dart-define` build args, or a secrets manager (e.g. AWS Secrets)
- Use `flutter_secure_storage` for tokens/credentials — **NEVER** `SharedPreferences` for sensitive data
- Obfuscation: enable `--obfuscate --split-debug-info=build/debug-symbols/` for release builds
## Authentication & sessions
- JWT/session tokens: stored in `flutter_secure_storage`, never in `SharedPreferences` or local DB
- Implement token refresh with retry logic — never let a 401 show a raw error to the user
- Certificate pinning: required for production builds on {{AUTH}} — use `dio_pinning_interceptor`
- Biometric re-auth: require for any transaction > defined threshold
## Data & privacy
- **NEVER** log PII (names, emails, phone numbers, addresses) to console or crash reporters
- Sanitize all user inputs before sending to backend
- Use `SensitiveWidget` wrapper to exclude screens from screenshots / app switcher thumbnails
- Comply with platform privacy requirements: iOS `NSPrivacyAccessedAPITypes`, Android permissions
## Network security
- All API calls use HTTPS — no http:// in production
- Validate SSL certificates — never bypass with `badCertificateCallback: (_,_,_) => true`
- Add a timeout to every HTTP call: `connectTimeout: Duration(seconds: 10)`
- Rate-limit sensitive endpoints: auth, OTP, password reset
## Dependency security
- Run `dart pub outdated` monthly — flag packages with known CVEs
- Never use a package with <100 pub points without explicit tech lead approval
- Pin critical security packages to exact versions in `pubspec.yaml`
## Secure coding patterns
```dart
// ✅ Correct: secure token storage
final storage = FlutterSecureStorage();
await storage.write(key: 'access_token', value: token);
// ❌ Wrong: SharedPreferences for sensitive data
final prefs = await SharedPreferences.getInstance();
prefs.setString('access_token', token); // NEVER do this
// ✅ Correct: PII-safe logging
logger.info('User authenticated: userId=${user.id}'); // ok — id, not email
logger.debug('Payment processed: orderId=$orderId'); // ok — no card data
// ❌ Wrong: PII in logs
logger.info('Login: email=${user.email}, phone=${user.phone}'); // NEVER
// ✅ Correct: --dart-define for secrets (build arg, not in code)
// flutter build apk --dart-define=API_KEY=$API_KEY
const apiKey = String.fromEnvironment('API_KEY'); // acceptable
// ✅ Correct: certificate pinning with Dio
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.badCertificateCallback = (cert, host, port) => false; // strict
return client;
};
```
## Deep linking & intent security
- Validate all incoming deep link parameters — never trust raw URL params
- Use `app_links` package for verified deep link handling
- Restrict URL schemes to known patterns — reject unknown schemes
- For OAuth callbacks: validate `state` parameter to prevent CSRF
## Storage security
- Encrypt locally cached sensitive data using `hive` with `HiveAesCipher`
- Clear sensitive data from memory after use (set to null, trigger GC)
- Do NOT cache auth tokens in network layer (no `dio_cache_interceptor` for auth endpoints)
- Use `SecureRandom` for nonce/token generation — never `Random()`
## Code obfuscation & binary protection
```yaml
# android/app/build.gradle — release config
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
```
```bash
# Flutter release build with obfuscation
flutter build apk --release --obfuscate --split-debug-info=build/debug-symbols/
flutter build ipa --release --obfuscate --split-debug-info=build/debug-symbols/
```
## Release checklist
Before every production release, verify:
- [ ] No hardcoded secrets (`grep -r "api_key\|secret\|password\|token" lib/`)
- [ ] Debug flags disabled (`kDebugMode` guards on all debug-only paths)
- [ ] Obfuscation enabled in release build config
- [ ] Certificate pinning active and tested on both platforms
- [ ] Crash reporter PII filters configured (Sentry `beforeSend`, Firebase `setConsentType`)
- [ ] `flutter_secure_storage` used for all tokens — no `SharedPreferences` for sensitive keys
- [ ] Network calls all HTTPS — scan for `http://` in lib/
- [ ] Dependencies audited: `dart pub outdated --json | jq '.packages[] | select(.isDiscontinued)'`
- [ ] Deep link parameters validated
- [ ] App transport security / network security config reviewed
@@ -0,0 +1,64 @@
---
description: "BLoC / Cubit conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# BLoC / Cubit Standards — {{PROJECT_NAME}}
## When to use BLoC vs Cubit
- **Cubit**: simple state with no meaningful event history (toggle, counter, pagination)
- **BLoC**: event-driven flows where event history or transitions matter (auth, checkout, form wizard)
## Event and State classes
```dart
// Events — sealed class (exhaustive switch)
sealed class AuthEvent { const AuthEvent(); }
final class AuthLoginRequested extends AuthEvent {
final String email, password;
const AuthLoginRequested({required this.email, required this.password});
}
final class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
// States — sealed class, immutable
sealed class AuthState { const AuthState(); }
final class AuthInitial extends AuthState { const AuthInitial(); }
final class AuthLoading extends AuthState { const AuthLoading(); }
final class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
}
final class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); }
final class AuthFailure extends AuthState {
final String message;
const AuthFailure(this.message);
}
```
## BlocProvider placement
- Create `BlocProvider` at the **route level** (in {{ROUTING}} route definitions)
- `MultiBlocProvider` at route level for screens needing multiple blocs
- **NEVER** create `BlocProvider` inside a widget's `build()` method
## Usage rules
- **NEVER** call `bloc.add()` inside `build()` — only in gesture callbacks or `initState()`
- Use `BlocConsumer` only when BOTH `listen` + `build` logic are needed
- Use `BlocSelector` when only a subset of state triggers a rebuild
- Every BLoC must override `close()` and cancel `StreamSubscription`s
## BlocBuilder patterns
```dart
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const SizedBox.shrink(),
AuthLoading() => const LoadingIndicator(),
AuthAuthenticated(user: final u) => HomeScreen(user: u),
AuthUnauthenticated() => const LoginScreen(),
AuthFailure(message: final m) => ErrorScreen(message: m),
},
)
```
## File locations in {{PROJECT_NAME}}
- `lib/features/[feature]/presentation/bloc/[feature]_bloc.dart`
- `lib/features/[feature]/presentation/bloc/[feature]_event.dart`
- `lib/features/[feature]/presentation/bloc/[feature]_state.dart`
@@ -0,0 +1,67 @@
---
description: "GetX conventions for {{PROJECT_NAME}} (legacy — migration available)"
alwaysApply: true
---
# GetX Standards — {{PROJECT_NAME}}
> ⚠️ This project uses GetX. See `migration-agent` for incremental migration to Riverpod.
## Controller structure
```dart
class ProductsController extends GetxController {
final ProductRepository _repo;
ProductsController(this._repo);
final RxList<Product> products = <Product>[].obs;
final RxBool isLoading = false.obs;
final Rx<String?> error = Rx(null);
@override
void onInit() {
super.onInit();
fetchProducts();
}
Future<void> fetchProducts() async {
isLoading.value = true;
error.value = null;
try {
products.value = await _repo.getProducts();
} catch (e) {
error.value = e.toString();
} finally {
isLoading.value = false;
}
}
}
```
## View pattern
```dart
// Views extend GetView<Controller> — never GetWidget or raw StatelessWidget
class ProductsView extends GetView<ProductsController> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Obx(() {
if (controller.isLoading.value) return const ProductShimmer();
if (controller.error.value != null) return ErrorWidget(controller.error.value!);
return ProductList(controller.products);
}),
);
}
}
```
## Rules
- **NEVER** pass `BuildContext` into a controller
- Use `Binding` classes for dependency injection — never `Get.put()` in a widget
- Use `.obs` for all reactive state — never call `update()` on non-observable state
- Use `Get.find<Controller>()` only in `Binding` classes, not in widgets
- **No business logic in Views** — controllers handle all logic
## File locations in {{PROJECT_NAME}}
- `lib/features/[feature]/views/[feature]_view.dart`
- `lib/features/[feature]/controllers/[feature]_controller.dart`
- `lib/features/[feature]/bindings/[feature]_binding.dart`
- `lib/features/[feature]/models/[feature]_model.dart`
@@ -0,0 +1,44 @@
---
description: "Hooks + Riverpod conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Hooks + Riverpod Standards — {{PROJECT_NAME}}
## Widget base classes
- `HookConsumerWidget` — when you need BOTH hooks and Riverpod providers
- `HookWidget` — when you need ONLY hooks (no Riverpod)
- `ConsumerWidget` — when you need ONLY Riverpod (no hooks)
## Hook rules
```dart
class ProductSearchWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Hooks at the TOP of build() — never inside conditions or loops
final searchQuery = useState('');
final searchCtrl = useTextEditingController();
final focusNode = useFocusNode();
final debounced = useDebounced(searchQuery.value, const Duration(milliseconds: 300));
// Riverpod below hooks
final results = ref.watch(searchResultsProvider(debounced));
return // ...
}
}
```
## What goes in hooks vs providers
| Concern | Tool |
|---------|------|
| Local UI state (text controller, animation, focus) | `useState`, `useAnimationController` |
| Server/async state | Riverpod `AsyncNotifier` |
| Cross-widget/feature state | Riverpod providers |
| Lifecycle side effects | `useEffect` |
## Rules
- **NEVER** call hooks inside `if`, `for`, or callbacks
- `useEffect` cleanup MUST return a dispose function
- Custom hooks: prefix with `use`, live in `lib/core/hooks/`
- Do not use `flutter_riverpod` `ref.watch` inside `useEffect` — use `useRef` + `ref.listen`
@@ -0,0 +1,58 @@
---
description: "Riverpod conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Riverpod Standards — {{PROJECT_NAME}}
## Provider types
| Type | Use case |
|------|----------|
| `AsyncNotifier` | Async state from {{BACKEND}} |
| `Notifier` | Synchronous derived/local UI state |
| `StreamNotifier` | Real-time subscriptions |
| `@riverpod` function | Simple computed/derived values |
## Code generation (mandatory)
```dart
// ✅ Always use @riverpod annotation
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
Future<User?> build() => ref.watch(authRepositoryProvider).currentUser();
Future<void> login(String email, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(authRepositoryProvider).login(email, password),
);
}
}
// ❌ Never write manual provider declarations
final authProvider = StateNotifierProvider<AuthNotifier, AsyncValue<User?>>(
(ref) => AuthNotifier(),
); // DON'T DO THIS
```
## Rules
- `ref.watch()` inside `build()` ONLY — **never** `ref.read()` inside `build()`
- `ref.read(provider.notifier).method()` for mutations in gesture handlers
- `ref.invalidate(provider)` to refresh — never manually reset state to `AsyncLoading()`
- Family providers for parameterized data: `productDetailsProvider(productId)`
- Providers scoped at feature level; core providers in `lib/core/di/`
## AsyncValue in widgets
Every `AsyncValue` MUST handle all three states:
```dart
ref.watch(productsProvider).when(
data: (products) => ProductList(products: products),
loading: () => const ProductListShimmer(), // required
error: (e, _) => ErrorWidget(error: e), // required
)
```
## File locations in {{PROJECT_NAME}}
- `lib/features/[feature]/[feature]_provider.dart` (generated: `[feature]_provider.g.dart`)
- `lib/features/[feature]/[feature]_repository.dart`
- Run `dart run build_runner watch` during development
@@ -0,0 +1,19 @@
---
description: "Policy for optional local AI usage logs under .cursor/telemetry/"
globs: [".cursor/telemetry/**"]
alwaysApply: false
---
# Telemetry — {{PROJECT_NAME}}
## Context
When `telemetry_opt_in: true`, this repo may record **local-only** generation or usage notes under `.cursor/telemetry/`. This is **not** production analytics.
## Constraints
- **Never** commit secrets, tokens, or PII into JSONL or shell history
- Prefer redacted summaries over raw prompts
- Add `.cursor/telemetry/*.jsonl` to `.gitignore` unless your team explicitly version-controls sanitized samples
## Patterns
- Append one JSON object per line (JSONL) with ISO timestamps and event type
- Rotate or truncate files if they grow beyond a few MB
@@ -0,0 +1,60 @@
---
description: "BLoC testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# BLoC Testing Standards — {{PROJECT_NAME}}
## Test pattern (bloc_test)
```dart
// {{TEST_PATTERN}}
void main() {
late AuthBloc authBloc;
late MockAuthRepository mockRepo;
setUp(() {
mockRepo = MockAuthRepository();
authBloc = AuthBloc(repository: mockRepo);
});
tearDown(() => authBloc.close());
group('AuthBloc', () {
blocTest<AuthBloc, AuthState>(
'emits [Loading, Authenticated] when login succeeds',
build: () {
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => const Right(User(id: '1', email: 'test@test.com')));
return authBloc;
},
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'test@test.com', password: 'pass')),
expect: () => [
const AuthLoading(),
isA<AuthAuthenticated>(),
],
);
blocTest<AuthBloc, AuthState>(
'emits [Loading, Failure] when login fails',
build: () {
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => const Left(AuthError('Invalid credentials')));
return authBloc;
},
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'bad', password: 'bad')),
expect: () => [
const AuthLoading(),
const AuthFailure('Invalid credentials'),
],
);
});
}
```
## Rules
- Use `mocktail` for mocking — never `mockito`
- Every BLoC test file: `test/features/[feature]/[feature]_bloc_test.dart`
- Coverage requirement: all state transitions must be tested
- Use `Given/When/Then` naming in test descriptions
- Test error paths as thoroughly as success paths
@@ -0,0 +1,37 @@
---
description: "Patrol E2E testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# Patrol E2E Testing — {{PROJECT_NAME}}
## Test structure
```dart
void main() {
patrolTest('User can complete checkout flow', ($) async {
await $.pumpWidgetAndSettle(const App());
// Login
await $(LoginScreen).waitUntilVisible();
await $(#emailField).enterText('test@example.com');
await $(#passwordField).enterText('password123');
await $('Sign In').tap();
// Add to cart
await $(ProductCard).at(0).tap();
await $('Add to Cart').tap();
// Checkout
await $('Cart').tap();
await $('Checkout').tap();
await $(CheckoutSuccessScreen).waitUntilVisible();
});
}
```
## Rules
- E2E tests in `integration_test/` — separate from unit tests
- Use `patrolTest` not `testWidgets` for E2E scenarios
- Tag tests with `@Tags(['slow'])` so CI can skip on PRs
- Run against real emulators/simulators, not mocked environments
- Test on minimum supported OS version for each platform
@@ -0,0 +1,40 @@
---
description: "GetX testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# GetX Testing Standards — {{PROJECT_NAME}}
## Test pattern
```dart
void main() {
late ProductsController controller;
late MockProductRepository mockRepo;
setUp(() {
mockRepo = MockProductRepository();
Get.testMode = true;
controller = Get.put(ProductsController(mockRepo));
});
tearDown(() => Get.deleteAll());
test('loads products on init', () async {
when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]);
await controller.fetchProducts();
expect(controller.products, [fakeProduct]);
expect(controller.isLoading.value, false);
});
testWidgets('ProductsView shows shimmer while loading', (tester) async {
controller.isLoading.value = true;
await tester.pumpWidget(GetMaterialApp(home: ProductsView()));
expect(find.byType(ProductShimmer), findsOneWidget);
});
}
```
## Rules
- Use `Get.testMode = true` in setUp
- Always call `Get.deleteAll()` in tearDown
- Wrap widget tests in `GetMaterialApp`, not `MaterialApp`
@@ -0,0 +1,56 @@
---
description: "Riverpod testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# Riverpod Testing Standards — {{PROJECT_NAME}}
## Test pattern
```dart
// {{TEST_PATTERN}}
void main() {
late ProviderContainer container;
late MockProductRepository mockRepo;
setUp(() {
mockRepo = MockProductRepository();
container = ProviderContainer(overrides: [
productRepositoryProvider.overrideWithValue(mockRepo),
]);
});
tearDown(() => container.dispose()); // ALWAYS dispose
test('ProductsNotifier loads products successfully', () async {
when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]);
final notifier = container.read(productsProvider.notifier);
await notifier.loadProducts();
final state = container.read(productsProvider);
expect(state, isA<AsyncData<List<Product>>>());
expect(state.value, [fakeProduct]);
});
}
```
## Widget tests with Riverpod
```dart
testWidgets('ProductScreen shows shimmer while loading', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
productsProvider.overrideWith((ref) => const AsyncLoading()),
],
child: const MaterialApp(home: ProductScreen()),
),
);
expect(find.byType(ProductShimmer), findsOneWidget);
});
```
## Rules
- **Never** use a real `ProviderScope` in unit tests — always use `ProviderContainer` with overrides
- `addTearDown(container.dispose)` in every test that creates a container
- Test all three `AsyncValue` states: loading, data, error
@@ -0,0 +1,20 @@
---
description: Semantic colours and theme extensions for {{PROJECT_NAME}}
globs: ["lib/**/*.dart", "test/**/*.dart"]
alwaysApply: false
---
# Theming — {{PROJECT_NAME}}
## Context
Theme variants from brief: **{{THEME_SUMMARY}}**.{{HIGH_CONTRAST_NOTE}}
## Constraints
- Prefer **`ThemeExtension`** (or design-system equivalents) for app-specific colours and radii — avoid raw `Color(0xFF...)` in feature code except in central token definitions
- Use **`Theme.of(context).colorScheme`** / `TextTheme` for Material-aligned roles where appropriate
- **High contrast:** when supported, verify WCAG contrast for text and controls; never rely on colour alone for state (pair with icon or label){{HIGH_CONTRAST_UX_LINE}}
- Touch targets: respect platform minimums (e.g. ~48 logical pixels) for interactive widgets
## Anti-patterns
- Hard-coded `Colors.*` scattered across feature widgets instead of theme tokens
- Ignoring `MediaQuery.of(context).platformBrightness` / high-contrast modes when the product claims support
@@ -0,0 +1,38 @@
---
description: "Core Flutter conventions for {{PROJECT_NAME}}"
globs: ["lib/**/*.dart", "test/**/*.dart", "integration_test/**/*.dart"]
alwaysApply: false
---
# Flutter Core Standards — {{PROJECT_NAME}}
## Const and performance
- Use `const` constructors wherever possible — compile-time guarantee of no rebuild
- Prefer `const` widgets at the leaf level: `const SizedBox.shrink()`, `const Padding(...)`
- Never use `const` with mutable values; lint: `prefer_const_constructors` is enabled
## Null safety
- Never use `!` (bang operator) unless you have a 100% safe runtime guarantee with a comment
- Prefer `??`, `?.`, and `if (x != null)` guards
- Use `required` for all non-nullable named parameters
- Never use `late` without a guarantee of initialisation before first access
## Widget lifecycle
- Override `dispose()` in every `StatefulWidget` that uses controllers, streams, or timers
- Cancel `StreamSubscription` in `dispose()`, not in `didUpdateWidget`
- Use `WidgetsBinding.instance.addPostFrameCallback` for post-build logic, not `Future.delayed(Duration.zero)`
## Naming conventions
- Files: `snake_case.dart`
- Classes: `PascalCase`
- Variables/functions: `camelCase`
- Constants: `kCamelCase` or `SCREAMING_SNAKE` for true compile-time constants
- Private members: `_camelCase`
## Imports
{{IMPORT_POLICY_BLOCK}}
## Code quality
- Max function length: 40 lines. Extract widgets and helpers aggressively
- No `print()` in production code — use a logging package
- All `TODO:` comments must include a ticket number: `// TODO: PROJ-123 — fix this`
- Run `dart format` before every commit
@@ -0,0 +1,65 @@
---
description: "Stack summary and product context for {{PROJECT_NAME}}"
globs: ["project-brief.yaml", ".cursor/**/*.md", ".cursor/**/*.mdc", "pubspec.yaml"]
alwaysApply: false
---
# Project Context — {{PROJECT_NAME}}
## Project identity
- **Name:** {{PROJECT_NAME}}
- **Package:** {{PACKAGE_ID}}
- **Description:** {{DESCRIPTION}}
- **Scale:** {{SCALE}}
## Technology stack
- **State management:** {{STATE_MANAGEMENT}}
- **Architecture:** {{ARCHITECTURE}}
- **Routing:** {{ROUTING}}
- **Backend:** {{BACKEND}}
- **Auth:** {{AUTH}}
- **Platforms:** {{PLATFORMS_LIST}}
- **Code generation:** {{CODEGEN_LIST}}
## Feature modules
{{FEATURES_LIST}}
## Special capabilities
{{SPECIAL_FEATURES}}
## Environments / flavors
- Flavors: {{FLAVORS_LIST}}
- CI/CD: {{CICD_TOOL}}
## Design & API references
- Design source: {{DESIGN_SOURCE}}
- API docs: {{API_DOCS_FORMAT}} at `{{API_DOCS_PATH}}`
## Code references
### Git repositories
{{GIT_REFS_BLOCK}}
### Local paths
{{LOCAL_PATHS_BLOCK}}
## Product UX / themes & roles
- **Theme variants:** {{THEME_SUMMARY}}
- **Roles:** {{ROLES_SUMMARY}}
{{HIGH_CONTRAST_NOTE}}
## Reviews — which rule owns what
- **Theme, colors, typography, spacing/radius tokens** → `ui-ux-standards.mdc` (widgets read `Theme.of(context)` only)
- **User-visible copy & locales** → `localization.mdc` (ARB / `AppLocalizations`; no UI string literals)
- **Imports, structure, naming** → `flutter-core.mdc` + architecture rule
## Architecture boundaries
{{ARCH_IMPORT_RULES}}
## When generating code for this project
1. Always use {{STATE_MANAGEMENT}} patterns — never suggest alternatives
2. Always follow {{ARCHITECTURE}} folder structure
3. Always use {{ROUTING}} for navigation — never `Navigator.push` directly
4. Always target platforms: {{PLATFORMS_LIST}}
5. If code generation tools are used ({{CODEGEN_LIST}}), follow their conventions
6. Apply visuals only through theme (`ColorScheme`, `TextTheme`, `ThemeExtension`) — never ad-hoc colors/fonts in feature widgets
7. No user-facing string literals in widgets — l10n or shared constants per localization rule
@@ -0,0 +1,24 @@
---
description: How Cursor rules in this repository must be written and maintained.
globs: [".cursor/rules/**/*.mdc"]
alwaysApply: true
---
# Rule authoring — {{PROJECT_NAME}}
## Context
Rules are version-controlled contracts for AI assistants. Poor rules waste context and silently steer every edit.
## Constraints
- One focused concern per file; split broad topics instead of one mega-rule
- Every rule MUST have a clear `description` in frontmatter (one sentence)
- Prefer `alwaysApply: false` with **narrow** `globs` for domain rules — reserve `alwaysApply: true` for meta and safety
- `globs` must be as specific as possible — never `["**/*"]` unless tooling requires it
- Code samples in rules MUST be valid for this project (Dart/Flutter/YAML as appropriate)
- Deprecated guidance is removed, not left commented out
- Each substantive rule includes **Context** (why), **Constraints** (must/must not), and where helpful **Patterns** / **Anti-patterns**
## Anti-patterns
- Domain rules (testing, l10n, a feature) with `alwaysApply: true` — burns context
- Rules with no concrete examples when the topic is code-facing
- Stale feature rules after modules are removed — run `tool/cursor_audit.sh` periodically
@@ -0,0 +1,49 @@
---
description: "UI/UX standards for {{PROJECT_NAME}}"
globs: ["lib/**/*.dart", "test/**/*.dart", "integration_test/**/*.dart"]
alwaysApply: false
---
# UI / UX Standards — {{PROJECT_NAME}}
## Theme & design tokens (single source of truth)
- Define **one** light/dark `ThemeData` (and optional `ThemeExtension`s for brand spacing, radii, semantic colors). Feature code reads `Theme.of(context)` only.
- **Colors:** `colorScheme` / extensions — never hex/`Color(...)` literals in widgets except inside the theme definition file(s).
- **Typography:** `textTheme` / `primaryTextTheme` — never raw `TextStyle(fontSize:, fontFamily:)` in feature UI.
- **Spacing & shapes:** `ThemeExtension` or documented constants consumed consistently — avoid one-off magic numbers for padding/radius.
## Loading states
- Every async operation MUST show a loading skeleton (shimmer), NOT a spinner unless < 300ms
- Use `shimmer` package with a shimmer that matches the final layout shape
- Never show a blank screen during loading — skeleton must fill the same space as the content
## Empty states
- Every list/grid MUST have a distinct empty state widget: illustration + headline + CTA
- Empty state is different from error state — never reuse the same widget for both
- Empty state copy: positive framing ("No items yet — add your first one")
## Error states
- Every async failure MUST show: error message + retry button
- Never swallow errors silently
- Error text: user-friendly, never expose stack traces or raw API messages
## Navigation & transitions
- Use `IndexedStack` for bottom nav tabs — preserves scroll position
- Named routes only — never `Navigator.push(context, MaterialPageRoute(...))`
- Page transitions: use `CustomTransitionPage` with `FadeTransition` for modal sheets
## Responsive layout
- Use `LayoutBuilder` or `MediaQuery` for breakpoints, not hardcoded pixel values
- Minimum touch target: 48×48 logical pixels (Material guideline)
- Test on 375px (iPhone SE) and 414px (iPhone Pro Max) widths minimum
## Haptics
- Use `HapticFeedback.lightImpact()` on primary CTAs
- Use `HapticFeedback.selectionClick()` on toggle/checkbox interactions
- Never add haptics to destructive actions without confirmation
## Accessibility
- All interactive widgets must have a `Semantics` label or `tooltip`
- Minimum contrast ratio: 4.5:1 (WCAG AA)
- Test with TalkBack / VoiceOver before each release
{{HIGH_CONTRAST_UX_LINE}}
@@ -0,0 +1,573 @@
# Build — {{PROJECT_NAME}}
Implements any feature end-to-end: deep research → TDD → integration tests → external setup checklist → verified PR.
Stack: **{{STATE_MANAGEMENT}}** / **{{ARCHITECTURE}}** / **{{BACKEND}}** / {{PLATFORMS_LIST}}.
## Usage
```
/build <free-text description of what to implement>
```
**Examples:**
- `/build implement notification module end-to-end`
- `/build add biometric auth`
- `/build integrate Stripe payments`
- `/build add deep linking for shared product pages`
- `/build implement analytics event tracking`
---
## FEATURE_REGISTRY
The AI reads this registry to classify the request and load the right research, test scenarios, and setup steps.
```yaml
feature_registry:
notifications:
keywords: [notification, push, fcm, apns, alert, badge, silent push,
remote notification, local notification, firebase messaging]
pub_packages: [firebase_messaging, flutter_local_notifications, firebase_core]
rules_to_load: [security-standards.mdc, platform-ios.mdc, platform-android.mdc]
unit_test_scenarios:
- parse notification payload to domain model
- FCM token generation and storage
- token refresh triggers re-registration
- notification permission denied returns graceful fallback
- notification service initialisation is idempotent
widget_test_scenarios:
- notification banner renders correct title and body
- tap on notification navigates to correct route
- badge count updates on new message
integration_test_scenarios:
- foreground notification receipt and display
- background notification receipt (app backgrounded, not killed)
- killed-state notification receipt (cold launch from notification tap)
- tap-to-open in foreground state routes correctly
- tap-to-open in background state routes correctly
- tap-to-open in killed state routes correctly
- notification payload parsing end-to-end
- deep link routing from notification data field
- multiple simultaneous notifications (ordering and dedup)
external_setup:
firebase: [Enable Cloud Messaging, download google-services.json, download GoogleService-Info.plist]
ios: [Push Notifications capability, Background Modes remote notifications, APNs .p8 key upload to Firebase]
android: [POST_NOTIFICATIONS permission, RECEIVE_BOOT_COMPLETED permission, google-services plugin]
files_to_touch:
new: [lib/features/notifications/, integration_test/notifications/]
modified: [lib/main.dart, android/app/src/main/AndroidManifest.xml, ios/Runner/Info.plist, ios/Runner/AppDelegate.swift, pubspec.yaml]
auth:
keywords: [auth, authentication, login, sign in, sign out, logout,
biometric, face id, touch id, fingerprint, oauth, jwt,
session, token refresh, social login, google sign-in]
pub_packages: [firebase_auth, supabase_flutter, local_auth, flutter_secure_storage, google_sign_in]
rules_to_load: [security-standards.mdc, platform-ios.mdc, platform-android.mdc]
unit_test_scenarios:
- login success stores token securely
- login failure (wrong password) returns typed error
- login failure (network) returns typed error
- token refresh succeeds and updates stored token
- token refresh failure triggers logout
- biometric auth success grants access
- biometric auth failure falls back to password
- biometric not enrolled returns correct error state
- logout clears all stored credentials
- session persistence: token loaded on cold start
widget_test_scenarios:
- login form renders correctly
- validation errors shown inline
- loading state disables submit button
- biometric prompt shown when available
integration_test_scenarios:
- complete login flow (email and password)
- login then logout then login again
- invalid credentials shows error and stays on login
- biometric login flow on enrolled device
- biometric fallback to password
- token refresh in background while user navigates
- cold start with stored valid session skips login
- cold start with expired session redirects to login
- social auth OAuth redirect and return
- deep link into protected route redirects to login then back
external_setup:
firebase: [Enable Email/Password provider, Enable Google provider, download updated config files]
ios: [NSFaceIDUsageDescription in Info.plist, LocalAuthentication.framework in Xcode]
android: [USE_BIOMETRIC permission, USE_FINGERPRINT permission, minSdkVersion 23 for biometric]
files_to_touch:
new: [lib/features/auth/, integration_test/auth/]
modified: [lib/main.dart, lib/core/di/injection.dart, pubspec.yaml]
payments:
keywords: [payment, stripe, in-app purchase, iap, checkout, subscription,
billing, apple pay, google pay, card, transaction, refund, revenue cat]
pub_packages: [flutter_stripe, purchases_flutter, in_app_purchase]
rules_to_load: [security-standards.mdc, platform-ios.mdc, platform-android.mdc]
unit_test_scenarios:
- checkout request builds correct PaymentIntent params
- payment success updates order state
- payment failure (card declined) returns typed error
- webhook event parsed to domain model
- refund request constructs correct API call
- subscription status checked on app resume
widget_test_scenarios:
- checkout form renders with correct amount
- payment loading state shown during processing
- success confirmation screen renders
- error state with retry option
integration_test_scenarios:
- end-to-end checkout with test card (Stripe test mode)
- checkout with 3D Secure challenge
- card declined shows error then retry succeeds
- Apple Pay / Google Pay sheet appears (device capability check)
- subscription purchase and entitlement unlock
- subscription restore flow
- refund flow from order history
external_setup:
stripe: [publishable and secret keys in flavor .env files, webhook endpoint in Stripe Dashboard, Apple Pay domain registration]
ios: [In-App Purchase capability, Apple Pay capability and merchant identifier, com.apple.developer.in-app-payments entitlement]
android: [Google Pay Console setup, BILLING permission for in_app_purchase]
files_to_touch:
new: [lib/features/payments/, integration_test/payments/]
modified: [ios/Runner/Runner.entitlements, android/app/src/main/AndroidManifest.xml, pubspec.yaml]
deep_links:
keywords: [deep link, deep linking, universal link, app link, deferred deep link,
dynamic link, branch, uri scheme, custom scheme, url scheme]
pub_packages: [go_router, app_links, uni_links]
rules_to_load: [platform-ios.mdc, platform-android.mdc]
unit_test_scenarios:
- URI parsed to correct route and parameters
- unknown URI falls back to home
- authenticated-only route redirects to login when unauthenticated
- deep link preserves query parameters
widget_test_scenarios:
- navigation guard redirects unauthenticated deep link
integration_test_scenarios:
- cold start from Universal Link routes to correct screen
- cold start from custom URI scheme routes correctly
- backgrounded app receives deep link and navigates
- foreground app receives deep link and navigates
- deep link to authenticated route redirects to login then original destination
- deep link with path parameters loads correct content
- invalid or malformed deep link shows 404 screen
external_setup:
ios: [Associated Domains capability applinks:yourdomain.com, host apple-app-site-association file at /.well-known/]
android: [intent-filter with android:autoVerify=true in AndroidManifest.xml, host assetlinks.json at /.well-known/]
files_to_touch:
new: [lib/core/routing/deep_link_handler.dart, integration_test/deep_links/]
modified: [lib/core/routing/router.dart, android/app/src/main/AndroidManifest.xml, ios/Runner/Runner.entitlements, ios/Runner/Info.plist]
analytics:
keywords: [analytics, tracking, event, mixpanel, amplitude, firebase analytics,
segment, posthog, screen view, funnel, cohort]
pub_packages: [firebase_analytics, mixpanel_flutter, amplitude_flutter, posthog_flutter]
rules_to_load: [security-standards.mdc]
unit_test_scenarios:
- analytics service logs correct event name and params
- PII fields stripped before event sent
- analytics disabled in dev flavor
- screen name logged on navigation
widget_test_scenarios:
- RouteObserver triggers screen_view event
integration_test_scenarios:
- user action triggers expected event (verify via debug view)
- screen transitions log screen_view with correct names
- opt-out disables all tracking
external_setup:
firebase: [Enable Analytics in Firebase Console, enable DebugView for local testing, configure conversion events]
files_to_touch:
new: [lib/core/analytics/analytics_service.dart, lib/core/analytics/analytics_events.dart, integration_test/analytics/]
modified: [lib/main.dart, pubspec.yaml]
storage:
keywords: [storage, file upload, download, cloud storage, firebase storage,
supabase storage, s3, image upload, document, file picker,
offline, cache, hive, isar, objectbox, sqflite, drift]
pub_packages: [firebase_storage, supabase_flutter, hive_flutter, isar, drift, file_picker, image_picker, path_provider]
rules_to_load: [security-standards.mdc, platform-ios.mdc, platform-android.mdc]
unit_test_scenarios:
- upload returns public URL on success
- upload failure returns typed error
- local cache read before remote fetch (offline-first)
- cache invalidation on TTL expiry
- large file upload uses resumable upload
widget_test_scenarios:
- file picker button triggers picker
- upload progress indicator shown
- image preview renders after selection
integration_test_scenarios:
- end-to-end file upload and retrieval
- offline mode: local cache serves data
- background upload completes when connectivity restored
- file size limit enforced
external_setup:
firebase: [Enable Firebase Storage, configure Storage security rules, set CORS policy for web if applicable]
ios: [NSPhotoLibraryUsageDescription in Info.plist, NSCameraUsageDescription in Info.plist]
android: [READ_EXTERNAL_STORAGE or READ_MEDIA_IMAGES permission]
files_to_touch:
new: [lib/features/storage/, integration_test/storage/]
modified: [android/app/src/main/AndroidManifest.xml, ios/Runner/Info.plist, pubspec.yaml]
camera_media:
keywords: [camera, photo, video, image picker, qr code, barcode scanner,
ar, augmented reality, gallery, media, record, capture]
pub_packages: [camera, image_picker, mobile_scanner, qr_flutter, image_cropper, video_player]
rules_to_load: [security-standards.mdc, platform-ios.mdc, platform-android.mdc]
unit_test_scenarios:
- QR/barcode parsed to correct domain model
- image compressed before upload
- camera permission denied returns typed error
widget_test_scenarios:
- camera preview widget renders
- capture button triggers photo take
integration_test_scenarios:
- camera opens and captures photo on real device
- gallery picker selects image and returns
- QR scan decodes valid code correctly
- permission denied shows correct error UI
external_setup:
ios: [NSCameraUsageDescription, NSPhotoLibraryUsageDescription, NSMicrophoneUsageDescription for video]
android: [CAMERA permission, READ_MEDIA_IMAGES for gallery access]
files_to_touch:
new: [lib/features/camera/, integration_test/camera/]
modified: [android/app/src/main/AndroidManifest.xml, ios/Runner/Info.plist, pubspec.yaml]
```
---
## Phase 1: Context Loading
**When the user types `/build <request>`, do the following before writing a single line of code:**
1. Read `project-brief.yaml` from the repo root. Extract and hold in context:
- `stack.state_management` → **{{STATE_MGMT_RAW}}**
- `stack.architecture` → **{{ARCH_RAW}}**
- `stack.routing` → **{{ROUTING_RAW}}**
- `stack.backend` → **{{BACKENDS_LIST}}**
- `stack.platforms` → **{{PLATFORMS_LIST}}**
- `stack.codegen` → **{{CODEGEN_LIST}}**
- `testing.depth` → **{{TESTING_DEPTH}}**
- `testing.e2e_tool` → **{{E2E_TOOL}}**
- `features.modules` (existing features) → **{{FEATURES_LIST}}**
- `features.special` → **{{SPECIAL_FEATURES}}**
- `environments.flavors` → **{{FLAVORS_LIST}}**
- `environments.cicd` → **{{CICD_RAW}}**
2. Parse the user's free-text request. Match against `FEATURE_REGISTRY` keywords using longest-match. Multiple types are allowed if the request spans features.
3. Load `.cursor/rules/` files listed under `rules_to_load` for the matched feature type.
4. If the user's message contains a URL or GitHub repo reference, fetch and index it for pattern reference.
5. Check `features.modules` — if the feature already exists in the list, ask: "This feature is already listed in project-brief.yaml. Building additional capability on top of it? (y/n)"
6. Print the context summary table before proceeding:
```
| Field | Value |
|------------------|------------------------------|
| Feature type | [detected type] |
| State management | {{STATE_MANAGEMENT}} |
| Architecture | {{ARCHITECTURE}} |
| Backend | {{BACKEND}} |
| Platforms | {{PLATFORMS_LIST}} |
| E2E tool | {{E2E_TOOL}} |
| Rules loaded | [list from rules_to_load] |
| Existing modules | {{FEATURES_LIST}} |
```
---
## Phase 2: Deep Research
**Before touching any file, print the full research output.**
1. From the FEATURE_REGISTRY, enumerate all `pub_packages` for the detected type. For each, determine the correct version compatible with current Flutter stable (`flutter --version`). Print the full `pubspec.yaml` additions.
2. Build the complete file manifest, adapting to `{{ARCHITECTURE}}`:
**For {{ARCHITECTURE}} ({{ARCH_RAW}}):**
- `clean`: expand each feature layer explicitly → `domain/entities/`, `domain/repositories/`, `domain/usecases/`, `data/models/`, `data/datasources/`, `data/repositories/`, `presentation/`
- `feature_first`: `[feature]/[feature]_screen.dart`, `[feature]_provider.dart` or `[feature]_bloc.dart`, `[feature]_repository.dart`, `[feature]_model.dart`, `widgets/`
- `mvc`: `[feature]/model/`, `[feature]/view/`, `[feature]/controller/`
**State management file naming ({{STATE_MANAGEMENT}} / {{STATE_MGMT_RAW}}):**
- `bloc`: `[feature]_bloc.dart`, `[feature]_event.dart`, `[feature]_state.dart`
- `riverpod`: `[feature]_provider.dart`, `[feature]_notifier.dart`
- `getx`: `[feature]_controller.dart`, `[feature]_binding.dart`
3. List all modified files from `files_to_touch.modified` — show exactly what change goes in each file.
4. List all external service configuration required per platform (from `external_setup`). Only show platforms present in `{{PLATFORMS_LIST}}`.
5. Print research results in this format:
```
## Research Results
### Packages to add to pubspec.yaml
- [package_name]: ^[version]
### Files to create ({{ARCHITECTURE}} / {{STATE_MANAGEMENT}})
lib/features/[name]/... (full list)
### Files to modify
[file_path] — [what changes]
### External services requiring configuration
- [Service] ([platform]) — [what to do]
```
Do not touch any file until the user has seen this output.
---
## Phase 3: TDD Implementation
> **Invoke skill: `superpowers:test-driven-development`**
Follow Red → Green → Refactor strictly. Show actual terminal output at each step.
### Step 3a — Red: Write all failing tests first
1. Mirror the feature directory under `test/features/[feature]/`.
2. Write unit tests for **every scenario** in `FEATURE_REGISTRY.unit_test_scenarios` for the detected type.
3. Write widget tests for **every scenario** in `FEATURE_REGISTRY.widget_test_scenarios`.
4. Use `mocktail` for all dependencies.
5. Test naming convention: `'given [precondition], when [action], then [expected outcome]'`
6. Use the state management test pattern for **{{STATE_MANAGEMENT}}**:
```
{{TEST_PATTERN}}
```
7. Run and confirm red:
```
flutter test test/features/[feature]/ --no-pub
```
**Paste the actual output here before proceeding.**
If failures are unclear or non-deterministic after one iteration, use **`/debug`** with full pasted output before guessing fixes.
### Step 3b — Green: Implement
1. Create domain entities and repository interfaces.
2. Create data-layer implementations wiring to `{{BACKEND}}`.
3. Create presentation layer using **{{STATE_MANAGEMENT}}** patterns.
4. Register in DI container — follow `{{ARCH_IMPORT_RULES}}`.
5. Wire the route in `{{ROUTING}}` router.
6. If `{{CODEGEN_LIST}}` is not `none`: run `dart run build_runner build --delete-conflicting-outputs` after adding models.
7. Run tests and confirm green:
```
flutter test test/features/[feature]/ --no-pub
```
**Paste the actual output here before proceeding.**
### Step 3c — Refactor
1. Review for duplication, naming, and layer boundary violations per `.cursor/rules/flutter-core.mdc`.
2. Run and confirm clean:
```
flutter analyze
```
**Paste the actual output here before proceeding.**
3. Run tests once more to confirm still green.
If output stays red or errors are ambiguous after two focused attempts, stop and use **`/debug`** with the full failing command and log before changing more code.
---
## Phase 4: Integration Test Generation
**Generate `integration_test/[feature_type]/` before asking the user to run anything.**
### Integration test file structure
Each test file follows this template:
```dart
// integration_test/[feature]/[scenario]_test.dart
// Generated by /build — {{PROJECT_NAME}}
// Feature: [feature_type] | Scenario: [scenario_name]
// Run with: flutter test integration_test/[feature]/[scenario]_test.dart -d <device_id>
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:{{PACKAGE_ID}}/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('[FeatureType] — [Scenario Name]', () {
setUp(() async {
// Seed state / configure mocks / reset storage
});
tearDown(() async {
// Cleanup
});
testWidgets(
'given [precondition], when [action], then [expected outcome]',
(tester) async {
app.main();
await tester.pumpAndSettle();
// test body
},
);
});
}
```
**For `{{E2E_TOOL}}` (patrol):** swap `flutter_test` imports for `package:patrol` and use Patrol's `$` NativeAutomator selector syntax.
### Files to generate
Create one file per logical scenario cluster from `FEATURE_REGISTRY.integration_test_scenarios`.
Also generate `integration_test/[feature]/README.md` with the full test matrix and run commands.
### PAUSE GATE — user must run on device
After generating all files, print this table and **stop**. Wait for the user to paste device output before Phase 6.
```
## ACTION REQUIRED — Run Integration Tests on Real Device
| File | Covers | Requires Hardware |
|------|--------|------------------|
| integration_test/[feature]/[scenario]_test.dart | [what it covers] | Yes/No |
...
Run on iOS:
flutter test integration_test/[feature]/ -d <your_ios_device_id>
Run on Android:
flutter test integration_test/[feature]/ -d <your_android_device_id>
Paste the output here to continue.
```
---
## Phase 5: External Setup Checklist
**Print before asking the user to verify anything. Group by service/platform. Only show sections for platforms in `{{PLATFORMS_LIST}}`.**
### Format for console/service steps (numbered list):
```markdown
### Firebase (backend: {{BACKEND}})
- [ ] 1. Open Firebase Console → [exact menu path]
- [ ] 2. [Specific action]
- [ ] 3. Download updated config file → place at [exact path]
```
### Format for native platform steps (table):
```markdown
### iOS Setup
| Step | Where | What | How to Verify |
|------|-------|------|---------------|
| [Step name] | [Xcode location / file path] | [Exact change] | [Verification method] |
```
```markdown
### Android Setup
- [ ] 1. Open `android/app/src/main/AndroidManifest.xml`
- Add: `<uses-permission android:name="[permission]"/>`
- [ ] 2. [Next step with exact value]
```
### Flavor scoping note (flavors: {{FLAVORS_LIST}})
Config files and API keys must be scoped per flavor. Never place production keys in `dev` flavor files. Follow the pattern established in `.cursor/rules/` for flavor-based configuration.
---
## Phase 6: Verification Gate
> **Invoke skill: `superpowers:verification-before-completion`**
**Do not claim completion without pasting real output for every item below.**
```
VERIFICATION REQUIRED — paste real output for each:
[ ] flutter test test/features/[feature]/ --no-pub
Required: "All N tests passed."
[ ] flutter analyze
Required: "No issues found!"
[ ] Integration test device output (from Phase 4 pause gate)
Required: actual device test output
[ ] lefthook run pre-commit
Required: all hooks passed
```
If any check fails: invoke `superpowers:systematic-debugging` to diagnose before retrying.
For noisy errors or weak reproduction, run **`/debug`** and fill the BugReport skeleton with logs before retrying.
For small, isolated changes, **`/verify`** is enough to re-check tests and analyze without re-reading this whole skill.
Do not proceed to Phase 7 until all four checks are green.
---
## Phase 7: PR Preparation
> **Invoke skill: `superpowers:finishing-a-development-branch`**
1. **Commit message** (conventional commits format):
```
feat([feature_type]): implement [feature] end-to-end
```
2. **PR description template:**
```markdown
## What
[One sentence: what feature was implemented]
## Test coverage
- Unit/widget tests: N passing
- Integration test matrix:
| Scenario | iOS | Android |
|----------|-----|---------|
| [scenario] | ✅ | ✅ |
## External setup completed
- [ ] [Service 1 setup]
- [ ] [Service 2 setup]
## Notes
[Any follow-up TODOs or known limitations]
```
3. **CI/CD advice for {{CICD_TOOL}} ({{CICD_RAW}}):**
Ensure workflow secrets are scoped per flavor ({{FLAVORS_LIST}}). Never expose production keys in dev environment secrets. Verify the CI workflow runs `flutter test` and `flutter analyze` before deploy steps.
---
## Rules applied every phase
Always active — read before writing any code:
- `.cursor/rules/flutter-core.mdc`
- `.cursor/rules/security-standards.mdc`
- `.cursor/rules/project-context.mdc`
- Feature-type-specific rules from `FEATURE_REGISTRY.rules_to_load`
Architecture import rules for **{{ARCHITECTURE}}**:
{{ARCH_IMPORT_RULES}}
---
## Code generation notes
**Codegen tools configured: {{CODEGEN_LIST}}**
After adding any new model or injectable class, run:
```
dart run build_runner build --delete-conflicting-outputs
```
Commit generated files (`.g.dart`, `.freezed.dart`, `injection.config.dart`) — do not gitignore them.
**Template version:** {{TEMPLATE_VERSION}}
@@ -0,0 +1,18 @@
# Create Build Flavor — {{PROJECT_NAME}}
Creates a new build flavor/environment. Current flavors: {{FLAVORS_LIST}}.
## Usage
```
Create a new flavor called [flavor_name]
```
## What gets created
1. Android: new `productFlavors` block in `android/app/build.gradle`
2. iOS: new scheme + configuration in Xcode (instructions provided)
3. `lib/core/config/[flavor_name]_config.dart`
4. `.env.[flavor_name]` template
5. {{CICD_TOOL}} pipeline update
## CI/CD: {{CICD_TOOL}}
Generate the pipeline config snippet for the new flavor in {{CICD_TOOL}} format.
@@ -0,0 +1,86 @@
---
name: Debug issue
description: Triage failing tests, analyze, CI, or runtime errors with an evidence-first BugReport. Use /debug and paste logs; invokes systematic-debugging before fixes. Stack {{STATE_MANAGEMENT}} / {{ARCHITECTURE}} / {{BACKEND}} / {{PLATFORMS_LIST}}.
---
# Debug — {{PROJECT_NAME}}
Triage failures (tests, CI, runtime, or build) **without** jumping to fixes. Stay in hypothesis-and-evidence mode until root cause is stated.
**Stack context:** **{{STATE_MANAGEMENT}}** / **{{ARCHITECTURE}}** / **{{BACKEND}}** / platforms: {{PLATFORMS_LIST}}. Flavors: {{FLAVORS_LIST}}. Codegen: {{CODEGEN_LIST}}.
## Usage
```
/debug <what failed — paste error output, command, or symptom>
```
**Examples:**
- `/debug flutter test fails on auth_cubit_test — paste output`
- `/debug CI analyze step — paste log excerpt`
- `/debug app crashes on cold start after last change`
---
## Phase 0 — Normalize the report (BugReport)
Emit this skeleton **before** deep analysis. If the user already pasted logs, map them into the fields instead of re-asking.
| Field | Content |
|-------|---------|
| **Summary** | One line: what broke |
| **Expected** | What should happen |
| **Actual** | What happened (symptom + error text) |
| **Repro steps** | Numbered, minimal |
| **Scope / files touched** | Paths or PR slice |
| **Environment** | OS, Flutter/Dart version if known, device vs simulator, flavor |
| **Evidence** | Pasted command output, stack trace, or screenshot notes |
---
## Phase 1 — Evidence checklist (Flutter-aware)
Gather or request **concrete** evidence. Do not guess versions or config.
1. **`flutter doctor -v`** — paste output when environment is unknown or iOS/Android toolchain errors appear.
2. **Failing command** — full invocation + **verbatim** tail of output (e.g. `flutter test …`, `dart test …`, `flutter analyze`).
3. **`flutter analyze`** — if not already in the failure log, run or ask the user to run and paste.
4. **Flavors** — this project uses: **{{FLAVORS_LIST}}**. Confirm which flavor was active if the failure is env-specific.
5. **Platforms** — **{{PLATFORMS_LIST}}**. Narrow reproduction to the platform that failed when relevant.
6. **Codegen** — tools: **{{CODEGEN_LIST}}**. When this is not `none`, remind to run `dart run build_runner build --delete-conflicting-outputs` after generated files changed, and to align with `.cursor/hooks/` / `lefthook run pre-commit` when hooks are present.
7. **Testing depth** — **{{TESTING_DEPTH}}**; E2E tool: **{{E2E_TOOL}}**. Match the failure to the right layer (unit vs widget vs integration).
---
## Phase 2 — Root cause (no code yet)
> **Invoke skill: `superpowers:systematic-debugging`**
Produce **one paragraph**: hypothesis tied to **specific lines** in the pasted evidence. Mark confidence (high / medium / low). **No code changes** in this phase.
---
## Phase 3 — Fix (only after Phase 2)
> **Invoke skill: `superpowers:systematic-debugging`** again while iterating fixes.
When proposing changes:
- Respect architecture boundaries for **{{ARCHITECTURE}}**:
{{ARCH_IMPORT_RULES}}
- Always consider: `.cursor/rules/flutter-core.mdc`, `.cursor/rules/security-standards.mdc`, `.cursor/rules/project-context.mdc`, and state-management rules for **{{STATE_MANAGEMENT}}**.
After each fix attempt, re-run the **same** failing command and paste new output.
---
## ACTION REQUIRED
If evidence is missing, **stop** and print:
1. Exact commands to run (copy-paste ready).
2. What to paste back (full error blocks, not summaries).
3. If the user cannot run commands: state assumptions explicitly and set confidence to **low**.
**Template version:** {{TEMPLATE_VERSION}}
@@ -0,0 +1,21 @@
# Deploy — {{PROJECT_NAME}}
Guides through deployment to {{CICD_TOOL}} for any of the flavors: {{FLAVORS_LIST}}.
## Usage
```
Deploy [flavor] to [store/environment]
```
## {{CICD_TOOL}} pipeline
The AI will generate or update the {{CICD_RAW}} configuration file for:
- Build signing
- Running tests
- Distributing to Firebase App Distribution (beta) or App Store / Play Store (prod)
## Pre-deploy checklist
- [ ] Version bump in `pubspec.yaml`
- [ ] Obfuscation enabled for prod: `--obfuscate --split-debug-info=build/debug-symbols/`
- [ ] No debug flags in production code
- [ ] Security checklist from `security-standards.mdc` passed
- [ ] `cursor_gen --validate` passes
@@ -0,0 +1,80 @@
---
name: Explain code
description: Explain what code does without editing it. Use /explain with a path or symbol; covers {{STATE_MANAGEMENT}} state, {{BACKEND}} I/O, routing {{ROUTING}}, and failure modes. Ask when facts are not in the repo.
---
# Explain — {{PROJECT_NAME}}
Explain **what the code is doing** — **do not** change production code unless the user explicitly asks for a fix.
**Stack:** **{{STATE_MANAGEMENT}}** / **{{ARCHITECTURE}}** / **{{ROUTING}}** / **{{BACKEND}}** / platforms: {{PLATFORMS_LIST}}. Package: `{{PACKAGE_ID}}`.
## Usage
```
/explain <file path, widget name, class, or symbol>
```
**Examples:**
- `/explain lib/features/cart/cart_cubit.dart`
- `/explain how checkout routes after payment`
- `/explain CartPage build method`
---
## Output format (use these headings in order)
### Purpose
One short paragraph: why this code exists in the product context (**{{PROJECT_NAME}}** — {{DESCRIPTION}}).
### Public API
Surface area: public classes/methods, constructors, and what callers are expected to pass. Note codegen involvement when **{{CODEGEN_LIST}}** is not `none`.
### Call flow
Ordered steps from entry point (e.g. widget `build`, route handler, Bloc `on<Event>`, Riverpod `build`, GetX controller lifecycle) through collaborators. Tie navigation to **{{ROUTING}}** where relevant.
### State and side effects
How state is held and updated for **{{STATE_MANAGEMENT}}** ({{STATE_MGMT_RAW}}). Mention async work, listeners, and disposal. Reference **{{ARCH_IMPORT_RULES}}** if layering is unclear.
### I/O and backends
Network, local storage, or platform channels touching **{{BACKEND}}** (and **{{AUTH}}** where auth applies: {{AUTH_RAW}}). Do **not** invent API shapes not visible in the repo.
### Failure modes
What can go wrong: null paths, error states, race conditions, missing permissions on {{PLATFORMS_LIST}}, auth edge cases.
### Suggested tests
Ideas aligned with **{{TESTING_DEPTH}}** and **{{E2E_TOOL}}**; for **{{STATE_MANAGEMENT}}**, prefer patterns like:
```
{{TEST_PATTERN}}
```
### Unknowns — questions for you
If behavior depends on runtime config, native projects, remote API contracts, or secrets not in tree: **stop** and list **specific** questions. Do not fabricate facts.
---
## References
- **Project brief:** `project-brief.yaml` — feature modules: {{FEATURES_LIST}}; special features: {{SPECIAL_FEATURES}}.
- **Scale:** {{SCALE}}; i18n locales: {{LOCALES_LIST}} (when explaining localization).
- **Design:** {{DESIGN_SOURCE}}; Figma URL: {{FIGMA_URL}}.
- **API docs format:** {{API_DOCS_FORMAT}} (path: {{API_DOCS_PATH}}).
- **Related repos (if any):**
{{GIT_REFS_BLOCK}}
- **Local paths (if any):**
{{LOCAL_PATHS_BLOCK}}
**Template version:** {{TEMPLATE_VERSION}}
@@ -0,0 +1,16 @@
# Generate API Client — {{PROJECT_NAME}}
Generates type-safe API clients from {{API_DOCS_FORMAT}} spec at `{{API_DOCS_PATH}}`.
## Usage
```
Generate API client for [endpoint or resource name]
```
## Generated files
1. **DTO** (`data/models/[resource]_dto.dart`) — request/response models with json_serializable
2. **DataSource** (`data/datasources/[resource]_remote_datasource.dart`) — Dio calls with error handling
3. **Repository impl** (`data/repositories/[resource]_repository_impl.dart`)
## After generation
Run: `dart run build_runner build --delete-conflicting-outputs`
@@ -0,0 +1,41 @@
# Generate Tests — {{PROJECT_NAME}}
Generates comprehensive unit, widget, and integration tests for **{{STATE_MANAGEMENT}}** patterns.
## Usage
```
Generate tests for [ClassName or file path]
```
## Test generation process
1. Read the source file completely
2. Identify all testable units (public methods, state transitions, UI states)
3. Generate tests following this pattern:
```
{{TEST_PATTERN}}
```
4. Create mocks with `mocktail` for all dependencies
5. Place test file at `test/[mirror of source path]_test.dart`
## Coverage targets
- Business logic (UseCases, Repositories, BLoC/Notifiers): **80% minimum**
- Widget tests: all three states (loading/error/data) must be tested
- E2E: only critical user flows
## Test file structure
```dart
void main() {
// Setup
group('[ClassName]', () {
// Happy path tests
group('success cases', () { ... });
// Error path tests
group('error cases', () { ... });
// Edge cases
group('edge cases', () { ... });
});
}
```
## Naming convention
`'given [precondition], when [action], then [expected outcome]'`
@@ -0,0 +1,69 @@
# Scaffold Feature — {{PROJECT_NAME}}
Scaffolds a complete new feature module following **{{ARCHITECTURE}}** architecture with **{{STATE_MANAGEMENT}}** state management.
## Usage
```
Create a feature called [feature_name] with [description]
```
## What gets generated
### For {{ARCHITECTURE}} architecture:
The AI will create all necessary files for the `[feature_name]` feature following {{ARCHITECTURE}} patterns.
### File structure to create:
**Clean Architecture:**
```
lib/features/[feature_name]/
domain/
entities/[feature_name].dart
repositories/[feature_name]_repository.dart
usecases/
get_[feature_name]_usecase.dart
create_[feature_name]_usecase.dart
data/
models/[feature_name]_dto.dart
datasources/[feature_name]_remote_datasource.dart
repositories/[feature_name]_repository_impl.dart
presentation/
[state_files]/ ← based on {{STATE_MANAGEMENT}}
pages/[feature_name]_page.dart
widgets/
```
**Feature-First:**
```
lib/features/[feature_name]/
[feature_name]_screen.dart
[feature_name]_provider.dart ← or [feature_name]_bloc.dart
[feature_name]_repository.dart
[feature_name]_model.dart
widgets/
```
## State management boilerplate
### For {{STATE_MANAGEMENT}}:
Generate the appropriate state management files with:
- Initial/loading/success/error states
- All necessary events (for BLoC)
- Repository connection
- Dependency injection registration
## Steps the AI takes
1. Ask: "What is the feature name and brief description?"
2. Ask: "What data does this feature manage? (e.g., list of products, single user profile)"
3. Generate all files with correct imports and patterns
4. Add the feature to the DI container
5. Add the route to {{ROUTING}} router
6. Create a placeholder test file
## Code generation
{{#if codegen_freezed}}
- Generate Freezed model: run `dart run build_runner build` after scaffolding
{{/if}}
{{#if codegen_injectable}}
- Register in injectable: add `@lazySingleton` to repository
{{/if}}
@@ -0,0 +1,61 @@
# Scaffold Screen — {{PROJECT_NAME}}
Creates a complete screen widget with all states handled, following **{{STATE_MANAGEMENT}}** patterns.
## Usage
```
Create a screen for [screen_name] that shows [content description]
```
## Generated screen template
### {{STATE_MANAGEMENT}} screen pattern:
**BLoC:**
```dart
class [ScreenName]Screen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<[Feature]Bloc, [Feature]State>(
builder: (context, state) => switch (state) {
[Feature]Initial() || [Feature]Loading() => const [ScreenName]Shimmer(),
[Feature]Loaded(:final data) => [ScreenName]Content(data: data),
[Feature]Empty() => const [ScreenName]EmptyState(),
[Feature]Error(:final message) => [ScreenName]ErrorState(
message: message,
onRetry: () => context.read<[Feature]Bloc>().add(const [Feature]LoadRequested()),
),
},
);
}
}
```
**Riverpod:**
```dart
class [ScreenName]Screen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch([feature]Provider);
return state.when(
loading: () => const [ScreenName]Shimmer(),
data: (data) => [ScreenName]Content(data: data),
error: (e, _) => [ScreenName]ErrorState(error: e),
);
}
}
```
## Required sub-widgets to generate
1. `[ScreenName]Shimmer` — skeleton loading layout matching final content
2. `[ScreenName]EmptyState` — illustration + headline + CTA button
3. `[ScreenName]ErrorState` — error message + retry button
4. `[ScreenName]Content` — the actual data display
## Platform considerations ({{PLATFORMS_LIST}})
{{#if platform_web}}
- Web: ensure no dart:io usage; test at 375px and 1280px widths
{{/if}}
{{#if platform_desktop}}
- Desktop: add keyboard shortcut support for primary actions
{{/if}}
@@ -0,0 +1,73 @@
---
name: Verify change
description: Post-change or pre-PR verification without the full /build lifecycle. Use /verify with optional scope; respects testing depth {{TESTING_DEPTH}} and E2E {{E2E_TOOL}}; no success claims without pasted outputs.
---
# Verify — {{PROJECT_NAME}}
Run a **focused** verification gate after a change or before a small PR — without replaying the entire `/build` document.
**Stack:** **{{STATE_MANAGEMENT}}** / **{{ARCHITECTURE}}** / **{{BACKEND}}** / {{PLATFORMS_LIST}}. **Testing depth:** {{TESTING_DEPTH}}. **E2E tool:** {{E2E_TOOL}}. **Codegen:** {{CODEGEN_LIST}}.
## Usage
```
/verify [optional scope: paths, “small PR”, or feature name]
```
**Examples:**
- `/verify lib/features/auth/`
- `/verify small PR before push`
- `/verify after dependency bump`
---
## Checklist (adapt to testing depth)
> **Invoke skill: `superpowers:verification-before-completion`**
**Do not claim success** until the user (or you, in a trusted environment) has pasted **real** output for each applicable item below.
### Always (typical Flutter repo)
- [ ] **Unit / widget tests** for touched code — e.g. `flutter test <path> --no-pub` or full suite as appropriate.
**Required in paste:** a line showing tests passed (or the exact failure to fix).
- [ ] **`flutter analyze`**
**Required in paste:** `No issues found!` or the analyzer errors to address.
### When `testing.depth` is `full` or `e2e` (this brief: **{{TESTING_DEPTH}}**)
- [ ] **Integration / E2E** — use **{{E2E_TOOL}}** patterns from `.cursor/rules/` (e.g. Patrol). Run on a device or emulator when scenarios require it; paste run output.
### When `testing.depth` is `unit_widget` only
- [ ] **Integration / device E2E** — *not* mandatory unless the change touches integration-only surfaces; if skipped, say so explicitly in the paste.
### Hooks and codegen
- [ ] **`lefthook run pre-commit`** — run when the repo has Lefthook configured (especially when **{{CODEGEN_LIST}}** is not `none`). Paste hook summary.
If hooks are not set up for this workspace, write *“skipped — hooks not configured”* instead of failing silently.
### CI alignment
- [ ] For **{{CICD_TOOL}}** ({{CICD_RAW}}): confirm the same commands the pipeline runs (analyze + tests) are green locally. Note flavor-scoped secrets for **{{FLAVORS_LIST}}**.
---
## ACTION REQUIRED — paste block
Print this table and wait for pasted output before declaring done:
```
VERIFICATION — paste real output for each line you executed:
[ ] flutter test … → (paste: pass count or failures)
[ ] flutter analyze → (paste: no issues or errors)
[ ] integration / e2e (if depth is full/e2e or change requires it) →
[ ] lefthook run pre-commit → (paste or "skipped — not configured")
```
If anything fails: switch to **`/debug`** with the failing log before guessing.
**Template version:** {{TEMPLATE_VERSION}}
@@ -0,0 +1,2 @@
*.jsonl
*.log
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Append a JSONL usage line (local only). Requires jq when passing structured payload.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG="${ROOT}/telemetry/usage.jsonl"
mkdir -p "$(dirname "$LOG")"
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"${1:-note}\",\"detail\":\"${2:-}\"}" >>"$LOG"
@@ -3,109 +3,41 @@
import 'dart:io';
import 'package:test/test.dart';
import '../src/brief_loader.dart';
import 'golden_briefs.dart';
import '../src/resolver.dart';
import '../src/renderer.dart';
import '../src/validator.dart';
import '../src/models.dart';
import '../src/mcp_json.dart';
// ─── Test fixtures ─────────────────────────────────────────────────────────
final _blocCleanBrief = ProjectBrief(
projectName: 'TestApp',
packageId: 'com.test.testapp',
description: 'Test app for golden tests',
scale: 'medium',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['firebase'],
auth: 'firebase_auth',
platforms: ['ios', 'android'],
codegenTools: ['freezed'],
flavors: ['dev', 'prod'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: ['auth', 'home', 'products'],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
);
final _riverpodFFBrief = ProjectBrief(
projectName: 'TaskFlow',
packageId: 'com.test.taskflow',
description: 'Task management app',
scale: 'small',
stateManagement: 'riverpod',
routing: 'gorouter',
architecture: 'feature_first',
backends: ['supabase'],
auth: 'supabase_auth',
platforms: ['ios', 'android', 'web'],
codegenTools: ['freezed', 'json_serializable'],
flavors: ['dev', 'prod'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: ['auth', 'tasks', 'profile'],
specialFeatures: [],
i18nEnabled: true,
locales: ['en', 'fr'],
);
final _getxMvcBrief = ProjectBrief(
projectName: 'LegacyApp',
packageId: 'com.test.legacy',
description: 'Legacy GetX app',
scale: 'medium',
stateManagement: 'getx',
routing: 'getx_nav',
architecture: 'mvc',
backends: ['rest'],
auth: 'jwt_rest',
platforms: ['ios', 'android'],
codegenTools: [],
flavors: ['dev', 'prod'],
cicd: 'codemagic',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'openapi',
apiDocsPath: 'docs/api.yaml',
referenceRepos: [],
localPaths: [],
featureModules: ['auth', 'dashboard'],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
);
void main() {
// ─── Resolver tests ─────────────────────────────────────────────────────────
group('Resolver', () {
group('Resolver', () {
test('BLoC + Clean resolves correct files', () {
final files = Resolver.resolve(_blocCleanBrief);
final files = Resolver.resolve(kBlocCleanFirebaseBrief);
// Universal — always present
expect(files.first, equals('rules/universal/rule-authoring'));
expect(files, contains('rules/universal/flutter-core'));
expect(files, contains('rules/universal/ui-ux-standards'));
expect(files, contains('rules/universal/project-context'));
// CI/CD rule when cicd set or multiple flavors
expect(files, contains('rules/cicd/cicd'));
// Feature stubs from brief
expect(files, contains('rules/features/auth'));
expect(files, contains('rules/features/home'));
expect(files, contains('rules/features/products'));
// Cursor workspace extras
expect(files, contains('root/.cursorignore'));
expect(files, contains('root/tool/cursor_audit.sh'));
expect(files, contains('onboarding/ONBOARDING'));
expect(files, contains('commands/build'));
expect(files, contains('commands/debug-issue'));
// Security — always present (Pillar 5)
expect(files, contains('rules/security/security-standards'));
@@ -133,17 +65,24 @@ group('Resolver', () {
// Agents
expect(files, contains('agents/code-reviewer'));
expect(files, containsNot('agents/migration-agent')); // not GetX
expect(files, contains('agents/security-agent')); // firebase_auth triggers it
expect(files,
contains('agents/security-agent')); // firebase_auth triggers it
// NOT included
expect(files, containsNot('rules/state-management/riverpod'));
expect(files, containsNot('rules/state-management/getx'));
expect(files, containsNot('rules/architecture/feature_first'));
expect(files, containsNot('rules/routing/getx_nav'));
expect(files, contains('commands/verify-change'));
expect(files, contains('commands/explain-code'));
expect(files, containsNot('config/mcp-json'));
expect(files, contains('root/AGENTS.md'));
expect(files, contains('root/lefthook.yaml'));
});
test('Riverpod + Feature-First + Web resolves web platform template', () {
final files = Resolver.resolve(_riverpodFFBrief);
final files = Resolver.resolve(kRiverpodFfSupabaseBrief);
expect(files, contains('rules/platform/platform-web'));
expect(files, contains('rules/state-management/riverpod'));
expect(files, contains('rules/architecture/feature_first'));
@@ -153,25 +92,221 @@ group('Resolver', () {
expect(files, containsNot('agents/migration-agent'));
});
test('featureRuleKey sanitizes module names', () {
expect(Resolver.featureRuleKey('My Cart'), 'rules/features/my_cart');
expect(Resolver.featureRuleKey('auth'), 'rules/features/auth');
expect(Resolver.featureRuleKey(' '), isNull);
});
test('MCP template key when integrations.mcp.enabled', () {
final brief = ProjectBrief(
projectName: 'McpApp',
packageId: 'com.test.mcp',
description: '',
scale: 'small',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['firebase'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'none',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
mcpConfigEnabled: true,
);
expect(Resolver.resolve(brief), contains('config/mcp-json'));
});
test('Push/deeplink rule when special features request it', () {
final brief = ProjectBrief(
projectName: 'PushApp',
packageId: 'com.test.push',
description: '',
scale: 'small',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'none',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: ['push_notifications'],
i18nEnabled: false,
locales: ['en'],
);
expect(Resolver.resolve(brief), contains('rules/integrations/push-deeplink'));
});
test('Theming rule when high contrast requested', () {
final brief = ProjectBrief(
projectName: 'A11yApp',
packageId: 'com.test.a11y',
description: '',
scale: 'small',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'none',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
themeVariants: const ['light', 'dark', 'high_contrast'],
);
expect(Resolver.resolve(brief), contains('rules/theming/theming'));
});
test('Telemetry helpers when telemetry_opt_in', () {
final brief = ProjectBrief(
projectName: 'TelApp',
packageId: 'com.test.tel',
description: '',
scale: 'small',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'none',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
telemetryOptIn: true,
);
final files = Resolver.resolve(brief);
expect(files, contains('telemetry/gitignore'));
expect(files, contains('telemetry/log-sh'));
expect(files, contains('rules/telemetry/usage-logging'));
});
test('McpJsonBuilder minimal preset emits empty mcpServers', () {
const brief = ProjectBrief(
projectName: 'M',
packageId: 'com.m.m',
description: '',
scale: 'small',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'none',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
mcpConfigEnabled: true,
mcpPreset: 'minimal',
);
expect(McpJsonBuilder.build(brief), contains('"mcpServers": {}'));
});
test('GetX + MVC includes migration-agent', () {
final files = Resolver.resolve(_getxMvcBrief);
final files = Resolver.resolve(kGetxMvcRestBrief);
expect(files, contains('agents/migration-agent'));
expect(files, contains('rules/state-management/getx'));
expect(files, contains('rules/architecture/mvc'));
expect(files, contains('agents/api-client-gen')); // rest backend
expect(files, contains('skills/generate-api-client')); // apiDocsFormat != none
expect(files,
contains('skills/generate-api-client')); // apiDocsFormat != none
// Hooks are emitted only when stack.codegen is non-empty
expect(files, containsNot('hooks/hooks-json'));
expect(files, containsNot('hooks/flutter-analyze'));
});
test('Codegen stack includes Cursor hooks templates', () {
final files = Resolver.resolve(kBlocCleanFirebaseBrief);
expect(files, contains('hooks/hooks-json'));
expect(files, contains('hooks/arch-guard'));
});
test('Realtime special feature includes realtime template', () {
final brief = ProjectBrief(
projectName: 'RealtimeApp', packageId: 'com.test.rt', description: '',
scale: 'medium', stateManagement: 'riverpod', routing: 'gorouter',
architecture: 'feature_first', backends: ['supabase'], auth: 'supabase_auth',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: ['realtime'], i18nEnabled: false, locales: ['en'],
projectName: 'RealtimeApp',
packageId: 'com.test.rt',
description: '',
scale: 'medium',
stateManagement: 'riverpod',
routing: 'gorouter',
architecture: 'feature_first',
backends: ['supabase'],
auth: 'supabase_auth',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: ['realtime'],
i18nEnabled: false,
locales: ['en'],
);
final files = Resolver.resolve(brief);
expect(files, contains('rules/backend/realtime'));
@@ -179,29 +314,62 @@ group('Resolver', () {
test('E2E testing depth includes e2e template', () {
final brief = ProjectBrief(
projectName: 'E2EApp', packageId: 'com.test.e2e', description: '',
scale: 'small', stateManagement: 'bloc', routing: 'gorouter',
architecture: 'clean', backends: ['rest'], auth: 'none',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'full', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: [], i18nEnabled: false, locales: ['en'],
projectName: 'E2EApp',
packageId: 'com.test.e2e',
description: '',
scale: 'small',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'full',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
);
final files = Resolver.resolve(brief);
expect(files, contains('rules/testing/testing-e2e-patrol'));
});
test('No duplicate files in resolved list', () {
final files = Resolver.resolve(_blocCleanBrief);
final files = Resolver.resolve(kBlocCleanFirebaseBrief);
final unique = files.toSet();
expect(files.length, equals(unique.length), reason: 'Duplicate template files detected');
expect(files.length, equals(unique.length),
reason: 'Duplicate template files detected');
});
test('Universal slash skills always in resolved list', () {
const universalSkills = [
'skills/build',
'skills/debug-issue',
'skills/verify-change',
'skills/explain-code',
];
for (final skill in universalSkills) {
expect(Resolver.resolve(kBlocCleanFirebaseBrief), contains(skill));
expect(Resolver.resolve(kRiverpodFfSupabaseBrief), contains(skill));
expect(Resolver.resolve(kGetxMvcRestBrief), contains(skill));
}
});
});
});
// ─── Renderer / placeholder tests ───────────────────────────────────────────
group('Renderer — placeholder substitution', () {
group('Renderer — placeholder substitution', () {
test('buildContext produces all required keys', () {
// Access via Renderer._buildContext (white-box test)
// Instead, verify rendered output has no unreplaced {{VAR}} patterns
@@ -212,22 +380,24 @@ group('Renderer — placeholder substitution', () {
};
final template = 'Project: {{PROJECT_NAME}} ({{PACKAGE_ID}})';
final result = template
.replaceAll('{{PROJECT_NAME}}', 'TestApp')
.replaceAll('{{PACKAGE_ID}}', 'com.test.testapp');
.replaceAll('{{PROJECT_NAME}}', context['PROJECT_NAME']!)
.replaceAll('{{PACKAGE_ID}}', context['PACKAGE_ID']!);
expect(result, equals('Project: TestApp (com.test.testapp)'));
expect(result, isNot(contains('{{')));
});
test('Rendered output has no unreplaced {{PLACEHOLDER}} patterns', () async {
test('Rendered output has no unreplaced {{PLACEHOLDER}} patterns',
() async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template directory not found at $templateDir — run from generator/');
markTestSkipped(
'Template directory not found at $templateDir — run from generator/');
return;
}
final rendered = await Renderer.render(
brief: _blocCleanBrief,
templateFiles: Resolver.resolve(_blocCleanBrief),
brief: kBlocCleanFirebaseBrief,
templateFiles: Resolver.resolve(kBlocCleanFirebaseBrief),
templateSrc: templateDir,
);
@@ -238,29 +408,115 @@ group('Renderer — placeholder substitution', () {
unresolved.add('${entry.key}: ${m.group(0)}');
}
}
expect(unresolved, isEmpty, reason: 'Unreplaced placeholders:\n${unresolved.join("\n")}');
expect(unresolved, isEmpty,
reason: 'Unreplaced placeholders:\n${unresolved.join("\n")}');
});
test('hooks/arch-guard renders real template content, not placeholder',
() async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped(
'Template directory not found at $templateDir — run from generator/');
return;
}
final rendered = await Renderer.render(
brief: kBlocCleanFirebaseBrief,
templateFiles: ['hooks/arch-guard'],
templateSrc: templateDir,
);
final content = rendered['hooks/arch-guard.ts'];
expect(content, isNotNull, reason: 'arch-guard.ts must be produced');
expect(content, isNot(contains('Template not found')),
reason:
'Default template dir should resolve real arch-guard.ts.tmpl');
expect(content, contains('arch-guard'));
});
test('mcp.json renders from config/mcp-json key', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template directory not found');
return;
}
final brief = ProjectBrief(
projectName: 'McpApp',
packageId: 'com.test.mcp',
description: '',
scale: 'small',
stateManagement: 'bloc',
routing: 'gorouter',
architecture: 'clean',
backends: const ['firebase'],
auth: 'none',
platforms: const ['ios'],
codegenTools: const [],
flavors: const ['dev'],
cicd: 'none',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: const [],
localPaths: const [],
featureModules: const [],
specialFeatures: const [],
i18nEnabled: false,
locales: const ['en'],
mcpConfigEnabled: true,
);
final rendered = await Renderer.render(
brief: brief,
templateFiles: const ['config/mcp-json'],
templateSrc: templateDir,
);
final json = rendered['mcp.json']!;
expect(json, contains('mcpServers'));
expect(json, contains('filesystem'));
expect(json, contains('firebase'));
});
});
});
// ─── Validator tests ─────────────────────────────────────────────────────────
group('Validator', () {
group('Validator', () {
test('Valid brief passes validation', () async {
final result = await Validator.validate(_blocCleanBrief);
final result = await Validator.validate(kBlocCleanFirebaseBrief);
expect(result.isValid, isTrue);
expect(result.errors, isEmpty);
});
test('Invalid state_management fails validation', () async {
final brief = ProjectBrief(
projectName: 'X', packageId: 'com.x.x', description: '',
scale: 'small', stateManagement: 'invalid_sm', routing: 'gorouter',
architecture: 'clean', backends: ['rest'], auth: 'none',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: [], i18nEnabled: false, locales: ['en'],
projectName: 'X',
packageId: 'com.x.x',
description: '',
scale: 'small',
stateManagement: 'invalid_sm',
routing: 'gorouter',
architecture: 'clean',
backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
);
final result = await Validator.validate(brief);
expect(result.isValid, isFalse);
@@ -269,33 +525,51 @@ group('Validator', () {
test('Missing project name fails validation', () async {
final brief = ProjectBrief(
projectName: '', packageId: 'com.x.x', description: '',
scale: 'small', stateManagement: 'riverpod', routing: 'gorouter',
architecture: 'clean', backends: ['rest'], auth: 'none',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '',
referenceRepos: [], localPaths: [], featureModules: [],
specialFeatures: [], i18nEnabled: false, locales: ['en'],
projectName: '',
packageId: 'com.x.x',
description: '',
scale: 'small',
stateManagement: 'riverpod',
routing: 'gorouter',
architecture: 'clean',
backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
);
final result = await Validator.validate(brief);
expect(result.isValid, isFalse);
expect(result.errors, anyElement(contains('project.name')));
});
});
});
// ─── Golden file tests (Pillar 3) ───────────────────────────────────────────
group('Golden file tests', () {
group('Golden file tests', () {
test('BLoC + Clean renders match golden files', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return;
markTestSkipped('Template dir not found');
return;
}
final rendered = await Renderer.render(
brief: _blocCleanBrief,
templateFiles: Resolver.resolve(_blocCleanBrief),
brief: kBlocCleanFirebaseBrief,
templateFiles: Resolver.resolve(kBlocCleanFirebaseBrief),
templateSrc: templateDir,
);
@@ -319,33 +593,37 @@ group('Golden file tests', () {
test('Riverpod + FF renders match golden files', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return;
markTestSkipped('Template dir not found');
return;
}
final rendered = await Renderer.render(
brief: _riverpodFFBrief,
templateFiles: Resolver.resolve(_riverpodFFBrief),
brief: kRiverpodFfSupabaseBrief,
templateFiles: Resolver.resolve(kRiverpodFfSupabaseBrief),
templateSrc: templateDir,
);
_compareGoldens('test/golden/riverpod-ff-supabase', rendered);
await _compareGoldens('test/golden/riverpod-ff-supabase', rendered);
});
test('GetX + MVC renders match golden files', () async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return;
markTestSkipped('Template dir not found');
return;
}
final rendered = await Renderer.render(
brief: _getxMvcBrief,
templateFiles: Resolver.resolve(_getxMvcBrief),
brief: kGetxMvcRestBrief,
templateFiles: Resolver.resolve(kGetxMvcRestBrief),
templateSrc: templateDir,
);
_compareGoldens('test/golden/getx-mvc-rest', rendered);
await _compareGoldens('test/golden/getx-mvc-rest', rendered);
});
});
});
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
Future<void> _compareGoldens(String goldenDir, Map<String, String> rendered) async {
Future<void> _compareGoldens(
String goldenDir, Map<String, String> rendered) async {
for (final entry in rendered.entries) {
final goldenFile = File('$goldenDir/${entry.key}');
if (!goldenFile.existsSync()) {
@@ -359,14 +637,18 @@ Future<void> _compareGoldens(String goldenDir, Map<String, String> rendered) asy
}
String _templateDir() {
// When running from generator/, go up one level to find templates/
final script = Platform.script.toFilePath();
return script.replaceAll(RegExp(r'test[/\\][^/\\]+$'), '../templates');
// Walk up from CWD until we find the sibling templates/ directory.
// Works under `dart test` (snapshot CWD) and direct script execution alike.
var dir = Directory.current;
for (var i = 0; i < 5; i++) {
final candidate = Directory('${dir.path}/../templates');
if (candidate.existsSync()) return candidate.path;
final inside = Directory('${dir.path}/templates');
if (inside.existsSync()) return inside.path;
if (dir.parent.path == dir.path) break;
dir = dir.parent;
}
return '${Directory.current.path}/../templates';
}
// Custom matcher
Matcher get containsNot => isNot(contains);
extension on Matcher {
Matcher call(dynamic value) => isNot(contains(value));
}
Matcher containsNot(dynamic expected) => isNot(contains(expected));

Some files were not shown because too many files have changed in this diff Show More