Merge pull request #271 from zapstore/ai-guidelines

Spec-first AI guardrails and lightweight task workflow
This commit is contained in:
franzap
2026-01-20 02:04:50 +00:00
committed by GitHub
22 changed files with 643 additions and 2110 deletions
-1
View File
@@ -1 +0,0 @@
../tools/content/agent.json
+1 -1
View File
@@ -1 +1 @@
tools/content/CONTEXT.md
CONTEXT.md
-1
View File
@@ -1 +0,0 @@
../.cursor/mcp.json
+61
View File
@@ -0,0 +1,61 @@
# Zapstore — Project Context
This document is the entry point for AI assistants.
All behavioral authority lives in the project spec, not here.
If anything in this file conflicts with files under `spec/guidelines/`,
this file is wrong.
## What This Repository Is
Zapstore is a local-first, Nostr-native app store for Android.
It discovers, downloads, verifies, and installs APKs signed by developers the user trusts.
Users can support developers directly via Lightning zaps.
## Project Spec Structure
spec/
guidelines/ # Permanent rules (human-owned, never AI-modified)
ARCHITECTURE.md # Package boundaries, dependencies, key patterns
INVARIANTS.md # Non-negotiable behavioral guarantees
QUALITY_BAR.md # Standards, when to create specs
VISION.md # Product goals and non-goals
features/ # Feature specs (behavioral contracts)
_TEMPLATE.md # Template with example
FEAT-001-*.md # Actual feature specs
work/ # Active work packets (temporary, delete after merge)
_TEMPLATE.md # Template with example
WORK-001-*.md # Actual work packets
## How to Work
1. Before implementing, check `spec/features/` for a relevant feature spec
2. For non-trivial work, create a work packet in `work/`
3. Every code change must trace to a task in the work packet
4. If a spec is unclear or incorrect, report a Spec Issue—do not guess
See `spec/guidelines/QUALITY_BAR.md` for what qualifies as "non-trivial."
## File Ownership
**Never modify** files in `spec/guidelines/`.
If a guideline seems wrong or incomplete, report it as a Spec Issue.
| Path | Owner | AI May Modify |
|------|-------|---------------|
| `spec/guidelines/*` | Human | No |
| `spec/features/*` | Human | No (unless explicitly asked) |
| `work/*.md` | AI | Yes |
| `lib/**` | Shared | Yes |
| `test/**` | Shared | Yes |
| `CONTEXT.md` | Human | No |
## Working Rules
- Prefer small, localized changes. Avoid unrelated refactors.
- After dependency changes, run: `fvm flutter pub get`
- Fix any analyze/lint errors introduced by your changes.
- Assume Android as default target unless instructed otherwise.
+14 -30
View File
@@ -51,7 +51,7 @@ packages:
source: hosted
version: "1.0.4"
archive:
dependency: "direct dev"
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
@@ -59,7 +59,7 @@ packages:
source: hosted
version: "4.0.7"
args:
dependency: "direct dev"
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
@@ -114,14 +114,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.0"
bm25:
dependency: "direct dev"
description:
name: bm25
sha256: "75f9f627fcdb884db420510d34906fb5e7ec7caef9d9cbdc35be17317b637f7b"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
boolean_selector:
dependency: transitive
description:
@@ -589,26 +581,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -657,22 +649,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.11.1"
mcp_dart:
dependency: "direct dev"
description:
name: mcp_dart
sha256: "4bc05574bc8bda0ae779e177ab5639f580b37059b5cb1248288b7a79979dd5e6"
url: "https://pub.dev"
source: hosted
version: "0.5.3"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1157,10 +1141,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.7"
timezone:
dependency: transitive
description:
@@ -1277,10 +1261,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
-4
View File
@@ -83,10 +83,6 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^5.0.0
mcp_dart: ^0.5.3
bm25: ^2.2.3
archive: ^4.0.7
args: ^2.7.0
flutter_launcher_icons: ^0.14.4
# For information on the generic Dart part of this file, see the
+122
View File
@@ -0,0 +1,122 @@
# FEAT-001 — Package Manager
## Goal
Single source of truth for installed packages and active install operations.
Manages the complete lifecycle: download → verify → install, with pause/resume/cancel support.
## Non-Goals
- Managing non-APK file types
- Auto-updating without user awareness
- Installing from sources other than Nostr-published releases
## User-Visible Behavior
### Download Phase
- User taps "Install" → download begins, progress shown
- User can pause/resume/cancel active downloads
- Multiple downloads queue automatically (max 3 concurrent)
- "Update All" queues all updates immediately with visual feedback
### Verification Phase
- After download completes, hash verification runs
- Verification state is visible (not hidden)
- Hash mismatch blocks install with clear error
### Permission Phase
- If "Install unknown apps" permission not granted, user is prompted
- Permission state is explicit in UI
- Once granted, all waiting installs advance automatically
### Install Phase
- Native Android install dialog shown
- One install dialog at a time (serialized)
- If user dismisses dialog, install shows "Tap to retry" state
- Success updates installed list immediately (no stale UI)
### Failure States
- Download failed → clear error, can retry
- Hash mismatch → error, cannot proceed
- Certificate mismatch → offer "Uninstall and reinstall" option
- Permission denied → guidance to enable in Settings
## State Machine
Operations follow this sealed class hierarchy (`install_operation.dart`):
```
DownloadQueued → Downloading ↔ DownloadPaused
↓
Verifying
↓
AwaitingPermission (if needed)
↓
ReadyToInstall
↓
Installing → AwaitingUserAction (if dismissed)
↓
[cleared] or OperationFailed
```
State transitions are unidirectional except Downloading ↔ DownloadPaused.
## Edge Cases
- Network drops mid-download → download pauses or fails gracefully, can retry
- App backgrounded during install → install completes, UI updates on return
- 404 from origin server → automatic CDN fallback before failing
- Stale operations (>7 days) → garbage collected on app restart
- Android package DB race condition → state updated from target metadata, not sync
## Invariants
These are non-negotiable. Violations mean the implementation is broken.
1. **UI never blocks** — `install()` returns immediately; events drive state via EventChannel
2. **One install dialog at a time** — Android PackageInstaller limitation, enforced by serialization
3. **Hash verification before install** — Native side verifies before install session opens
4. **Permission flow is explicit** — `AwaitingPermission` state exists for UI feedback
5. **Downloaded files are cleaned up** — Deleted after success or dismissal
6. **No polling** — All state changes via callbacks/events, never periodic checks
## Integration Boundaries
```
┌─────────────────────────────────────────────────────────────┐
│ PackageManager (Dart) │
│ - State machine owner │
│ - Download management (background_downloader) │
│ - Orchestrates flow │
└─────────────────────────┬───────────────────────────────────┘
│ MethodChannel / EventChannel
┌─────────────────────────▼───────────────────────────────────┐
│ AndroidPackageManagerPlugin (Kotlin) │
│ - Hash verification │
│ - PackageInstaller session │
│ - Permission checks │
│ - Emits: verifying/started/success/failed/cancelled │
└─────────────────────────────────────────────────────────────┘
```
## Acceptance Criteria
- [ ] User can download, pause, resume, cancel downloads
- [ ] User can install apps with proper verification
- [ ] Multiple downloads queue correctly (max 3 concurrent)
- [ ] Install failures show actionable error messages
- [ ] Certificate mismatch offers force-update option
- [ ] UI remains responsive throughout all operations
- [ ] No operations block the UI thread
## Files
- `lib/services/package_manager/package_manager.dart` — Base class, state machine
- `lib/services/package_manager/install_operation.dart` — State definitions
- `lib/services/package_manager/android_package_manager.dart` — Android implementation
- `android/.../AndroidPackageManagerPlugin.kt` — Native side
+75
View File
@@ -0,0 +1,75 @@
# FEAT-XXX — Short Name
## Goal
1–2 sentences describing what this feature/bugfix achieves for the user.
## Non-Goals
- Explicitly list what is out of scope
- Prevents scope creep and AI drift
## User-Visible Behavior
- What the user sees or can do
- States: loading, success, error, empty (where relevant)
- Offline behavior
## Edge Cases
- Degraded or no network
- Cancellation / retry
- Invalid or partial data
- Permission denied
- Other relevant risks
## Acceptance Criteria
- [ ] Observable outcome 1
- [ ] Observable outcome 2
- [ ] Observable outcome 3
## Notes (optional)
- Anything that needs human decision
- Open questions
---
# Example: FEAT-002 — NWC Zaps
## Goal
Allow users to zap app developers directly from the app detail screen using Nostr Wallet Connect.
## Non-Goals
- In-app wallet management (just NWC connection)
- Zapping comments or reviews (only developers)
- Recurring zaps or subscriptions
## User-Visible Behavior
- Zap button visible on app detail screen when NWC connected
- Tapping opens amount selection dialog (21, 100, 500, 1000 sats, custom)
- Success: toast confirmation with amount
- Failure: error dialog with reason
- Button disabled with tooltip when developer has no lightning address
## Edge Cases
- Developer has no lightning address → button hidden or disabled with explanation
- NWC connection drops mid-zap → graceful error, suggest reconnect
- Insufficient wallet balance → clear error from wallet
- App backgrounded during zap → completes, toast on return
## Acceptance Criteria
- [ ] User can connect NWC from profile settings
- [ ] User can zap developer from app detail screen
- [ ] Zap fails gracefully with clear error message
- [ ] Zap button correctly disabled when NWC not connected
## Notes
- Consider whether to show cumulative zaps received by developer
+98
View File
@@ -0,0 +1,98 @@
# Zapstore — Architecture
## Core Principle
Architecture exists to prevent accidental coupling and hidden ownership.
Each layer has clear responsibilities and must not exceed them.
## Layers
### zapstore (this Flutter app)
- Flutter UI and application orchestration
- Navigation, presentation, and user interaction
- Coordinates use cases across dependencies
- Must not contain domain rules or persistence logic
### Dart dependencies
#### models
- Domain models for Nostr events, kinds, zaps, releases, and NWC
- Parsing, validation, signing, encryption, and verification
- Pure domain logic only
- Must not depend on storage, networking, isolates, or UI
#### purplebase
- Local-first storage and indexing (SQLite)
- Relay synchronization and subscription lifecycle management
- Background work and isolate execution
- Must not depend on UI or presentation logic
## Dependency Rules
- zapstore → purplebase → models
- Reverse dependencies are forbidden
- UI widgets must not manage relay connections, storage, or background jobs
## Ownership & Orchestration
- Relay pools and subscriptions are owned by purplebase
- Background work lifecycle is explicit and cancellable
- zapstore orchestrates flows but does not own low-level resources
## Common Patterns
### Widget watching data
```dart
// Watch a query provider — reactive, auto-disposes
final state = ref.watch(
query<Profile>(
authors: {pubkey},
source: const LocalAndRemoteSource(relays: {'social'}),
),
);
return switch (state) {
StorageLoading() => CircularProgressIndicator(),
StorageError(:final exception) => Text('Error: $exception'),
StorageData(:final models) => ProfileWidget(models.first),
};
// Nested queries with `and` — loads relationships
final appState = ref.watch(
query<App>(
tags: {'#d': {identifier}},
and: (app) => {
app.latestRelease.query(
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
and: (release) => {release.latestMetadata.query()},
),
},
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
subscriptionPrefix: 'app-detail',
),
);
```
### Imperative queries (notifiers/services)
```dart
// One-shot query via storage extension
final apps = await ref.storage.query(
RequestFilter<App>(authors: {pubkey}, limit: 20).toRequest(),
);
```
### Saving and publishing (from callbacks)
```dart
onPressed: () async {
await ref.storage.save({signedModel});
await ref.storage.publish({signedModel});
}
```
For detailed API, see models/purplebase READMEs in pub cache.
+46
View File
@@ -0,0 +1,46 @@
# Zapstore — Invariants
The following guarantees are non-negotiable.
If any invariant is violated, the implementation is incorrect.
## UI Safety
- UI rendering must remain responsive under partial or total network failure.
- The UI must never block on I/O, cryptography, disk access, or network/relay operations.
- All background or asynchronous work must be cancellable and lifecycle-safe.
- Local data must be sufficient to render meaningful UI state; network access must enhance UX, not gate it.
- No operation may assume continuous network availability.
## Async Discipline
- No polling or artificial delays (e.g., Future.delayed for timing).
- Background work must surface results asynchronously and non-blockingly.
- All async work must be cancellable.
## Local-First Guarantees
- Cached data must be preferred over network data when available.
- Installed apps and metadata must be accessible offline.
- Network failures must degrade gracefully.
## Security & Verification
- APKs must never be installed unless their hash matches the expected value.
- Signed Nostr events must be verified before use.
- NWC secrets must be stored securely and must never be logged or exposed.
## Data Robustness
- Parsing unknown, missing, or future tags must not crash the app.
- Partial or invalid data must degrade gracefully.
## Lifecycle Safety
- Subscriptions must always be cancellable.
- Isolates and background jobs must not leak resources.
- Reconnection or retries must not duplicate events or actions.
## UX Safety
- All user-visible processes must have explicit states (loading, empty, success, error).
- Silent failures are unacceptable.
+111
View File
@@ -0,0 +1,111 @@
# Zapstore — Quality Bar
## General Expectations
- Correct behavior matters more than coverage numbers.
- Happy-path-only implementations are insufficient.
- Failures must be explicit and observable.
## When to Create a Feature Spec
Create a spec if the work:
- Touches async/lifecycle code (risk of UI blocking or resource leaks)
- Modifies security-sensitive flows (verification, permissions, signing, secrets)
- Changes state machine behavior (package manager, auth, subscriptions)
- Affects multiple screens or services
- Could regress existing UX
**Skip the spec** if:
- Pure UI cosmetics (colors, spacing, copy changes)
- Adding a field to an existing model with no behavioral change
- Bug fix with obvious cause and obvious solution
- Dependency update with no API changes
When in doubt, create a spec. The overhead is low.
## Work Packet Lifecycle
1. Create `WORK-XXX-*.md` when starting non-trivial work
2. Update tasks and decisions as you work
3. **Delete after PR merges** — the feature spec remains as the contract
If multiple phases: `WORK-005-a.md`, `WORK-005-b.md` (same feature number).
## Layer Expectations
### models
- Parsing and serialization behavior must be tested.
- Unknown or future fields must be tolerated.
### purplebase
- Storage and query behavior must be testable without network access.
- Subscription, cancellation, and isolate behavior must be validated.
### zapstore UI
- UI state machines must be explicit and testable.
- Loading, empty, error, and retry states are mandatory.
- UI must remain usable under degraded network conditions.
## Implementation Expectations
- Follow existing patterns in the nearest module.
- Avoid introducing new architectural layers unless required by a spec.
- Do not perform broad or stylistic refactors.
- Prefer clarity and locality over abstraction.
- Prefer extending or reusing existing abstractions over introducing new ones.
- Code must be structured for human review first, not for AI generation convenience.
## Testing Expectations
- Tests must validate behavior, not implementation details.
- Failure, cancellation, and degraded-network paths must be covered.
- Tests that only assert the happy path are insufficient.
## Anti-Patterns
- Silent failures
- Blocking the UI thread
- Artificial delays or polling
- Large refactors unrelated to the task
## Working With AI
This project uses a spec-first workflow to collaborate safely with AI.
### Documentation Discipline
- Markdown files must remain small, focused, and human-readable.
- Prefer extending existing documents over creating new ones.
- The goal is to do more with less, not to document everything.
### What Humans Own
- Guidelines under `spec/guidelines/` (never AI-modified)
- Feature specs under `spec/features/`
- Decisions to change behavior or architecture
### What AI Owns
- Work packets under `work/`
- Refinement of task plans during implementation
### Spec-First Rule
- Behavior changes require a feature spec first.
- During implementation, specs are read-only.
- If a spec is unclear or incorrect, AI must stop and report a "Spec Issue".
### Task Completeness
For non-trivial work, changes are not complete unless:
- Work packet reflects the actual work performed
- No significant code exists outside the task plan
- Edge cases and failure modes are addressed
This workflow exists to prevent AI drift, accidental refactors, and UX regressions.
+24
View File
@@ -0,0 +1,24 @@
# Zapstore — Vision
## What Zapstore Is
Zapstore is a local-first, Nostr-native app distribution platform focused on trust,
verification, and user sovereignty.
It enables discovering, installing, and supporting apps using open protocols
(Nostr + Bitcoin/Lightning), without centralized app store control.
## Who It Is For
- Users who value open networks and self-custody
- Developers distributing apps via Nostr
- Communities curating and funding software directly
## What Success Means
- Users can reliably discover and install apps offline-first
- Installs and updates are verifiable and safe
- Developers can be supported directly via zaps
## Non-Goals
- Zapstore is not a general-purpose app marketplace
- Zapstore does not optimize for engagement or growth metrics
- Zapstore does not require real-world identity or accounts
- Zapstore does not hide trust, verification, or provenance from users
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,29 +0,0 @@
{
"model": "claude-sonnet-4",
"temperature": 0.2,
"mcpServers": {
"purplestack": {
"type": "stdio",
"command": "bash",
"args": [
"tools/scripts/run-mcp.sh"
]
},
"nostr": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@nostrbook/mcp@latest"
]
},
"developer": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@soapbox.pub/developer-mcp@latest"
]
}
}
}
Binary file not shown.
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
set -e
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
echo "Generating MCP content..."
cd "$SCRIPT_DIR/../../../../purplebase/purplestack-context"
./generate-content.sh
echo "Copying MCP content to project..."
cp "$SCRIPT_DIR/../../../../purplebase/purplestack/tools/content/mcp-content.zip" "$SCRIPT_DIR/mcp-content.zip"
echo "MCP content updated successfully!"
Binary file not shown.
-372
View File
@@ -1,372 +0,0 @@
import 'dart:io';
import 'dart:convert';
import 'package:archive/archive.dart';
import 'package:path/path.dart' as path;
import 'package:bm25/bm25.dart';
import 'package:mcp_dart/mcp_dart.dart';
/// Main entry point for the MCP server
void main() async {
final server = PurpleStackMcpServer();
try {
await server.initialize();
server.start();
} catch (e) {
stderr.writeln('Failed to initialize server: $e');
exit(1);
}
}
/// MCP server that serves recipes and API documentation
class PurpleStackMcpServer {
static String get contentZipPath {
final scriptUri = Platform.script;
final scriptDir = path.dirname(scriptUri.toFilePath());
return path.normalize(
path.join(scriptDir, '..', 'content', 'mcp-content.zip'),
);
}
late McpServer _server;
// Content storage - now using full paths as keys
final Map<String, String> _recipes = {};
final Map<String, String> _docs = {};
// Search indexes
BM25? _recipeSearchIndex;
BM25? _docSearchIndex;
List<String> _recipePaths = [];
List<String> _docPaths = [];
PurpleStackMcpServer();
/// Initialize the server and load content
Future<void> initialize() async {
// Create MCP server
_server = McpServer(
Implementation(name: 'Purplestack Context Server', version: '1.0.0'),
options: ServerOptions(
capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()),
),
);
// Load content from zip file
await _loadContent();
// Build search indexes
await _buildSearchIndexes();
// Register tools
_registerTools();
}
/// Start the MCP server
void start() {
_server.connect(StdioServerTransport());
}
/// Load content from the zip file
Future<void> _loadContent() async {
final file = File(contentZipPath);
if (!file.existsSync()) {
throw Exception('Content zip file not found: $contentZipPath');
}
final bytes = await file.readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
for (final file in archive) {
if (file.isFile &&
(file.name.endsWith('.md') || file.name.endsWith('.html'))) {
final content = utf8.decode(file.content as List<int>);
if (file.name.startsWith('recipes/')) {
// Use full path as key, removing only the 'recipes/' prefix
final recipePath = file.name.substring('recipes/'.length);
_recipes[recipePath] = content;
} else if (file.name.startsWith('api-docs/')) {
// Use full path as key, removing only the 'api-docs/' prefix
final docPath = file.name.substring('api-docs/'.length);
_docs[docPath] = content;
}
}
}
}
/// Build search indexes for recipes and docs
Future<void> _buildSearchIndexes() async {
if (_recipes.isNotEmpty) {
// Build recipe search index
_recipePaths = _recipes.keys.toList();
final recipeDocuments = _recipes.entries.map((entry) {
return '${entry.key} ${entry.value}';
}).toList();
_recipeSearchIndex = await BM25.build(recipeDocuments);
}
if (_docs.isNotEmpty) {
// Build docs search index
_docPaths = _docs.keys.toList();
final docDocuments = _docs.entries.map((entry) {
return '${entry.key} ${entry.value}';
}).toList();
_docSearchIndex = await BM25.build(docDocuments);
}
}
/// Register all tools with the MCP server
void _registerTools() {
// List recipes tool
_server.tool(
'list_recipes',
description: 'List all available recipes',
callback: ({args, extra}) async {
final result = await _listRecipes({});
return CallToolResult.fromContent(content: [TextContent(text: result)]);
},
);
// Read recipe tool
_server.tool(
'read_recipe',
description: 'Read a specific recipe by name',
inputSchemaProperties: {
'name': {'type': 'string', 'description': 'Name of the recipe to read'},
},
callback: ({args, extra}) async {
final result = await _readRecipe(args ?? {});
return CallToolResult.fromContent(content: [TextContent(text: result)]);
},
);
// Search recipes tool
_server.tool(
'search_recipes',
description: 'Search recipes by query',
inputSchemaProperties: {
'query': {'type': 'string', 'description': 'Search query for recipes'},
},
callback: ({args, extra}) async {
final result = await _searchRecipes(args ?? {});
return CallToolResult.fromContent(content: [TextContent(text: result)]);
},
);
// List docs tool
_server.tool(
'list_docs',
description: 'List all available documentation',
callback: ({args, extra}) async {
final result = await _listDocs({});
return CallToolResult.fromContent(content: [TextContent(text: result)]);
},
);
// Read doc tool
_server.tool(
'read_doc',
description: 'Read a specific document by name',
inputSchemaProperties: {
'name': {
'type': 'string',
'description': 'Name of the document to read',
},
},
callback: ({args, extra}) async {
final result = await _readDoc(args ?? {});
return CallToolResult.fromContent(content: [TextContent(text: result)]);
},
);
// Search docs tool
_server.tool(
'search_docs',
description: 'Search documentation by query',
inputSchemaProperties: {
'query': {
'type': 'string',
'description': 'Search query for documentation',
},
},
callback: ({args, extra}) async {
final result = await _searchDocs(args ?? {});
return CallToolResult.fromContent(content: [TextContent(text: result)]);
},
);
}
// Tool handlers
Future<String> _listRecipes(Map<String, dynamic> arguments) async {
if (_recipes.isEmpty) {
return 'No recipes available.';
}
final recipeList = _recipes.keys.toList()..sort();
return 'Available recipes:\n${recipeList.map((path) => '- $path').join('\n')}';
}
Future<String> _readRecipe(Map<String, dynamic> arguments) async {
final name = arguments['name'] as String?;
if (name == null) {
return 'Error: Recipe name is required';
}
// Try exact match first
var recipe = _recipes[name];
if (recipe != null) {
return recipe;
}
// Try partial match for backwards compatibility
final matchingKeys = _recipes.keys
.where(
(key) =>
key.toLowerCase().contains(name.toLowerCase()) ||
path.basenameWithoutExtension(key).toLowerCase() ==
name.toLowerCase(),
)
.toList();
if (matchingKeys.length == 1) {
return _recipes[matchingKeys.first]!;
} else if (matchingKeys.length > 1) {
return 'Multiple recipes found. Please be more specific:\n${matchingKeys.map((key) => '- $key').join('\n')}';
}
final suggestions = _findSimilarKeys(name, _recipes.keys.toList());
final suggestionText = suggestions.isNotEmpty
? '\n\nDid you mean: ${suggestions.join(', ')}?'
: '';
return 'Recipe "$name" not found.$suggestionText';
}
Future<String> _searchRecipes(Map<String, dynamic> arguments) async {
final query = arguments['query'] as String?;
if (query == null || query.isEmpty) {
return 'Error: Search query is required';
}
if (_recipeSearchIndex == null) {
return 'Search index not available';
}
final results = await _recipeSearchIndex!.search(query);
if (results.isEmpty) {
return 'No recipes found for query: "$query"';
}
// Build documents list for index lookup
final recipeDocuments = _recipes.entries.map((entry) {
return '${entry.key} ${entry.value}';
}).toList();
final resultText = StringBuffer('Search results for "$query":\n\n');
for (final result in results.take(5)) {
final index = recipeDocuments.indexOf(result.doc.text);
if (index != -1) {
final recipePath = _recipePaths[index];
final score = result.score.toStringAsFixed(2);
resultText.writeln('$recipePath ($score)');
}
}
return resultText.toString().trim();
}
Future<String> _listDocs(Map<String, dynamic> arguments) async {
if (_docs.isEmpty) {
return 'No documentation available.';
}
final docList = _docs.keys.toList()..sort();
return 'Available documentation:\n${docList.map((path) => '- $path').join('\n')}';
}
Future<String> _readDoc(Map<String, dynamic> arguments) async {
final name = arguments['name'] as String?;
if (name == null) {
return 'Error: Document name is required';
}
// Try exact match first
var doc = _docs[name];
if (doc != null) {
return doc;
}
// Try partial match for backwards compatibility
final matchingKeys = _docs.keys
.where(
(key) =>
key.toLowerCase().contains(name.toLowerCase()) ||
path.basenameWithoutExtension(key).toLowerCase() ==
name.toLowerCase(),
)
.toList();
if (matchingKeys.length == 1) {
return _docs[matchingKeys.first]!;
} else if (matchingKeys.length > 1) {
return 'Multiple documents found. Please be more specific:\n${matchingKeys.map((key) => '- $key').join('\n')}';
}
final suggestions = _findSimilarKeys(name, _docs.keys.toList());
final suggestionText = suggestions.isNotEmpty
? '\n\nDid you mean: ${suggestions.join(', ')}?'
: '';
return 'Document "$name" not found.$suggestionText';
}
Future<String> _searchDocs(Map<String, dynamic> arguments) async {
final query = arguments['query'] as String?;
if (query == null || query.isEmpty) {
return 'Error: Search query is required';
}
if (_docSearchIndex == null) {
return 'Search index not available';
}
final results = await _docSearchIndex!.search(query);
if (results.isEmpty) {
return 'No documentation found for query: "$query"';
}
// Build documents list for index lookup
final docDocuments = _docs.entries.map((entry) {
return '${entry.key} ${entry.value}';
}).toList();
final resultText = StringBuffer('Search results for "$query":\n\n');
for (final result in results.take(5)) {
final index = docDocuments.indexOf(result.doc.text);
if (index != -1) {
final docPath = _docPaths[index];
final score = result.score.toStringAsFixed(2);
resultText.writeln('$docPath ($score)');
}
}
return resultText.toString().trim();
}
/// Find similar keys for suggestions
List<String> _findSimilarKeys(String input, List<String> keys) {
final inputLower = input.toLowerCase();
return keys
.where(
(key) =>
key.toLowerCase().contains(inputLower) ||
inputLower.contains(key.toLowerCase()) ||
path
.basenameWithoutExtension(key)
.toLowerCase()
.contains(inputLower),
)
.take(3)
.toList();
}
}
-545
View File
@@ -1,545 +0,0 @@
// ignore_for_file: avoid_print
import 'dart:io';
import 'package:args/args.dart';
const String kOriginalAppId = 'com.example.purplestack';
const String kOriginalAppName = 'Purplestack';
const String kOriginalAppNameSnakeCase = 'purplestack';
/// Converts a string to PascalCase (removes spaces and capitalizes each word)
String _toPascalCase(String input) {
return input
.split(RegExp(r'[\s_-]+')) // Split on spaces, underscores, and hyphens
.where((word) => word.isNotEmpty)
.map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase())
.join('');
}
void main(List<String> args) async {
final parser = ArgParser()
..addOption('name', abbr: 'n', help: 'App display name')
..addOption(
'app-id',
abbr: 'i',
help: 'App ID in reverse domain notation (e.g., com.company.app)',
)
..addOption(
'description',
abbr: 'd',
help: 'App description for pubspec.yaml',
)
..addOption(
'version',
abbr: 'v',
help: 'App version for pubspec.yaml (default: 0.1.0)',
)
..addOption('icon', help: 'Path to main app icon image')
..addOption(
'adaptive-background',
help: 'Path to adaptive background image (Android)',
)
..addOption(
'adaptive-foreground',
help: 'Path to adaptive foreground image (Android)',
)
..addOption(
'adaptive-monochrome',
help: 'Path to adaptive monochrome image (Android)',
)
..addOption(
'notification-icon',
help: 'Path to notification icon image (Android)',
)
..addFlag(
'help',
abbr: 'h',
help: 'Show usage information',
negatable: false,
);
late ArgResults results;
try {
results = parser.parse(args);
} catch (e) {
print('Error: $e\n');
_printUsage(parser);
exit(1);
}
if (results['help'] as bool) {
_printUsage(parser);
exit(0);
}
// Check for mandatory parameters
if (results['name'] == null) {
print('Error: --name is required\n');
_printUsage(parser);
exit(1);
}
if (results['app-id'] == null) {
print('Error: --app-id is required\n');
_printUsage(parser);
exit(1);
}
final appName = results['name'] as String;
final appId = results['app-id'] as String;
final appDescription = results['description'] as String?;
final appVersion = results['version'] as String? ?? '0.1.0';
// Create PascalCase version for Dart code (removes spaces, capitalizes each word)
final appNamePascalCase = _toPascalCase(appName);
final iconPath = results['icon'] as String?;
final adaptiveBackground = results['adaptive-background'] as String?;
final adaptiveForeground = results['adaptive-foreground'] as String?;
final adaptiveMonochrome = results['adaptive-monochrome'] as String?;
final notificationIcon = results['notification-icon'] as String?;
// Validate app ID format
if (!RegExp(r'^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$').hasMatch(appId)) {
print(
'Error: App ID must be in reverse domain notation (e.g., com.company.app)',
);
print(
'Use only lowercase letters, numbers, and dots. Each segment must start with a letter.',
);
exit(1);
}
// Validate version format
if (!RegExp(r'^\d+\.\d+\.\d+(\+\d+)?$').hasMatch(appVersion)) {
print(
'Error: Version must be in format x.y.z or x.y.z+build (e.g., 1.0.0 or 1.0.0+1)',
);
exit(1);
}
// Validate icon paths exist if provided
final iconPaths = [
iconPath,
adaptiveBackground,
adaptiveForeground,
adaptiveMonochrome,
notificationIcon,
].where((path) => path != null).cast<String>();
for (final iconPathToCheck in iconPaths) {
if (!File(iconPathToCheck).existsSync()) {
print('Error: Icon file not found: $iconPathToCheck');
exit(1);
}
}
final appNameSnakeCase = appName.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
'_',
);
print('Renaming Electric app...');
print('Original App ID: $kOriginalAppId → $appId');
print('Original App Name: $kOriginalAppName → $appName');
print('App Name (PascalCase): $appNamePascalCase');
print('Original Snake Case: $kOriginalAppNameSnakeCase → $appNameSnakeCase');
if (appDescription != null) print('Description: $appDescription');
print('Version: $appVersion');
if (iconPath != null) print('Main Icon: $iconPath');
if (adaptiveBackground != null) {
print('Adaptive Background: $adaptiveBackground');
}
if (adaptiveForeground != null) {
print('Adaptive Foreground: $adaptiveForeground');
}
if (adaptiveMonochrome != null) {
print('Adaptive Monochrome: $adaptiveMonochrome');
}
if (notificationIcon != null) print('Notification Icon: $notificationIcon');
print('');
final renamer = AppRenamer(
appName,
appNamePascalCase,
appId,
appNameSnakeCase,
appDescription,
appVersion,
iconPath: iconPath,
adaptiveBackground: adaptiveBackground,
adaptiveForeground: adaptiveForeground,
adaptiveMonochrome: adaptiveMonochrome,
notificationIcon: notificationIcon,
);
try {
await renamer.renameApp();
// Clean and get dependencies
await _cleanAndGetDependencies();
// Generate icons if any icon paths were provided
if (renamer._hasIconPaths()) {
await renamer._generateIcons();
}
print('\n✅ Electric app renamed successfully!');
print('\nNext steps:');
print('1. Test the app on your target platforms');
print('2. Commit your changes to version control');
} catch (e) {
print('\n❌ Error renaming app: $e');
exit(1);
}
}
void _printUsage(ArgParser parser) {
print(
'Electric App Renamer - Rename your Electric app across all platforms\n',
);
print(
'Usage: dart rename_app.dart --name "App Name" --app-id "com.company.app" [options]\n',
);
print(
'This tool will search all files in android/, ios/, lib/, linux/, macos/, test/, windows/ and update pubspec.yaml',
);
print('(excludes tools/ directory)');
print('and replace:');
print('- "$kOriginalAppId" with your new app ID (FIRST)');
print(
'- "$kOriginalAppName" with your new app name in PascalCase (for code)',
);
print('- "$kOriginalAppNameSnakeCase" with your new app name in snake_case');
print('- "com.example" with your new app name (LAST)');
print('- pubspec.yaml name, description, and version fields specifically');
print('- version defaults to 0.1.0 if not provided\n');
print('Options:');
print(parser.usage);
print('\nExamples:');
print(' # Basic rename (spaces in name are allowed)');
print(
' dart rename_app.dart --name "My Super App" --app-id "com.mycompany.myapp"',
);
print('');
print(' # With description and version');
print(
' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" --description "A Flutter app for managing tasks" --version "1.0.0"',
);
print('');
print(' # With main icon');
print(
' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" --icon "assets/icon.png"',
);
print('');
print(' # With Android adaptive icons');
print(
' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" \\',
);
print(' --icon "assets/icon.png" \\');
print(' --adaptive-background "assets/adaptive-bg.png" \\');
print(' --adaptive-foreground "assets/adaptive-fg.png" \\');
print(' --adaptive-monochrome "assets/adaptive-mono.png"');
}
class AppRenamer {
final String appName;
final String appNamePascalCase;
final String appId;
final String appNameSnakeCase;
final String? appDescription;
final String appVersion;
final String? iconPath;
final String? adaptiveBackground;
final String? adaptiveForeground;
final String? adaptiveMonochrome;
final String? notificationIcon;
AppRenamer(
this.appName,
this.appNamePascalCase,
this.appId,
this.appNameSnakeCase,
this.appDescription,
this.appVersion, {
this.iconPath,
this.adaptiveBackground,
this.adaptiveForeground,
this.adaptiveMonochrome,
this.notificationIcon,
});
Future<void> renameApp() async {
print('🔍 Searching and replacing in all files...');
// Directories to search (excluding tools)
final searchDirs = [
'android',
'ios',
'lib',
'linux',
'macos',
'test',
'windows',
];
int filesProcessed = 0;
int filesChanged = 0;
// Process pubspec.yaml specifically
final pubspecFile = File('pubspec.yaml');
if (pubspecFile.existsSync()) {
final result = await _processPubspecFile(pubspecFile);
filesProcessed++;
if (result) filesChanged++;
}
// Process all files in search directories
for (final dirName in searchDirs) {
final dir = Directory(dirName);
if (!dir.existsSync()) {
print('⚠️ Directory $dirName not found, skipping');
continue;
}
await for (final entity in dir.list(recursive: true)) {
if (entity is File) {
final result = await _processFile(entity);
filesProcessed++;
if (result) filesChanged++;
}
}
}
print('✅ Processed $filesProcessed files, changed $filesChanged files');
}
Future<bool> _processPubspecFile(File file) async {
try {
String content = await file.readAsString();
String originalContent = content;
// Replace the exact snake_case project name
content = content.replaceAll(
RegExp('^name:\\s+$kOriginalAppNameSnakeCase\$', multiLine: true),
'name: $appNameSnakeCase',
);
// Replace description if provided
if (appDescription != null) {
content = content.replaceAll(
RegExp(r'^description:.*$', multiLine: true),
'description: $appDescription',
);
}
// Replace version (always overwrite with default 0.1.0 or provided value)
content = content.replaceAll(
RegExp(r'^version:\s+[\d\.]+(\+\d+)?$', multiLine: true),
'version: $appVersion',
);
// Write back if content changed
if (content != originalContent) {
await file.writeAsString(content);
print(' ✓ Updated ${file.path}');
return true;
}
return false;
} catch (e) {
print(' ⚠️ Error updating ${file.path}: $e');
return false;
}
}
Future<bool> _processFile(File file) async {
try {
String content = await file.readAsString();
String originalContent = content;
// Perform exact string replacements - APP ID FIRST!
content = content.replaceAll(kOriginalAppId, appId);
// Use PascalCase for Dart code identifiers (no spaces)
content = content.replaceAll(kOriginalAppName, appNamePascalCase);
content = content.replaceAll(kOriginalAppNameSnakeCase, appNameSnakeCase);
// For display names in platform files, we might want the full name with spaces
// Check if this is a platform-specific file that needs display names
if (_shouldUseDisplayName(file.path)) {
// In platform files, replace PascalCase back with display name for labels
content = content.replaceAll(appNamePascalCase, appName);
}
content = content.replaceAll('com.example', appName);
// Write back if content changed
if (content != originalContent) {
await file.writeAsString(content);
print(' ✓ Updated ${file.path}');
return true;
}
return false;
} catch (e) {
// Skip binary files or files we can't read/write
return false;
}
}
/// Determines if a file should use the display name (with spaces) instead of PascalCase
/// Platform files like AndroidManifest.xml and Info.plist need display names for labels
bool _shouldUseDisplayName(String filePath) {
return filePath.contains('AndroidManifest.xml') ||
filePath.contains('Info.plist') ||
filePath.contains('strings.xml') ||
filePath.contains('.xml') && filePath.contains('android') ||
filePath.endsWith('.plist');
}
bool _hasIconPaths() {
return iconPath != null ||
adaptiveBackground != null ||
adaptiveForeground != null ||
adaptiveMonochrome != null ||
notificationIcon != null;
}
Future<void> _generateIcons() async {
print('🎨 Generating app icons...');
final pubspecFile = File('pubspec.yaml');
if (!pubspecFile.existsSync()) {
throw Exception('pubspec.yaml not found');
}
String originalContent = await pubspecFile.readAsString();
String modifiedContent = originalContent;
try {
// Add icons_launcher configuration to pubspec.yaml
final iconsConfig = _buildIconsLauncherConfig();
modifiedContent = '$originalContent\n$iconsConfig';
await pubspecFile.writeAsString(modifiedContent);
// Run icons_launcher
final result = await Process.run('dart', [
'run',
'icons_launcher:create',
]);
if (result.exitCode == 0) {
print('✅ Icons generated successfully');
if (result.stdout.toString().isNotEmpty) {
print('Icons output: ${result.stdout}');
}
} else {
print('❌ Failed to generate icons');
print('Error: ${result.stderr}');
throw Exception('Icon generation failed: ${result.stderr}');
}
} finally {
// Restore original pubspec.yaml
await pubspecFile.writeAsString(originalContent);
}
}
String _buildIconsLauncherConfig() {
final buffer = StringBuffer();
buffer.writeln('icons_launcher:');
// Main icon path (required by icons_launcher)
final mainIcon = iconPath ?? adaptiveForeground ?? adaptiveBackground;
if (mainIcon != null) {
buffer.writeln(' image_path: "$mainIcon"');
}
buffer.writeln(' platforms:');
// Android configuration
buffer.writeln(' android:');
buffer.writeln(' enable: true');
if (iconPath != null) {
buffer.writeln(' image_path: "$iconPath"');
}
if (notificationIcon != null) {
buffer.writeln(' notification_image: "$notificationIcon"');
} else if (iconPath != null) {
buffer.writeln(' notification_image: "$iconPath"');
}
if (adaptiveBackground != null) {
buffer.writeln(' adaptive_background_image: "$adaptiveBackground"');
}
if (adaptiveForeground != null) {
buffer.writeln(' adaptive_foreground_image: "$adaptiveForeground"');
}
if (adaptiveMonochrome != null) {
buffer.writeln(' adaptive_monochrome_image: "$adaptiveMonochrome"');
}
// iOS configuration
buffer.writeln(' ios:');
buffer.writeln(' enable: true');
if (iconPath != null) {
buffer.writeln(' image_path: "$iconPath"');
}
// macOS configuration
buffer.writeln(' macos:');
buffer.writeln(' enable: true');
if (iconPath != null) {
buffer.writeln(' image_path: "$iconPath"');
}
// Windows configuration
buffer.writeln(' windows:');
buffer.writeln(' enable: true');
if (iconPath != null) {
buffer.writeln(' image_path: "$iconPath"');
}
// Linux configuration
buffer.writeln(' linux:');
buffer.writeln(' enable: true');
if (iconPath != null) {
buffer.writeln(' image_path: "$iconPath"');
}
return buffer.toString();
}
}
Future<void> _cleanAndGetDependencies() async {
print('\n🧹 Cleaning pub cache and getting dependencies...');
try {
// Check if we have flutter command available
print('Running flutter clean...');
final cleanResult = await Process.run('flutter', ['clean']);
if (cleanResult.exitCode != 0) {
print('⚠️ Flutter clean failed, but continuing...');
}
print('Running dart pub cache clean...');
final cacheResult = await Process.run('dart', ['pub', 'cache', 'clean']);
if (cacheResult.exitCode != 0) {
print('⚠️ Pub cache clean failed, but continuing...');
}
print('Running flutter pub get...');
final pubGetResult = await Process.run('flutter', ['pub', 'get']);
if (pubGetResult.exitCode != 0) {
print('❌ Flutter pub get failed:');
print(pubGetResult.stderr);
throw Exception('Failed to run flutter pub get');
}
print('✓ Dependencies updated successfully');
} catch (e) {
print('⚠️ Error cleaning dependencies: $e');
print('Please run the following commands manually:');
print(' flutter clean');
print(' dart pub cache clean');
print(' flutter pub get');
}
}
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
# Run the purplestack MCP server
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(fvm flutter pub get || flutter pub get) && dart run "$SCRIPT_DIR/purplestack_mcp.dart"
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -e
UPSTREAM_URL="https://github.com/purplebase/purplestack.git"
UPSTREAM_BRANCH="main"
UPSTREAM_LOCAL_DIR=/tmp/purplestack-tmp
FOLDER="tools"
# 1. shallow clone upstream into a hidden mirror
if [ ! -d "$UPSTREAM_LOCAL_DIR" ]; then
git clone --depth 1 --branch "$UPSTREAM_BRANCH" "$UPSTREAM_URL" "$UPSTREAM_LOCAL_DIR"
fi
# 2. fetch latest
git -C "$UPSTREAM_LOCAL_DIR" fetch --depth 1 origin "$UPSTREAM_BRANCH"
git -C "$UPSTREAM_LOCAL_DIR" reset --hard "origin/$UPSTREAM_BRANCH"
# 3. copy only the folder we care about
rm -rf "$FOLDER"
cp -a "$UPSTREAM_LOCAL_DIR/$FOLDER" "$(dirname "$FOLDER")"
fvm flutter pub upgrade || flutter pub upgrade
+91
View File
@@ -0,0 +1,91 @@
# WORK-XXX — Short Name
**Feature:** FEAT-XXX-short-name.md
**Status:** In Progress | Complete
## Tasks
- [ ] 1. Task description
- Files: `lib/path/to/file.dart`
- Notes: any relevant context
- [ ] 2. Task description
- [ ] 3. Handle edge cases per spec
- [ ] 4. Self-review against INVARIANTS.md
## Test Coverage
| Scenario | Expected | Status |
|----------|----------|--------|
| Happy path | Describe expected behavior | [ ] |
| Edge case: network failure | Graceful degradation | [ ] |
| Edge case: cancellation | Clean cleanup | [ ] |
## Decisions
### YYYY-MM-DD — Decision title
**Context:** Why this decision came up.
**Options:** A, B, C considered.
**Decision:** Chosen option.
**Rationale:** Why.
## Spec Issues
Report blockers here instead of guessing. Format:
- **Issue:** Description of unclear/incorrect spec
- **Question:** What clarification is needed
## Progress Notes
Brief updates as work proceeds.
---
# Example: WORK-002 — NWC Zaps
**Feature:** FEAT-002-nwc-zaps.md
**Status:** In Progress
## Tasks
- [x] 1. Add nwc_wallet package dependency
- [x] 2. Create NwcService in lib/services/
- Files: `lib/services/nwc_service.dart`
- Stores connection string in SecureStorageService
- [x] 3. Add NWC settings UI in profile screen
- Files: `lib/screens/profile_screen.dart`, `lib/widgets/nwc_widgets.dart`
- [ ] 4. Create ZapButton widget
- Files: `lib/widgets/zap_button.dart`
- [ ] 5. Create ZapDialog for amount selection
- [ ] 6. Integrate into AppDetailScreen
- [ ] 7. Implement zap request flow (kind 9734)
- [ ] 8. Self-review against INVARIANTS.md
## Test Coverage
| Scenario | Expected | Status |
|----------|----------|--------|
| Connect valid NWC | Connection saved, status updated | [x] |
| Connect invalid NWC | Error shown, nothing saved | [x] |
| Zap with sufficient balance | Success toast | [ ] |
| Zap with insufficient balance | Wallet error shown | [ ] |
| Developer has no LN address | Button disabled with tooltip | [ ] |
## Decisions
### 2026-01-15 — NWC string storage
**Context:** Need to persist NWC connection string securely.
**Options:** New encrypted file, SecureStorageService, Nostr event.
**Decision:** SecureStorageService.
**Rationale:** Already used for nsec, platform-native secure storage.
## Spec Issues
_None_
## Progress Notes
**2026-01-15:** Completed NWC connection flow. Reused SecureStorageService.
**2026-01-17:** ZapButton done. Added edge case for missing LN address to test matrix.