From 1072d4cbdbe934d441912d93259f06c32fe36ffd Mon Sep 17 00:00:00 2001 From: jdluu Date: Tue, 25 Aug 2026 17:16:38 -0700 Subject: [PATCH 1/2] docs: add comprehensive refactor plan --- .../2026-08-25-comprehensive-refactor.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/plans/2026-08-25-comprehensive-refactor.md diff --git a/docs/plans/2026-08-25-comprehensive-refactor.md b/docs/plans/2026-08-25-comprehensive-refactor.md new file mode 100644 index 0000000..c7bb7f9 --- /dev/null +++ b/docs/plans/2026-08-25-comprehensive-refactor.md @@ -0,0 +1,140 @@ +# FlexInsight Comprehensive Refactor Plan + +> **For Hermes:** Orchestrate via OpenCode (`opencode run`) as the implementing junior engineer. +> One PR per phase. Review every diff as senior engineer before merge. + +**Goal:** Refactor FlexInsight to SOLID/DRY standards with comprehensive test coverage, hooked into CI, without breaking existing features. + +**Architecture:** Incremental refactor on `main`-adjacent feature branches. Each phase lands a PR that keeps the build green (`assembleDebug` + `testDebugUnitTest` + `lintDebug`). No behavior changes except where explicitly fixing defects found during testing. + +**Tech Stack:** Kotlin 2.1.20, Compose, Room 2.7, Hilt 2.56, Retrofit/OkHttp, WorkManager, ML Kit GenAI (Gemini Nano), JUnit4 + Robolectric + Turbine + MockK/Mockito, GitHub Actions CI. + +--- + +## Baseline (verified) + +- ~18.5k LOC Kotlin across data/domain/ui layers; clean layered structure already present +- Existing tests: 10 unit test files + 1 migration instrumented test — far from comprehensive +- **No CI at all** (no `.github/`) +- Largest hotspots: StatsRepositoryImpl (555), WorkoutRepositoryImpl (525), SettingsScreen (440), HevyAiDataAccessor (402), SettingsViewModel (383) +- Known duplication: stats calculations spread across StatsRepositoryImpl, StatsCalculator, DashboardStats.kt, HistoryStats.kt, WorkoutStats.kt (UI copies) +- Build env: JDK 21 works for AGP 8.13; Android SDK at ~/.local/android-sdk; adb device OFF-LIMITS (occupied by another session) — all tests must be JVM-side (Robolectric) or non-emulator instrumented only in CI via emulator later if user approves + +## Guiding principles + +1. Every PR: build green + all tests pass + lint passes before merge. +2. TDD: new abstractions get tests written first against current behavior (characterization tests where behavior is subtle, e.g., training load, recovery score, deload detection). +3. No device/emulator use. Robolectric for anything Android-framework-dependent. +4. Small PRs (~300-600 diff lines). Conventional Commits. Squash merges. +5. Extensibility targets: AI provider abstraction (Gemini Nano today, others tomorrow), repository interfaces already exist but leak implementation details; sync pipeline hardwired to Hevy. + +## Phase 0 — CI bootstrap (PR #1, branch ci/github-actions) + +Files: +- `.github/workflows/ci.yml` + +Steps: +1. Write workflow: JDK 17 (temurin), gradle cache, jobs: + - `lint`: ./gradlew lintDebug + - `unit`: ./gradlew testDebugUnitTest (+ publish JUnit XML report summary) + - `build`: ./gradlew assembleDebug, upload APK artifact on main +2. Verify locally first: run all three commands on this machine, confirm exit 0. +3. Push, open PR, watch checks pass, merge. +4. Add branch protection is out of scope (user decision). + +Acceptance: PR green on GitHub Actions; subsequent PRs gated by it. + +## Phase 1 — Test foundation & characterization tests (PR #2, branch test/foundation) + +Files: +- `gradle/libs.versions.toml`: add turbine, mockk, truth (or assertk) +- Create `app/src/test/.../domain/usecase/*Test.kt` for ALL existing use cases: + - CalculateTrainingLoadUseCase, DetectDeloadUseCase, GetMuscleRecoveryUseCase, + GetWeeklyProgressUseCase, GetMuscleGroupProgressUseCase, GetPRDetailsUseCase, + CompareRoutineSessionsUseCase, ExplainWorkoutUseCase, ExportCoachReportUseCase, + BuildAiContextUseCase +- Create `app/src/test/.../data/repository/*Test.kt` for repository impls using fake DAOs (no mock-heavy tests for logic-bearing code; fakes over mocks per Google guidance) +- Create `app/src/test/.../core/errors/ErrorHandlerTest.kt`, ResultTest, NetworkMonitorTest (Robolectric) + +Steps (per module): write failing/skeleton test -> confirm it compiles and characterizes CURRENT behavior -> document any surprising behavior in test comments (do not fix yet). +Run: `./gradlew testDebugUnitTest` — expect all pass (characterization = encode reality). + +Acceptance: every domain use case and repository has a test file; suite green; coverage of domain/data logic substantially up (report jacoco numbers). + +Add `.github/workflows` step or separate job: JaCoCo coverage report artifact (add jacoco to app/build.gradle.kts). + +## Phase 2 — Domain purity & DRY stats consolidation (PR #3, branch refactor/stats-core) + +Problem: stat math duplicated between StatsRepositoryImpl / StatsCalculator / UI composables. + +Steps: +1. Extract pure calculation functions into `domain/calc/` (e.g., VolumeCalculator, TrainingLoadCalculator, RecoveryScoreCalculator) — no Android deps, no coroutines, plain input->output. +2. Move logic from StatsRepositoryImpl + StatsCalculator into these; repositories become orchestration-only (SRP). +3. Update UI parts files to call shared calculators instead of local copies (delete duplicated math). +4. Characterization tests from Phase 1 must still pass unchanged (this proves no behavior change). Add unit tests for each extracted calculator directly. + +Acceptance: grep shows zero volume/EMA/load math outside `domain/calc/`; full suite green; net LOC reduction in data layer. + +## Phase 3 — Repository SRP split (PR #4, branch refactor/repo-split) + +Steps: +1. Split WorkoutRepositoryImpl (525L): separate query concerns (read paths) from mutation/sync-write concerns into WorkoutQueryRepository / WorkoutMutationRepository behind existing interfaces; keep old interface delegating so ViewModels unchanged initially. +2. Same treatment for StatsRepositoryImpl if still oversized after Phase 2. +3. Introduce mappers module `data/mapper/` — move API-entity <-> domain mapping out of repositories (single responsibility, reusable). +4. Tests: fakes updated; new mapper tests. + +Acceptance: repos under ~250 lines; ViewModels untouched; suite green. + +## Phase 4 — AI layer extensibility (PR #5, branch refactor/ai-provider) + +Steps: +1. Define `interface AiClient { suspend fun generate(prompt: AiPrompt): Result; fun isAvailable(): Flow }`. +2. GeminiNanoClient implements AiClient; FlexAIClient becomes a facade selecting provider (Strategy pattern); DI binds via @Binds in AiModule. +3. Extract prompt assembly from HevyAiDataAccessor into `domain/ai/PromptBuilder` (pure, testable). +4. Tests: PromptBuilder exhaustive tests (context truncation, exercise-history injection, empty states); FakeAiClient for AITrainerViewModel tests. + +Acceptance: adding a second AI provider = one new class + one DI line. AITrainerViewModel tested against fake. + +## Phase 5 — Sync pipeline abstraction (PR #6, branch refactor/sync-pipeline) + +Steps: +1. Extract `HevySyncSource` interface (fetch workouts/routines/templates pages) from SyncCoordinator/SyncManager; inject instead of direct Retrofit service calls. +2. SyncManager orchestrates generic sources; Hevy becomes one implementation. +3. Tests: SyncCoordinator with fake source — incremental cursor handling, error/retry, offline skip paths. + +Acceptance: sync tests cover incremental/error/offline paths without network. + +## Phase 6 — UI layer hygiene (PR #7, branch refactor/ui-hygiene) + +Steps: +1. Break up 400+ line screens/parts files into smaller composables (mechanical, no visual change). +2. Standardize ViewModel state pattern: single immutable UiState data class per screen (where not already done), collected via `collectAsStateWithLifecycle`. +3. ViewModel tests with fakes + Turbine for state emissions (DashboardViewModel, PlannerViewModel, SettingsViewModel, AITrainerViewModel, HistoryViewModel). + +Acceptance: lint passes, no screen file >300 lines, viewmodel state tests green. + +## Phase 7 — Final hardening (PR #8, branch chore/hardening) + +1. Full JaCoCo coverage gate in CI (fail under threshold — set realistically based on Phase 1 baseline, e.g., 60% domain/data, exclude ui/theme/generated). +2. ktlint/detekt configured with lenient baseline; fix violations incrementally. +3. README architecture section update. +4. Tag v1.1.0 if user approves release. + +--- + +## Risks / tradeoffs + +- stealth/ox-alpha rate limits (popular free model): retry with backoff; fall back to poolside/laguna-s-2.1 if blocked for >10 min. +- Characterization tests may reveal real bugs — log them, do NOT fix inside refactor PRs; separate fix PRs. +- Robolectric can't cover WorkManager+Hilt worker init fully — BackgroundSyncWorker gets constructor-level unit tests only. +- No emulator available: instrumented migration test stays manual/local-device; CI runs JVM tests only (documented limitation). +- Compose refactors risk visual regressions — mechanical moves only, no restyling. + +## Verification protocol (every PR) + +``` +./gradlew assembleDebug testDebugUnitTest lintDebug # exit 0 required +git push -u origin # open PR via gh +gh pr checks --watch # all green +gh pr merge --squash --delete-branch +``` From b5f5ef57c11b62b4e824f21a3ab0ae5d11953077 Mon Sep 17 00:00:00 2001 From: jdluu Date: Tue, 25 Aug 2026 17:34:26 -0700 Subject: [PATCH 2/2] ci: add GitHub Actions workflow and fix all lint errors - Add ci.yml with lint, unit-test (JUnit report), and APK build jobs - Fix SuspiciousIndentation in BottomNavigation - Fix 20 LocalContextGetResourceValueCall errors by hoisting string resolution into composition in MainActivity, PlannerScreen, SettingsScreen; behavior preserved --- .github/workflows/ci.yml | 64 +++++++++++++++++++ .../com/jdluu/flexinsight/MainActivity.kt | 59 ++++++++--------- .../ui/components/BottomNavigation.kt | 2 +- .../flexinsight/ui/screens/PlannerScreen.kt | 45 +++++++------ .../flexinsight/ui/screens/SettingsScreen.kt | 50 ++++++++------- opencode.json | 17 +++++ 6 files changed, 160 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 opencode.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c8e56a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + - name: Run lint + run: ./gradlew lintDebug + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html + + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + - name: Run unit tests + run: ./gradlew testDebugUnitTest + - name: Publish test report + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: '**/build/test-results/testDebugUnitTest/TEST-*.xml' + check_name: Unit test results + + build: + name: Build APK + runs-on: ubuntu-latest + needs: [lint, unit-tests] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + - name: Assemble debug APK + run: ./gradlew assembleDebug + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/app/src/main/java/com/jdluu/flexinsight/MainActivity.kt b/app/src/main/java/com/jdluu/flexinsight/MainActivity.kt index ec587e1..83bf506 100644 --- a/app/src/main/java/com/jdluu/flexinsight/MainActivity.kt +++ b/app/src/main/java/com/jdluu/flexinsight/MainActivity.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -27,7 +26,6 @@ import com.jdluu.flexinsight.ui.navigation.FlexAppNavigation import com.jdluu.flexinsight.ui.navigation.Screen import com.jdluu.flexinsight.ui.theme.FlexInsightTheme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -86,38 +84,31 @@ fun MainScreen( ) val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - - LaunchedEffect(Unit) { - kotlinx.coroutines.coroutineScope { - launch { - syncPreferencesManager.pendingNewWorkoutsFlow.collect { pending -> - if (pending > 0) { - snackbarHostState.showSnackbar( - message = context.getString( - R.string.sync_snackbar_new_workouts, - pending - ), - actionLabel = context.getString(R.string.done) - ) - syncPreferencesManager.clearPendingNewWorkouts() - } - } - } - launch { - syncPreferencesManager.pendingDeletedWorkoutsFlow.collect { deleted -> - if (deleted > 0) { - snackbarHostState.showSnackbar( - message = context.getString( - R.string.sync_snackbar_deleted_workouts, - deleted - ), - actionLabel = context.getString(R.string.done) - ) - syncPreferencesManager.clearPendingDeletedWorkouts() - } - } - } + + val pendingNewWorkouts: Int? by syncPreferencesManager.pendingNewWorkoutsFlow + .collectAsStateWithLifecycle(initialValue = null) + val pendingDeletedWorkouts: Int? by syncPreferencesManager.pendingDeletedWorkoutsFlow + .collectAsStateWithLifecycle(initialValue = null) + + val doneLabel = stringResource(R.string.done) + val newWorkoutsMessage = pendingNewWorkouts?.takeIf { it > 0 }?.let { + stringResource(R.string.sync_snackbar_new_workouts, it) + } + val deletedWorkoutsMessage = pendingDeletedWorkouts?.takeIf { it > 0 }?.let { + stringResource(R.string.sync_snackbar_deleted_workouts, it) + } + + LaunchedEffect(newWorkoutsMessage) { + newWorkoutsMessage?.let { message -> + snackbarHostState.showSnackbar(message = message, actionLabel = doneLabel) + syncPreferencesManager.clearPendingNewWorkouts() + } + } + + LaunchedEffect(deletedWorkoutsMessage) { + deletedWorkoutsMessage?.let { message -> + snackbarHostState.showSnackbar(message = message, actionLabel = doneLabel) + syncPreferencesManager.clearPendingDeletedWorkouts() } } diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/components/BottomNavigation.kt b/app/src/main/java/com/jdluu/flexinsight/ui/components/BottomNavigation.kt index c9749bf..0854e73 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/components/BottomNavigation.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/components/BottomNavigation.kt @@ -39,7 +39,7 @@ fun FlexBottomNavigation( NavItem("settings", stringResource(id = R.string.nav_profile), Icons.Default.Person) ) - Box( + Box( modifier = modifier .fillMaxWidth() .windowInsetsPadding(WindowInsets.navigationBars) diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/screens/PlannerScreen.kt b/app/src/main/java/com/jdluu/flexinsight/ui/screens/PlannerScreen.kt index 63128d8..3c08c6f 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/screens/PlannerScreen.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/screens/PlannerScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource @@ -73,8 +72,8 @@ fun PlannerScreen( return } - val context = LocalContext.current var showRescheduleDialog by remember { mutableStateOf(null) } + val movedTomorrowMessage = stringResource(R.string.planner_snackbar_moved_tomorrow) showRescheduleDialog?.let { workout -> PlannerRescheduleDialog( @@ -85,8 +84,7 @@ fun PlannerScreen( workout.id?.let { id -> viewModel.rescheduleWorkout(id, calendar.timeInMillis) } - val snackbarMsg = context.getString(R.string.planner_snackbar_moved_tomorrow) - scope.launch { snackbarHostState.showSnackbar(snackbarMsg) } + scope.launch { snackbarHostState.showSnackbar(movedTomorrowMessage) } showRescheduleDialog = null }, onDismiss = { showRescheduleDialog = null } @@ -158,26 +156,33 @@ fun PlannerScreen( ) } + val saveSuccessMessage: String? = when (val status = uiState.saveToHevyStatus) { + is SaveToHevyStatus.Success -> when { + status.usedPlaceholder -> + stringResource(R.string.planner_save_success_placeholder) + status.unmatchedNames.isEmpty() -> + stringResource(R.string.planner_save_success_matched, status.matchedCount) + status.matchedCount == 0 -> + stringResource( + R.string.planner_save_success_unmatched, + status.unmatchedNames.joinToString(", ") + ) + else -> + stringResource( + R.string.planner_save_success_matched_and_unmatched, + status.matchedCount, + status.unmatchedNames.joinToString(", ") + ) + } + else -> null + } + LaunchedEffect(uiState.saveToHevyStatus) { when (val status = uiState.saveToHevyStatus) { is SaveToHevyStatus.Success -> { - val message = when { - status.usedPlaceholder -> context.getString(R.string.planner_save_success_placeholder) - status.unmatchedNames.isEmpty() -> context.getString( - R.string.planner_save_success_matched, - status.matchedCount - ) - status.matchedCount == 0 -> context.getString( - R.string.planner_save_success_unmatched, - status.unmatchedNames.joinToString(", ") - ) - else -> context.getString( - R.string.planner_save_success_matched_and_unmatched, - status.matchedCount, - status.unmatchedNames.joinToString(", ") - ) + saveSuccessMessage?.let { message -> + snackbarHostState.showSnackbar(message) } - snackbarHostState.showSnackbar(message) viewModel.clearSaveStatus() } is SaveToHevyStatus.Error -> { diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/screens/SettingsScreen.kt b/app/src/main/java/com/jdluu/flexinsight/ui/screens/SettingsScreen.kt index a2f4aaf..cfe6a9d 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/screens/SettingsScreen.kt @@ -54,6 +54,22 @@ fun SettingsScreen( var showHealthPermissionsExplain by remember { mutableStateOf(false) } var showHealthPermissionsInfo by remember { mutableStateOf(false) } + val healthPermissionsDeniedMessage = stringResource(R.string.settings_health_permissions_denied) + val syncSuccessMessage = stringResource(R.string.settings_sync_success) + val syncStateFeedback: String? = when (val state = uiState.syncState) { + is LoadingState.Success -> syncSuccessMessage + is LoadingState.Error -> stringResource(R.string.settings_sync_failed, state.error.message ?: "") + else -> null + } + val exportSubject = stringResource(R.string.settings_export_subject) + val exportReportTitle = stringResource(R.string.settings_item_export_coach_report) + val exportFailedMessage = stringResource(R.string.settings_export_failed) + val contactSupportTitle = stringResource(R.string.settings_item_contact_support) + val supportEmail = stringResource(R.string.settings_support_email) + val supportSubject = stringResource(R.string.settings_support_subject) + val supportNoAppMessage = stringResource(R.string.settings_support_no_app) + val browserNoAppMessage = stringResource(R.string.settings_browser_no_app) + val permissionLauncher = rememberLauncherForActivityResult( contract = PermissionController.createRequestPermissionResultContract() ) { granted -> @@ -61,24 +77,14 @@ fun SettingsScreen( viewModel.setHealthConnectEnabled(allGranted) if (!allGranted) { scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.settings_health_permissions_denied)) + snackbarHostState.showSnackbar(healthPermissionsDeniedMessage) } } } // Sync Status Feedback LaunchedEffect(uiState.syncState) { - when (val state = uiState.syncState) { - is LoadingState.Success -> { - snackbarHostState.showSnackbar(context.getString(R.string.settings_sync_success)) - } - is LoadingState.Error -> { - snackbarHostState.showSnackbar( - context.getString(R.string.settings_sync_failed, state.error.message ?: "") - ) - } - else -> {} - } + syncStateFeedback?.let { snackbarHostState.showSnackbar(it) } } Scaffold( @@ -241,7 +247,7 @@ fun SettingsScreen( item { SectionTitle(stringResource(id = R.string.settings_section_data_privacy)) PreferenceItem( - title = stringResource(id = R.string.settings_item_export_coach_report), + title = exportReportTitle, icon = Icons.Default.Download, value = null, onClick = { @@ -249,18 +255,18 @@ fun SettingsScreen( val report = viewModel.exportCoachReport() val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { type = "text/plain" - putExtra(android.content.Intent.EXTRA_SUBJECT, context.getString(R.string.settings_export_subject)) + putExtra(android.content.Intent.EXTRA_SUBJECT, exportSubject) putExtra(android.content.Intent.EXTRA_TEXT, report) } try { context.startActivity( android.content.Intent.createChooser( intent, - context.getString(R.string.settings_item_export_coach_report) + exportReportTitle ) ) } catch (e: Exception) { - snackbarHostState.showSnackbar(context.getString(R.string.settings_export_failed)) + snackbarHostState.showSnackbar(exportFailedMessage) } } } @@ -276,19 +282,19 @@ fun SettingsScreen( item { SectionTitle(stringResource(id = R.string.settings_section_help_feedback)) PreferenceItem( - title = stringResource(id = R.string.settings_item_contact_support), + title = contactSupportTitle, icon = Icons.Default.Email, value = null, onClick = { val intent = android.content.Intent(android.content.Intent.ACTION_SENDTO).apply { data = android.net.Uri.parse("mailto:") - putExtra(android.content.Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.settings_support_email))) - putExtra(android.content.Intent.EXTRA_SUBJECT, context.getString(R.string.settings_support_subject)) + putExtra(android.content.Intent.EXTRA_EMAIL, arrayOf(supportEmail)) + putExtra(android.content.Intent.EXTRA_SUBJECT, supportSubject) } try { - context.startActivity(android.content.Intent.createChooser(intent, context.getString(R.string.settings_item_contact_support))) + context.startActivity(android.content.Intent.createChooser(intent, contactSupportTitle)) } catch (e: Exception) { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_support_no_app)) } + scope.launch { snackbarHostState.showSnackbar(supportNoAppMessage) } } } ) @@ -301,7 +307,7 @@ fun SettingsScreen( try { context.startActivity(intent) } catch (e: Exception) { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_browser_no_app)) } + scope.launch { snackbarHostState.showSnackbar(browserNoAppMessage) } } } ) diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..18ac3f7 --- /dev/null +++ b/opencode.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "openrouter/stealth/ox-alpha", + "small_model": "openrouter/stealth/ox-alpha", + "permission": { + "edit": "allow", + "webfetch": "allow", + "bash": { + "*": "allow", + "rm -rf *": "deny", + "sudo*": "deny", + "git push*": "deny" + }, + "external_directory": "ask", + "question": "allow" + } +}