Skip to content

feat: add scope-aware framework bootstrap#5

Open
papastanb wants to merge 20 commits into
mainfrom
feat/framework-bootstrap
Open

feat: add scope-aware framework bootstrap#5
papastanb wants to merge 20 commits into
mainfrom
feat/framework-bootstrap

Conversation

@papastanb
Copy link
Copy Markdown
Owner

@papastanb papastanb commented Apr 13, 2026

Summary

  • add a manifest-driven bootstrap engine shared by the CLI, server runtime, and new TUI plugin target
  • implement explicit global and project scope install, status, update, and uninstall flows with idempotent asset sync, config merge, MCP diagnostics, and hook deduplication
  • expand docs and integration tests to cover clean-environment installs, conflict handling, MCP state transitions, and uninstall safety

Validation

  • bun run check
  • bun test
  • bun run release:check

Review Request

  • Qodo and Devin should auto-trigger from this PR

Open with Devin

Summary by CodeRabbit

  • New Features

    • CLI and TUI bootstrap workflows for scoped install/status/update/uninstall, server & TUI entrypoints, manifest-driven framework installation, install state, restart reporting, and scope detection.
  • Runtime

    • Centralized persistence/checkpoint guidance with deduplication and single-load protection; plugin init/logging made resilient; MCP prerequisite diagnostics (env/binary checks) added to reports.
  • Documentation

    • Major rewrites: architecture, install, README, usage, commands; added framework manifest and project index artifacts.
  • Tests

    • Expanded sandboxed end-to-end bootstrap and hook tests.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

📝 Walkthrough

Walkthrough

The PR moves Super OpenCode runtime and hook implementations from .opencode/plugins/ into src/runtime/, replaces local plugin wrappers with re-exports, and adds a manifest-driven bootstrap framework with CLI and TUI entrypoints to install/status/update/uninstall framework assets and MCP config across project and global scopes.

Changes

Cohort / File(s) Summary
Plugin wrapper re-exports
\.opencode/plugins/super-opencode.ts, \.opencode/plugins/super-opencode/commands.ts, \.opencode/plugins/super-opencode/compaction.ts, \.opencode/plugins/super-opencode/memory.ts, \.opencode/plugins/super-opencode/system.ts
Replaced local implementations with re-exports that forward SuperOpenCodePlugin, createCommandHooks, createCompactionHooks, and memory constants to centralized runtime modules.
Runtime plugin & hooks
src/runtime/plugin.ts, src/runtime/hooks.ts, src/runtime/memory.ts
Added centralized SuperOpenCodePlugin with a process-global duplicate-load guard and merged hooks; implemented command/system/compaction hook factories and persistence hint constants.
Server & TUI entrypoints
src/server.ts, src/tui.ts
Added server plugin module exporting SuperOpenCodePlugin and a TUI plugin providing interactive install/status/update/uninstall flows with scope selection and reporting.
Framework manifest
framework.manifest.json
New manifest describing asset groups, scoped targets, config schemas, and MCP policies (local/remote entries with env/binary requirements and reasons).
Framework engine & types
src/framework/engine.ts, src/framework/types.ts, src/framework/manifest.ts, src/framework/state.ts, src/framework/package-root.ts, src/framework/paths.ts
Implemented manifest loading/caching, package-root resolution, scoped path resolution, install state persistence/normalization, asset hashing & sync logic, and lifecycle APIs: installFramework, updateFramework, statusFramework, uninstallFramework, detectFrameworkScopes.
Config & prerequisites
src/framework/config.ts, src/framework/prerequisites.ts
Added JSONC-aware config patch/remove utilities for opencode.json/tui.json (schema enforcement, plugin/instructions/MCP merge, ownership/hash tracking) and MCP prerequisite diagnostics (env vars and binary resolution).
CLI & scripts
src/cli.ts, scripts/install-project.mjs, scripts/validate-package.mjs
Added runCli() with scoped commands (install/status/update/uninstall/scopes), delegated install wrapper script, and package validation now requiring dist/src/server.js and dist/src/tui.js with expected export shapes.
Package & build config
package.json, tsconfig.json, tsconfig.build.json
Changed package entrypoints/exports to expose ./server and ./tui, updated published files (added manifest, removed .opencode/plugins), broadened TS build includes and enabled JSX transform for @opentui/solid.
Docs & install UX
ARCHITECTURE.md, INSTALL.md, README.md, USAGE.md
Rewrote docs to describe the bootstrap engine, scope-driven installs (project/global), idempotent sync, MCP diagnostics, and removed .opencode/plugins from documented public surface.
Tests
tests/framework.test.mjs, tests/plugin-hooks.test.mjs
Reworked tests for end-to-end framework bootstrap, scope/idempotency/conflict semantics, MCP diagnostics, and updated hook tests to use runtime modules and verify duplicate-load protection.
Misc scripts & metadata
scripts/*, PROJECT_INDEX.*, .gitignore, .npmignore, COMMANDS.md
Replaced install script with CLI delegator, updated validation script, added project index/artifacts, and minor ignore/docs adjustments.

Sequence Diagram

sequenceDiagram
    participant User as CLI/TUI User
    participant Engine as Framework Engine
    participant Manifest as Manifest Loader/State
    participant Assets as Asset Sync
    participant Config as Config Patcher

    User->>Engine: installFramework(options)
    Engine->>Manifest: loadFrameworkManifest()
    Manifest-->>Engine: FrameworkManifest
    Engine->>Manifest: readInstallState(scopeStatePath)
    Manifest-->>Engine: FrameworkInstallState|undefined
    Engine->>Assets: enumerate & hash assetGroups
    Assets-->>Engine: files + hashes
    Engine->>Assets: syncAssets(sources → targets)
    Assets-->>Engine: ReportItems (installed/updated/conflict)
    Engine->>Config: patchOpencodeConfig(...) / patchTuiConfig(...)
    Config-->>Engine: ConfigPatchResult
    Engine->>Manifest: writeInstallState(updatedState)
    Manifest-->>Engine: persisted
    Engine-->>User: FrameworkReport (items, MCP diagnostics, restartRequired)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 A hop, a patch, the framework springs—
From plugins freed, new bootstrap sings.
TUI or CLI, choose scope with cheer,
Sync the assets, keep states clear.
Tiny paws stamp hashes near.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add scope-aware framework bootstrap' clearly and concisely summarizes the main architectural change: introducing a manifest-driven bootstrap engine with global/project scope awareness, which is the primary objective of this comprehensive changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/framework-bootstrap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Add scope-aware framework bootstrap engine with TUI and CLI targets

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Implement manifest-driven bootstrap engine for scope-aware framework installation
  - Supports explicit global and project scope install, status, update, uninstall flows
  - Idempotent asset sync with managed file hashes and conflict detection
  - MCP diagnostics and prerequisite validation
• Add TUI and CLI targets for framework bootstrap with explicit scope selection
  - TUI plugin provides Plugin Manager visibility and first-load prompting
  - CLI wrapper delegates to shared bootstrap engine
• Refactor runtime plugin to server target and consolidate hook implementations
  - Move persistence, command, and compaction hooks to src/runtime/hooks.ts
  - Add duplicate-load protection to prevent hook registration conflicts
• Expand test coverage to validate clean installs, updates, conflicts, and uninstalls
  - Test global and project scope isolation and deduplication
  - Test MCP prerequisite diagnostics and state transitions
Diagram
flowchart LR
  A["User installs package"] --> B["TUI or CLI bootstrap"]
  B --> C["Select scope: global or project"]
  C --> D["Load framework manifest"]
  D --> E["Sync assets and config"]
  E --> F["Diagnose MCP prerequisites"]
  F --> G["Write install state"]
  G --> H["Report results"]
  H --> I["Restart OpenCode if needed"]
Loading

Grey Divider

File Changes

1. src/framework/engine.ts ✨ Enhancement +548/-0

Core bootstrap engine with install/update/status/uninstall

src/framework/engine.ts


2. src/framework/config.ts ✨ Enhancement +370/-0

Config merge logic for opencode.json and tui.json

src/framework/config.ts


3. src/framework/types.ts ✨ Enhancement +127/-0

Type definitions for framework manifest and state

src/framework/types.ts


View more (28)
4. src/framework/prerequisites.ts ✨ Enhancement +98/-0

MCP prerequisite diagnostics and binary resolution

src/framework/prerequisites.ts


5. src/framework/paths.ts ✨ Enhancement +57/-0

Scope path resolution for global and project directories

src/framework/paths.ts


6. src/framework/state.ts ✨ Enhancement +44/-0

Install state persistence and management

src/framework/state.ts


7. src/framework/manifest.ts ✨ Enhancement +27/-0

Framework manifest loading with caching

src/framework/manifest.ts


8. src/framework/package-root.ts ✨ Enhancement +34/-0

Package root discovery from import.meta.url

src/framework/package-root.ts


9. src/tui.ts ✨ Enhancement +163/-0

TUI plugin with scope selection and bootstrap UI

src/tui.ts


10. src/cli.ts ✨ Enhancement +112/-0

CLI wrapper delegating to bootstrap engine

src/cli.ts


11. src/server.ts ✨ Enhancement +11/-0

Server plugin module exporting runtime plugin

src/server.ts


12. src/runtime/plugin.ts ✨ Enhancement +44/-0

Runtime plugin with duplicate-load protection

src/runtime/plugin.ts


13. src/runtime/hooks.ts ✨ Enhancement +76/-0

Consolidated persistence, command, and compaction hooks

src/runtime/hooks.ts


14. src/runtime/memory.ts ✨ Enhancement +17/-0

Persistence contract and checkpoint guidance strings

src/runtime/memory.ts


15. framework.manifest.json ⚙️ Configuration changes +188/-0

Manifest defining asset groups, config, and MCP policies

framework.manifest.json


16. tests/framework.test.mjs 🧪 Tests +364/-53

Comprehensive bootstrap tests for all scopes and scenarios

tests/framework.test.mjs


17. tests/plugin-hooks.test.mjs 🧪 Tests +43/-20

Runtime hook deduplication and plugin load protection tests

tests/plugin-hooks.test.mjs


18. scripts/install-project.mjs ✨ Enhancement +15/-98

CLI entrypoint delegating to compiled src/cli.ts

scripts/install-project.mjs


19. .opencode/plugins/super-opencode.ts ✨ Enhancement +1/-20

Re-export server plugin from src/runtime/plugin.ts

.opencode/plugins/super-opencode.ts


20. .opencode/plugins/super-opencode/memory.ts ✨ Enhancement +1/-17

Re-export memory constants from src/runtime/memory.ts

.opencode/plugins/super-opencode/memory.ts


21. .opencode/plugins/super-opencode/commands.ts ✨ Enhancement +1/-30

Re-export command hooks from src/runtime/hooks.ts

.opencode/plugins/super-opencode/commands.ts


22. .opencode/plugins/super-opencode/compaction.ts ✨ Enhancement +1/-14

Re-export compaction hooks from src/runtime/hooks.ts

.opencode/plugins/super-opencode/compaction.ts


23. .opencode/plugins/super-opencode/system.ts ✨ Enhancement +1/-7

Re-export system hooks from src/runtime/hooks.ts

.opencode/plugins/super-opencode/system.ts


24. package.json ⚙️ Configuration changes +27/-8

Add server and tui exports, update dependencies and scripts

package.json


25. tsconfig.json ⚙️ Configuration changes +4/-0

Add JSX configuration for TUI plugin development

tsconfig.json


26. tsconfig.build.json ⚙️ Configuration changes +2/-0

Include src directory in build configuration

tsconfig.build.json


27. README.md 📝 Documentation +62/-14

Document scope-aware bootstrap and explicit install flows

README.md


28. INSTALL.md 📝 Documentation +47/-38

Update installation guide for TUI and CLI bootstrap

INSTALL.md


29. USAGE.md 📝 Documentation +12/-3

Update usage guide with scope selection and CLI commands

USAGE.md


30. ARCHITECTURE.md 📝 Documentation +58/-15

Document bootstrap engine, TUI, and server plugin layers

ARCHITECTURE.md


31. scripts/validate-package.mjs Additional files +31/-8

...

scripts/validate-package.mjs


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 13, 2026

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Adopted files block uninstall🐞
Description
uninstallFramework treats "adopted" (unmanaged) files as remaining managed files and returns early,
so it never removes framework config entries or the install-state file. This makes a clean uninstall
impossible whenever any pre-existing identical file was adopted during install.
Code

src/framework/engine.ts[R404-449]

+    const currentHash = hashContent(content)
+    // Skip deleting adopted files unless --force is used
+    if (fileState.origin === "adopted" && !(options.force ?? false)) {
+      remainingFiles[relativePath] = fileState
+      report.items.push({
+        kind: "asset",
+        name: relativePath,
+        status: "skipped",
+        detail: "File is unmanaged (adopted) and will not be removed unless --force is used.",
+      })
+      continue
+    }
+    
+    if (currentHash !== fileState.installedHash && !(options.force ?? false)) {
+      remainingFiles[relativePath] = fileState
+      report.items.push({
+        kind: "asset",
+        name: relativePath,
+        status: "conflict/manual action required",
+        detail: "Managed file was modified after install. Re-run with --force or remove it manually.",
+      })
+      continue
+    }
+
+    await unlink(filePath)
+    await removeEmptyDirectories(path.dirname(filePath), options.scope === "global" ? paths.configDir : paths.projectRoot)
+    changed = true
+    report.items.push({ kind: "asset", name: relativePath, status: "removed", detail: `Removed managed ${fileState.group} asset.` })
+  }
+
+  if (Object.keys(remainingFiles).length > 0) {
+    const nextState = {
+      ...state,
+      files: remainingFiles,
+      updatedAt: new Date().toISOString(),
+    }
+    await writeInstallState(paths.statePath, nextState)
+    report.restartRequired = changed
+    report.items.push({
+      kind: "runtime",
+      name: "OpenCode runtime",
+      status: "conflict/manual action required",
+      detail: "Uninstall stopped because some managed files were modified. Install state was preserved for the remaining assets.",
+    })
+    return report
+  }
Evidence
During install, syncAssets records pre-existing identical files as origin="adopted" in the persisted
state. During uninstall, those adopted files are added to remainingFiles and trigger the
early-return path, which skips removeFrameworkConfig/removeFrameworkTuiConfig and
removeInstallState; detectFrameworkScopes then continues to report the scope as installed because
the state file remains.

src/framework/engine.ts[156-175]
src/framework/engine.ts[404-449]
src/framework/engine.ts[485-507]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`uninstallFramework()` currently aborts uninstall whenever `remainingFiles` is non-empty. Because adopted files are intentionally *not* removed (unless `--force`), they should not be treated as “remaining managed files” that block uninstall.
### Issue Context
- Install can “adopt” a pre-existing file (matching content) by writing `origin: "adopted"` into the state.
- Uninstall should leave adopted files in place, but still remove framework config entries and delete the install-state file so the framework is fully uninstalled.
### Fix Focus Areas
- src/framework/engine.ts[156-175]
- src/framework/engine.ts[404-449]
- src/framework/engine.ts[451-472]
### Implementation notes
- When encountering `origin === "adopted"` (and no `--force`), **skip deleting the file** but also **do not keep it in `remainingFiles`** (i.e., drop it from state as part of uninstall).
- Only preserve state / early-return when there are true conflicts (e.g., modified `origin === "installed"` files).
- Ensure uninstall proceeds to remove framework config entries and calls `removeInstallState()` when the only “remaining” entries were adopted files.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Uninstall blocked by JSONC🐞
Description
uninstallFramework() validates opencode.json and tui.json before deleting managed assets, so a
malformed JSONC config prevents uninstalling framework-managed files. This can strand users with
managed assets/state even though asset deletion does not require parsing configs.
Code

src/framework/engine.ts[R428-429]

+  await validateJsoncConfigFile(paths.opencodeConfigPath)
+  await validateJsoncConfigFile(paths.tuiConfigPath)
Evidence
uninstallFramework() calls validateJsoncConfigFile() prior to iterating over the managed file list;
validateJsoncConfigFile() throws on JSONC parse errors, which aborts the uninstall before any asset
cleanup happens.

src/framework/engine.ts[411-430]
src/framework/config.ts[71-92]
src/framework/config.ts[153-156]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`uninstallFramework()` currently fails fast if `opencode.json` or `tui.json` contain invalid JSONC, which prevents removal of framework-managed assets even though asset deletion can proceed safely from the install-state file.
## Issue Context
The uninstall flow has two independent responsibilities:
1) remove managed assets using `state.files`
2) clean up config entries (plugin, instructions, MCP)
Only (2) requires parsing JSONC.
## Fix Focus Areas
- src/framework/engine.ts[411-470]
- src/framework/engine.ts[488-523]
## Implementation notes
- Move JSONC validation so it happens *after* managed asset deletion, and handle parse errors by:
- skipping config cleanup
- adding report items indicating `conflict/manual action required` for config cleanup
- preserving install state for remaining config cleanup (similar to existing conflict behavior)
- Alternatively/additionally: allow `--force` uninstall to bypass config validation (while still avoiding writing invalid JSONC), and only remove config when it parses cleanly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Uninstall dir cleanup can throw🐞
Description
removeEmptyDirectories does readdir() followed by rmdir() without handling ENOTEMPTY/ENOENT races;
if the directory changes between calls, uninstall can throw and abort mid-run. This can leave a
partially-uninstalled scope and a confusing state for users.
Code

src/framework/engine.ts[R79-96]

+async function removeEmptyDirectories(startDir: string, stopDir: string): Promise<void> {
+  let current = startDir
+  const resolvedStopDir = path.resolve(stopDir)
+  while (true) {
+    const resolvedCurrent = path.resolve(current)
+    const relativeToStop = path.relative(resolvedStopDir, resolvedCurrent)
+    if (resolvedCurrent === resolvedStopDir || relativeToStop.startsWith("..") || path.isAbsolute(relativeToStop)) {
+      return
+    }
+
+    const entries = await readdir(current)
+    if (entries.length > 0) {
+      return
+    }
+
+    await rmdir(current)
+    current = path.dirname(current)
+  }
Evidence
removeEmptyDirectories removes directories based on an emptiness check, but it does not catch
filesystem errors from readdir/rmdir; uninstallFramework calls it after each unlink, so any
transient race/error will bubble up and interrupt uninstall.

src/framework/engine.ts[79-96]
src/framework/engine.ts[445-446]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`removeEmptyDirectories()` performs `readdir()` then `rmdir()` with no error handling. If the directory becomes non-empty (or disappears) between the check and removal, `rmdir()`/`readdir()` can throw and abort uninstall.
## Issue Context
`uninstallFramework()` calls `removeEmptyDirectories()` after deleting each managed file. A single transient filesystem change can interrupt the whole uninstall flow.
## Fix Focus Areas
- src/framework/engine.ts[79-96]
- src/framework/engine.ts[445-446]
## What to change
- Wrap `readdir()` and `rmdir()` in try/catch and treat common benign races as a stop condition (e.g. ignore/return on `ENOENT`, `ENOTEMPTY`, possibly `EPERM`).
- Consider using `fs.rm(dir, { recursive: false })` only when you are certain it is empty, but still handle `ENOTEMPTY` gracefully.
- Ensure the uninstall report remains accurate even when directory cleanup is skipped due to races.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Config ownership unused on uninstall🐞
Description
The framework persists ownership flags indicating it created opencode.json/tui.json, but uninstall
never uses them to remove now-empty framework-created config files. This can leave stub config files
behind after uninstall even when the framework created them in a clean environment.
Code

src/framework/config.ts[R296-380]

+/** Removes framework-managed opencode.json entries that were added for this scope. */
+export async function removeFrameworkConfig(options: {
+  filePath: string
+  manifest: FrameworkManifest
+  state: FrameworkInstallState
+}): Promise<{ changed: boolean; removedFile: boolean }> {
+  const { created, value } = await readJsoncObject(options.filePath)
+  if (created) {
+    return { changed: false, removedFile: false }
+  }
+
+  const config: JsonObject = { ...value }
+  let changed = false
+
+  if (options.state.ownership.addedOpencodePlugin) {
+    const plugins = removePluginSpec(normalizePluginArray(config.plugin), options.manifest.config.opencode.plugin)
+    if (plugins.length > 0) {
+      config.plugin = plugins
+    } else {
+      delete config.plugin
+    }
+    changed = true
+  }
+
+  if (options.state.ownership.addedInstructions.length > 0) {
+    const instructions = removeInstructions(ensureStringArray(config.instructions), options.state.ownership.addedInstructions)
+    if (instructions.length > 0) {
+      config.instructions = instructions
+    } else {
+      delete config.instructions
+    }
+    changed = true
+  }
+
+  if (options.state.ownership.addedMcpKeys.length > 0 && isObject(config.mcp)) {
+    const nextMcp = { ...config.mcp }
+    for (const key of options.state.ownership.addedMcpKeys) {
+      delete nextMcp[key]
+    }
+    if (Object.keys(nextMcp).length > 0) {
+      config.mcp = nextMcp
+    } else {
+      delete config.mcp
+    }
+    changed = true
+  }
+
+  if (!changed) {
+    return { changed: false, removedFile: false }
+  }
+
+  await writeJson(options.filePath, config)
+  return { changed: true, removedFile: false }
+}
+
+/** Removes the framework TUI plugin entry from the scope-local tui.json during uninstall. */
+export async function removeFrameworkTuiConfig(options: {
+  filePath: string
+  manifest: FrameworkManifest
+  state: FrameworkInstallState
+}): Promise<{ changed: boolean; removedFile: boolean }> {
+  const { created, value } = await readJsoncObject(options.filePath)
+  if (created) {
+    return { changed: false, removedFile: false }
+  }
+
+  const config: JsonObject = { ...value }
+  let changed = false
+  if (options.state.ownership.addedTuiPlugin) {
+    const plugins = removePluginSpec(normalizePluginArray(config.plugin), options.manifest.config.tui.plugin)
+    if (plugins.length > 0) {
+      config.plugin = plugins
+    } else {
+      delete config.plugin
+    }
+    changed = true
+  }
+
+  if (!changed) {
+    return { changed: false, removedFile: false }
+  }
+
+  await writeJson(options.filePath, config)
+  return { changed: true, removedFile: false }
+}
Evidence
State tracks createdOpencodeConfig/createdTuiConfig and installFramework sets them, but
removeFrameworkConfig/removeFrameworkTuiConfig only delete specific keys and always return
removedFile:false (never deleting the file). As a result, uninstall cannot clean up config files
that were created solely for the framework bootstrap.

src/framework/state.ts[8-23]
src/framework/engine.ts[263-286]
src/framework/config.ts[296-380]
src/framework/engine.ts[451-472]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Uninstall tracks whether `opencode.json` / `tui.json` were created by the framework, but never uses that information to remove those files when they become empty/framework-only after uninstall.
### Issue Context
- `createEmptyState()` includes `ownership.createdOpencodeConfig` and `ownership.createdTuiConfig`.
- `installFramework()` sets those ownership flags.
- `removeFrameworkConfig()` / `removeFrameworkTuiConfig()` never delete the config files (they always return `removedFile: false`).
### Fix Focus Areas
- src/framework/state.ts[8-23]
- src/framework/engine.ts[263-286]
- src/framework/config.ts[296-380]
- src/framework/engine.ts[451-472]
### Implementation notes
- After removing framework-managed keys on uninstall, if `state.ownership.createdOpencodeConfig` is true, consider deleting `opencode.json` **only when** it’s effectively empty (e.g., no keys or only `$schema`).
- Same for `tui.json` when `state.ownership.createdTuiConfig` is true.
- If you implement file deletion, plumb a meaningful `removedFile` boolean back to `uninstallFramework()` so reporting reflects the deletion and you can optionally remove now-empty parent directories.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
5. Uninstall deletes adopted files🐞
Description
syncAssets records a managed-file entry whenever the destination file already exists and matches the
package content, even if the bootstrap didn’t create that file. A later uninstall will delete any
file present in install-state.json whose current hash matches the recorded installedHash, so
pre-existing identical files can be removed unexpectedly.
Code

src/framework/engine.ts[R155-163]

+      const destinationHash = hashContent(destinationContent)
+      if (destinationHash === sourceHash) {
+        options.stateFiles[destinationKey] = {
+          group: group.id,
+          sourceHash,
+          installedHash: sourceHash,
+        }
+        options.reportItems.push({ kind: "asset", name: destinationKey, status: "already up to date" })
+        continue
Evidence
On install, when a destination file exists and its hash equals the source hash, syncAssets still
writes an entry into state.files, even though it does not write the file. installFramework then
persists that state to disk. During uninstall, uninstallFramework iterates state.files and unlinks
each file when its current hash matches the recorded installedHash (which will be true for these
“adopted” files).

src/framework/engine.ts[134-163]
src/framework/engine.ts[221-227]
src/framework/engine.ts[281-285]
src/framework/engine.ts[375-399]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`syncAssets()` marks files as managed (adds them to `state.files`) even when it did not create or overwrite the file—only because the destination content matches the packaged asset. Later, `uninstallFramework()` deletes every file listed in `state.files` when its hash matches `installedHash`, which can remove pre-existing files unexpectedly.
## Issue Context
This happens in the `destinationHash === sourceHash` branch, which is hit for any pre-existing identical file. The uninstall path uses `state.files` as the authoritative list of files to remove.
## Fix Focus Areas
- src/framework/engine.ts[134-218]
- src/framework/engine.ts[221-304]
- src/framework/engine.ts[354-449]
## Suggested fix approach
- Extend `ManagedFileState` to include an ownership flag (e.g., `origin: "installed" | "adopted"`).
- In `syncAssets()`, set `origin: "installed"` only when the bootstrap actually writes the file (installed/updated/overwritten).
- When `destinationHash === sourceHash` and there is **no** prior state entry, either:
- do **not** add it to `state.files` (treat as “present but unmanaged”), or
- add it as `origin: "adopted"` and make `uninstallFramework()` skip deletion for `adopted` files unless `--force` is provided.
- Update reporting strings so users can distinguish “already present (unmanaged/adopted)” vs “managed and up to date.”

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Order-sensitive MCP change check🐞
Description
patchOpencodeConfig uses JSON.stringify to detect MCP config changes, so configs with identical
values but different key order are always treated as changed and rewritten. This breaks the stated
idempotence guarantees by repeatedly updating opencode.json (and often forcing a restart) even
when nothing semantically changed.
Code

src/framework/config.ts[R195-199]

+    const mergedValue = currentValue ? mergeObjects(diagnostic.config, currentValue) : { ...diagnostic.config }
+    mergedValue.enabled = diagnostic.enabled
+    if (!isObject(currentValue) || JSON.stringify(currentValue) !== JSON.stringify(mergedValue)) {
+      changed = true
+    }
Evidence
The change detector compares currentValue vs mergedValue using JSON.stringify, which is
sensitive to object key insertion order. This repo’s own opencode.json defines context7 with
enabled before headers, while diagnostics build config objects by spreading policy.config
first and then adding enabled last—yielding headers before enabled. When context7 already
exists, mergeObjects(diagnostic.config, currentValue) keeps the base object’s insertion order
(headers before enabled), so the stringify comparison can differ purely due to ordering, causing
changed = true and a rewrite on every run.

src/framework/config.ts[186-207]
src/framework/prerequisites.ts[50-95]
framework.manifest.json[80-88]
opencode.json[26-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`patchOpencodeConfig()` marks MCP entries as changed when `JSON.stringify(currentValue) !== JSON.stringify(mergedValue)`. This is order-sensitive and can cause repeated rewrites even when the config values are semantically identical.
## Issue Context
- `diagnoseMcpPolicies()` constructs diagnostic `config` by spreading `policy.config` and then adding `enabled`.
- Existing user configs (including this repo’s `opencode.json`) commonly order keys differently (e.g., `enabled` before `headers`).
## Fix Focus Areas
- Replace the `JSON.stringify` comparison with an order-insensitive deep equality check (object-key-set based), or perform a stable stringify with sorted keys.
- Alternatively, compute a "changed" signal from whether any jsonc-parser `modify()` calls would produce non-empty edits.
### Fix Focus Areas (code)
- src/framework/config.ts[186-207]
- src/framework/prerequisites.ts[50-95]
- framework.manifest.json[80-88]
- opencode.json[26-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Status reports fake updates🐞
Description
statusFramework runs syncAssets with dryRun: true, but syncAssets still emits report items
with status updated in the "previous managed revision" branch even though no file write occurs.
This produces incorrect status output that claims updates happened when they did not.
Code

src/framework/engine.ts[R254-267]

+      if (previousState && previousState.installedHash === destinationHash) {
+        if (!options.dryRun) {
+          await ensureFileParent(destinationPath)
+          await writeFile(destinationPath, sourceContent, "utf8")
+          options.stateFiles[destinationKey] = {
+            group: group.id,
+            sourceHash,
+            installedHash: sourceHash,
+          }
+        }
+
+        changed = true
+        options.reportItems.push({ kind: "asset", name: destinationKey, status: "updated" })
+        continue
Evidence
statusFramework() calls syncAssets() with dryRun: true. In syncAssets, when a destination
file matches the previously-installed hash but differs from the current package source, it skips the
write under dryRun, but still pushes a report item with status updated and sets `changed =
true. This makes the status` command output incorrect by reporting updates that never occurred.

src/framework/engine.ts[254-267]
src/framework/engine.ts[389-408]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`syncAssets()` reports `status: "updated"` even when `dryRun: true` and no write happens. This makes `statusFramework()` misleading.
## Issue Context
- `statusFramework()` always calls `syncAssets(..., dryRun: true)`.
- In the `previousState.installedHash === destinationHash` branch, writes are guarded by `if (!options.dryRun)` but reporting is not.
## Fix Focus Areas
- When `dryRun` is true, never emit `installed/updated/removed` statuses.
- For the managed-old-revision branch, emit `skipped` (or introduce a distinct status like `out of date`) with a detail such as "Would update on install/update".
- Avoid setting `changed = true` in dry-run paths, or ensure callers never interpret it as a real change.
### Fix Focus Areas (code)
- src/framework/engine.ts[254-267]
- src/framework/engine.ts[389-408]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

8. State directory not cleaned🐞
Description
Uninstall removes the install-state.json file but never removes the now-empty parent directory
(e.g., .opencode/super-opencode/), leaving empty framework state directories behind after a
successful uninstall.
Code

src/framework/state.ts[R59-62]

+/** Removes the persisted framework state file for a scope. */
+export async function removeInstallState(filePath: string): Promise<void> {
+  await rm(filePath, { force: true })
+}
Evidence
removeInstallState() deletes only the state file. uninstallFramework() calls removeInstallState(),
but does not call removeEmptyDirectories() for the state file path, so the containing directory can
remain empty on disk.

src/framework/state.ts[59-62]
src/framework/engine.ts[471-481]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
After a successful uninstall, the framework state file is removed but its parent directory can remain (empty) on disk.
## Issue Context
- `removeInstallState()` only removes the state file.
- `uninstallFramework()` already has `removeEmptyDirectories()` logic for assets, but not for the state path.
## Fix Focus Areas
- src/framework/engine.ts[471-481]
- src/framework/state.ts[59-62]
## Suggested fix
- After `await removeInstallState(paths.statePath)`, call `removeEmptyDirectories(path.dirname(paths.statePath), <stopDir>)`.
- For `global` scope, use `stopDir = paths.configDir`.
- For `project` scope, use `stopDir = paths.projectRoot` (so `.opencode` can be removed too if it became empty), or `paths.configDir` if you intentionally want to keep an empty `.opencode` directory.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 8 potential issues.

Open in Devin Review

Comment thread src/framework/engine.ts Outdated
Comment thread src/framework/engine.ts Outdated
Comment thread src/framework/config.ts Outdated
Comment on lines +195 to +196
const mergedValue = currentValue ? mergeObjects(diagnostic.config, currentValue) : { ...diagnostic.config }
mergedValue.enabled = diagnostic.enabled
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 13, 2026

Choose a reason for hiding this comment

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

🚩 MCP config merge gives user values priority over framework updates

At src/framework/config.ts:195, mergeObjects(diagnostic.config, currentValue) uses the framework's MCP config as base and the user's existing config as override. This means non-object scalar fields like command arrays, type, url, and headers values from the user's existing config will always win over framework updates. Only the enabled field is explicitly overridden afterwards at line 196. This design preserves user customizations but also means framework MCP config updates (e.g., changing a Serena command URL or updating a package version in a command array) will never propagate to users who already have existing MCP config entries. This may or may not be the intended tradeoff — worth documenting explicitly.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/runtime/hooks.ts Outdated
Comment thread src/framework/engine.ts Outdated
Comment thread src/framework/manifest.ts
Comment on lines +7 to +26
let cachedManifestPromise: Promise<FrameworkManifest> | undefined

export async function loadFrameworkManifest(): Promise<FrameworkManifest> {
if (cachedManifestPromise) {
return cachedManifestPromise
}

cachedManifestPromise = (async () => {
try {
const packageRoot = findPackageRoot(import.meta.url)
const manifestPath = path.join(packageRoot, "framework.manifest.json")
const rawManifest = await readFile(manifestPath, "utf8")
return JSON.parse(rawManifest) as FrameworkManifest
} catch (error) {
cachedManifestPromise = undefined
throw error
}
})()

return cachedManifestPromise
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 13, 2026

Choose a reason for hiding this comment

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

📝 Info: Manifest caching retains promise indefinitely after first successful load

In src/framework/manifest.ts:7-26, cachedManifestPromise is set on first call and only cleared on error. For the CLI this is fine (short-lived process), but the TUI plugin is long-lived. If the user upgrades the npm package while OpenCode is running, the cached manifest would still point to the old version. The TUI would need an OpenCode restart to pick up manifest changes. This is acceptable given the docs say 'Restart OpenCode' after updates, but worth being aware of.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/runtime/plugin.ts Outdated
Comment thread src/framework/config.ts Outdated
coderabbitai[bot]

This comment was marked as resolved.

@papastanb
Copy link
Copy Markdown
Owner Author

@qodo-code-review /review

@papastanb
Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 13, 2026

Persistent review updated to latest commit df3c97e

@papastanb
Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 13, 2026

Persistent review updated to latest commit 65cac00

qodo-code-review[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 6 new potential issues.

Open in Devin Review

Comment thread src/framework/engine.ts
Comment thread src/runtime/hooks.ts
import { autoCheckpointHint, commandPersistenceHint, persistenceContract } from "./memory.js"

const persistenceCommands = new Set(["sc-pm", "sc-save", "sc-load", "sc-reflect"])
const checkpointCommands = new Set(["sc-implement", "sc-build", "sc-test", "sc-document", "sc-task"])
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 13, 2026

Choose a reason for hiding this comment

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

🚩 Checkpoint commands set changed significantly from the old code

The old checkpointCommands set in .opencode/plugins/super-opencode/commands.ts was ['sc-save', 'sc-spawn', 'sc-workflow'] with an explicit normalized !== 'sc-save' exclusion (so effectively sc-spawn and sc-workflow received checkpoint hints). The new set in src/runtime/hooks.ts:6 is ["sc-implement", "sc-build", "sc-test", "sc-document", "sc-task"] — a completely different set of commands. This is a deliberate behavioral change, not a mechanical transformation. The old commands sc-spawn and sc-workflow no longer receive checkpoint hints, while several execution-oriented commands now do. This seems intentional (checkpoint before long-running execution, not before planning), but reviewers should confirm the new set matches the desired user-facing behavior.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/framework/engine.ts
Comment on lines +344 to +354
await syncAssets({
groups: manifest.assetGroups,
packageRoot: paths.packageRoot,
scope: options.scope,
configDir: paths.configDir,
projectRoot: paths.projectRoot,
stateFiles: state.files,
reportItems: report.items,
force: false,
dryRun: true,
})
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 13, 2026

Choose a reason for hiding this comment

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

📝 Info: statusFramework mutates in-memory state.files during dry-run but never persists

In statusFramework (src/framework/engine.ts:335-372), syncAssets is called with dryRun: true but state.files is passed as the mutable stateFiles parameter. The sync logic still writes to stateFiles in the content-matching branch (engine.ts lines 159-174) even during dry run — for example, recording files as 'adopted'. Since statusFramework never calls writeInstallState, these in-memory mutations are harmless and discarded. This was considered as a potential bug but dismissed because: (1) each file gets a unique key so mutations don't cross-contaminate, and (2) the state is never persisted.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/framework/config.ts Outdated
Comment on lines +226 to +227
const mergedValue = currentValue ? mergeObjects(diagnostic.config, currentValue) : { ...diagnostic.config }
mergedValue.enabled = diagnostic.enabled
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 13, 2026

Choose a reason for hiding this comment

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

📝 Info: MCP merge logic treats enabled field as framework-authoritative even for user-modified entries

In patchOpencodeConfig at src/framework/config.ts:308, mergedValue.enabled = diagnostic.enabled unconditionally overwrites the enabled field based on prerequisite diagnostics, even for user-modified MCP entries. This means a user cannot manually force-enable an MCP entry that the framework considers disabled (e.g., setting enabled: true when CONTEXT7_API_KEY is absent). The comment at line 307 explicitly calls this out as intentional: prerequisite diagnostics are always authoritative for runtime enablement. This is a reasonable safety measure (enabling an MCP without its prerequisites would cause runtime errors), but users who want to override this behavior would need to add the MCP entry under a different key name.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/framework/state.ts Outdated
Comment on lines +46 to +56
try {
await rename(tempPath, filePath)
} catch (error) {
if (!["EEXIST", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
await rm(tempPath, { force: true })
throw error
}

await rm(filePath, { force: true })
await rename(tempPath, filePath)
}
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 13, 2026

Choose a reason for hiding this comment

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

📝 Info: writeInstallState rename fallback leaves temp file on second rename failure

In src/framework/state.ts:46-56, if the initial rename(tempPath, filePath) fails with EEXIST or EPERM (Windows), the fallback deletes the target and retries. If the second rename also fails (e.g., another process recreated the file), the temp file is leaked. This is a very unlikely race condition for an interactive install tool that runs one operation at a time, so it was not flagged as a bug. The existing error handling is reasonable for the expected use case.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/framework/engine.ts
continue
}

const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 13, 2026

Choose a reason for hiding this comment

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

📝 Info: Cross-platform path handling is carefully maintained

listRelativeFiles at src/framework/engine.ts:269-290 uses path.posix.join for building relative paths (ensuring forward slashes in state keys) while using path.join for filesystem operations (native separators). The destination key normalization at line 384 (split(path.sep).join('/')) ensures state file keys are always forward-slash-separated regardless of platform. During uninstall, path.join(targetBase, relativePath) at line 688 correctly converts forward-slash keys back to native paths for filesystem operations. This cross-platform handling is consistent throughout.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@papastanb
Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 13, 2026

Persistent review updated to latest commit 70ec485

@papastanb
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@papastanb
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@papastanb
Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 14, 2026

Persistent review updated to latest commit 70ec485

coderabbitai[bot]

This comment was marked as resolved.

@papastanb
Copy link
Copy Markdown
Owner Author

/review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 6 new potential issues.

Open in Devin Review

Comment thread src/runtime/plugin.ts Outdated
Comment thread src/cli.ts
Comment thread src/framework/config.ts Outdated
Comment thread src/runtime/plugin.ts
Comment thread src/framework/config.ts Outdated
Comment thread src/runtime/plugin.ts
Comment on lines +22 to +31
if (runtimeState[runtimeLoadMarker]) {
await client.app.log({
body: {
service: "super-opencode",
level: "info",
message: "Super OpenCode runtime already active, skipping duplicate hook registration",
},
})

return createCompactionHooks(worktree)
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 14, 2026

Choose a reason for hiding this comment

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

📝 Info: Duplicate load protection returns compaction hooks only on second registration

When SuperOpenCodePlugin at src/runtime/plugin.ts:20-31 detects a duplicate registration via the global Symbol.for marker, it only returns createCompactionHooks(worktree), omitting system and command hooks. This means the second plugin instance contributes compaction context (which is worktree-specific) but does not contribute system or command hooks (avoiding duplication). The test at tests/plugin-hooks.test.mjs:68-70 confirms this: second['experimental.chat.system.transform'] is undefined. This is intentional design for the dual-registration scenario where both npm and local plugin copies coexist.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

Open in Devin Review

Comment thread src/framework/engine.ts
Comment on lines +444 to +452
if (fileState.origin === "adopted" && !(options.force ?? false)) {
report.items.push({
kind: "asset",
name: relativePath,
status: "skipped",
detail: "File is unmanaged (adopted) and will not be removed unless --force is used.",
})
continue
}
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 14, 2026

Choose a reason for hiding this comment

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

📝 Info: Adopted files are forgotten from state on partial uninstall with conflicts

In uninstallFramework at src/framework/engine.ts:697-704, adopted files are skipped without being added to remainingFiles. If the uninstall hits a conflict with a different managed file (line 707-715), the early return at line 724 persists state with only the conflicting files — adopted files vanish from the tracked state. This is benign because adopted files are never deleted by uninstall (they're skipped), so losing their tracking is harmless. On a subsequent clean uninstall, the adopted files simply won't appear in any report.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +73 to +82
if (policy.requirements?.manual) {
status = "configured but requires auth/manual setup"
enabled = false
} else if (missingEnv.length > 0) {
status = "configured but disabled by missing env"
enabled = false
} else if (missingBinaries.length > 0) {
status = "configured but disabled by missing binary"
enabled = false
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: MCP diagnostic priority may report env issues before binary issues

In diagnoseMcpPolicies (src/framework/prerequisites.ts:73-82), the priority order is: manual requirements → missing env → missing binaries. For MCPs like tavily that require both npx (binary) and TAVILY_API_KEY (env), if both are missing, only the env issue is reported in the status. The user would need to fix the env first, then see the binary issue on the next run. Both missingEnv and missingBinaries arrays are always fully populated regardless of status, so programmatic consumers have complete information. This is a minor UX consideration, not a correctness issue.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/framework/config.ts Outdated
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread src/framework/config.ts
Comment thread src/framework/config.ts
Comment on lines +187 to +207
async function writeJson(filePath: string, value: JsonObject): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true })

let originalText: string | undefined
try {
originalText = await readFile(filePath, "utf8")
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}

if (originalText === undefined) {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8")
return
}

const parsed = parseJsoncObject(originalText, filePath)
const updatedText = applyJsoncObjectEdits(originalText, parsed, value)
await writeFile(filePath, updatedText.endsWith("\n") ? updatedText : `${updatedText}\n`, "utf8")
}
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 14, 2026

Choose a reason for hiding this comment

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

📝 Info: writeJson re-reads the file even though the caller already parsed it

Both patchOpencodeConfig and removeFrameworkConfig read and parse the config file via readJsoncObject, then later call writeJson which reads the same file again to preserve JSONC comments during edits. This double-read is intentional: the first read extracts the semantic JSON object for programmatic manipulation, while the second read in writeJson retrieves the raw JSONC text to apply comment-preserving edits via jsonc-parser's modify/applyEdits. There's a theoretical TOCTOU window between the two reads, but since the bootstrap is single-threaded and no concurrent modifications are expected, this is acceptable. A minor optimization would be to thread the raw text through, but the current approach is clearer.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@papastanb
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@papastanb
Copy link
Copy Markdown
Owner Author

/review

coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@papastanb
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@papastanb
Copy link
Copy Markdown
Owner Author

/review

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 5 new potential issues.

Open in Devin Review

Comment thread src/framework/engine.ts Outdated
Comment thread src/framework/config.ts
const { created, value } = await readJsoncObject(options.filePath)
const config: JsonObject = { ...value }
let changed = created
const addedMcpHashes: Record<string, string> = { ...(options.state?.ownership.addedMcpHashes ?? {}) }
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 15, 2026

Choose a reason for hiding this comment

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

📝 Info: addedMcpHashes accumulates stale entries for removed manifest MCPs

In patchOpencodeConfig at src/framework/config.ts:266, addedMcpHashes is initialized by copying ALL previously recorded hashes from state. The loop only processes MCP entries present in the current manifest diagnostics. If a future manifest version removes an MCP entry (e.g., morph), the old hash for that entry persists in addedMcpHashes indefinitely. During uninstall, removeFrameworkConfig would still try to clean it up (which is good), but the state file grows with defunct entries over time. Not a correctness bug, but a minor state hygiene concern.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/framework/config.ts
Comment thread src/framework/engine.ts
Comment on lines +607 to +614
for (const diagnostic of diagnostics) {
report.items.push({
kind: "mcp",
name: diagnostic.name,
status: diagnostic.status,
detail: diagnostic.detail,
})
}
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot Apr 15, 2026

Choose a reason for hiding this comment

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

📝 Info: MCP diagnostics appear in both report.items and report.mcp

In installFramework (src/framework/engine.ts:555 and 607-614), MCP diagnostics are stored in both report.mcp and pushed as items into report.items with kind: "mcp". The CLI renderReport in src/cli.ts:42 correctly handles this by filtering report.items to exclude MCP items and rendering report.mcp separately. The TUI summarizeReport in src/tui.ts:13-14 filters on status strings rather than kind. Both approaches work, but the dual storage pattern is subtle — a consumer of FrameworkReport who doesn't know about this could double-count MCP entries.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +9 to +16
async function isExecutable(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.X_OK)
return true
} catch {
return false
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Cross-platform binary resolution on Windows uses X_OK which is aliased to F_OK

In src/framework/prerequisites.ts:11, isExecutable checks constants.X_OK. On Windows, Node.js maps X_OK to F_OK (file existence check), so any existing file in PATH would be considered executable regardless of actual permissions. The code also lowercases PATHEXT extensions at line 35, which is fine since Windows is case-insensitive. This Windows behavior is a known Node.js limitation, not a bug in this code, but it means binary resolution on Windows is effectively "does the file exist in PATH" rather than "is the file executable."

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@PROJECT_INDEX.md`:
- Line 48: Update the user-facing occurrences of "github" to "GitHub" in
PROJECT_INDEX.md; specifically replace the path/label string
`.github/workflows/` and the other listed entries (lines referencing "github" at
the mentioned locations) so the platform name is capitalized consistently, e.g.,
change `.github/workflows/` to `.github/workflows/` in display but ensure any
prose or label that reads "github" becomes "GitHub" (search for the lowercase
"github" tokens and update them in the same file).

In `@src/framework/config.ts`:
- Around line 448-466: The uninstall code currently calls
ensureArray(config.plugin) and ensureArray(config.instructions) which silently
coerces malformed user-owned values (objects/strings) into [] and causes
unexpected deletions; change these branches (the blocks using ensureArray with
removePluginSpecFromEntries and removeManagedInstructionEntries, keyed by
options.state.ownership.addedOpencodePlugin and
options.state.ownership.addedInstructions) to first validate the existing shapes
by using the same validation used in readArrayConfigValue() (or equivalent
type-checking) and if the value is not a proper array, surface a conflict/error
instead of proceeding, leaving the original config untouched and not toggling
changed; only call removePluginSpecFromEntries/removeManagedInstructionEntries
when the value is confirmed to be an array.
- Around line 337-340: The code currently treats pre-existing mcp.<name> entries
as "added" when bootstrap modifies them (using currentValue,
wasPreviouslyManaged, entryChanged), which can cause removeFrameworkConfig() to
delete user-owned data; change the logic so addedMcpKeys/addedMcpHashes are only
populated for keys the framework actually created from scratch (i.e., when
currentValue === undefined) instead of whenever an existing key was changed;
update the condition around addedMcpKeys.push(diagnostic.name) and
addedMcpHashes[diagnostic.name] = hashJsonValue(mergedValue) to require
currentValue === undefined, or alternatively extend FrameworkInstallState to
persist the original value (store originalValue keyed by diagnostic.name) and
write/read that field so removeFrameworkConfig() can restore originals—use the
simpler preferrable fix of restricting addedMcpKeys to newly created keys unless
you also update FrameworkInstallState and removeFrameworkConfig() to support
full rollback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cb9c5070-1520-4868-8698-4b7a7196ef83

📥 Commits

Reviewing files that changed from the base of the PR and between 99ece10 and e2bdd6f.

📒 Files selected for processing (15)
  • .gitignore
  • .npmignore
  • ARCHITECTURE.md
  • COMMANDS.md
  • PROJECT_INDEX.json
  • PROJECT_INDEX.md
  • README.md
  • src/cli.ts
  • src/framework/config.ts
  • src/framework/engine.ts
  • src/framework/state.ts
  • src/framework/types.ts
  • src/runtime/plugin.ts
  • tests/framework.test.mjs
  • tests/plugin-hooks.test.mjs
✅ Files skipped from review due to trivial changes (6)
  • .gitignore
  • .npmignore
  • COMMANDS.md
  • PROJECT_INDEX.json
  • tests/framework.test.mjs
  • src/framework/types.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/plugin-hooks.test.mjs
  • src/cli.ts
  • src/framework/state.ts
  • src/framework/engine.ts

Comment thread PROJECT_INDEX.md

| Path | Kind | Notes |
|---|---|---|
| `.github/workflows/` | automation | CI and npm publish workflows |
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Capitalize GitHub consistently in user-facing docs.

These entries use github in prose/path labels. The platform name should be GitHub.

Also applies to: 112-113, 119-120, 145-145

🧰 Tools
🪛 LanguageTool

[uncategorized] ~48-~48: The official name of this software platform is spelled with a capital “H”.
Context: ...| Path | Kind | Notes | |---|---|---| | .github/workflows/ | automation | CI and npm p...

(GITHUB)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PROJECT_INDEX.md` at line 48, Update the user-facing occurrences of "github"
to "GitHub" in PROJECT_INDEX.md; specifically replace the path/label string
`.github/workflows/` and the other listed entries (lines referencing "github" at
the mentioned locations) so the platform name is capitalized consistently, e.g.,
change `.github/workflows/` to `.github/workflows/` in display but ensure any
prose or label that reads "github" becomes "GitHub" (search for the lowercase
"github" tokens and update them in the same file).

Comment thread src/framework/config.ts Outdated
Comment thread src/framework/config.ts
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (6)
tests/framework.test.mjs (2)

940-963: Potential test environment issue with empty PATH.

Setting PATH: '' to simulate missing binaries may cause unexpected failures if the test framework or Node.js internals rely on PATH-resolved utilities. Consider using a non-existent path instead.

💡 Suggested improvement
         env: {
-          PATH: '',
+          PATH: '/nonexistent-path-for-test',
           PATHEXT: process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/framework.test.mjs` around lines 940 - 963, The test 'marks
binary-backed MCPs disabled when binaries are unavailable' constructs an env
with PATH: '' which can unpredictably break Node or test tooling; update the
test to set PATH to a guaranteed-nonexistent directory (e.g., '/no-such-path' or
platform-appropriate bogus path) when calling statusFramework({ env: { PATH, ...
} }) so binaries cannot be found but the runtime's PATH handling remains stable,
leaving PATHEXT and other keys unchanged.

57-59: Consider adding error handling for build failures in beforeAll.

If bun run build fails, subsequent tests will run against stale/missing artifacts with confusing errors. The current setup silently proceeds. Consider logging the failure or re-throwing with context.

💡 Suggested improvement
 beforeAll(async () => {
-  await execFileAsync('bun', ['run', 'build'], { cwd: projectRoot, env: process.env, timeout: 60000 })
+  try {
+    await execFileAsync('bun', ['run', 'build'], { cwd: projectRoot, env: process.env, timeout: 60000 })
+  } catch (error) {
+    console.error('Build failed:', error.stderr || error.message)
+    throw error
+  }
 }, 120000)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/framework.test.mjs` around lines 57 - 59, The beforeAll hook calling
execFileAsync('bun', ['run', 'build'], { cwd: projectRoot, env: process.env,
timeout: 60000 }) should handle failures explicitly: wrap the await call in a
try/catch inside beforeAll, log the error (including stdout/stderr if available)
and re-throw or throw a new Error with context so the test run fails fast
instead of proceeding with stale/missing artifacts; update the beforeAll block
to catch errors from execFileAsync and surface them with a descriptive message
referencing the build step.
src/framework/config.ts (2)

119-140: Recursive JSONC editing may accumulate formatting drift.

The applyJsoncObjectEdits function applies edits key-by-key recursively. Each modify + applyEdits call regenerates the text, which could introduce subtle formatting changes over multiple nested edits. While functionally correct, deeply nested configs may see whitespace drift over repeated operations.

This is acceptable for the current use case but worth noting if config formatting stability becomes a concern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/framework/config.ts` around lines 119 - 140, applyJsoncObjectEdits
applies modify + applyEdits per key recursively which can cause cumulative
formatting drift; change it to gather all TextEdit results (calls to modify) for
the full traversal (use applyJsoncObjectEdits to collect edits into an array)
and then call applyEdits once on the original sourceText, or alternatively
perform a single modify call for the whole object at the top-level, so that
modify/applyEdits are not invoked repeatedly; refer to applyJsoncObjectEdits,
modify, applyEdits and pathSegments when locating where to switch from per-key
apply to batching edits.

502-514: Duplicate file-removal check can be consolidated.

Lines 502-509 and 511-514 both check createdTuiConfig && !hasMeaningfulConfigContent(config) and delete the file. The first branch handles !changed while the second handles changed. Consider consolidating:

-  if (!changed) {
-    if (options.state.ownership.createdTuiConfig && !hasMeaningfulConfigContent(config)) {
-      await rm(options.filePath, { force: true })
-      return { changed: true, removedFile: true }
-    }
-
-    return { changed: false, removedFile: false }
-  }
-
-  if (options.state.ownership.createdTuiConfig && !hasMeaningfulConfigContent(config)) {
+  if (options.state.ownership.createdTuiConfig && !hasMeaningfulConfigContent(config)) {
     await rm(options.filePath, { force: true })
     return { changed: true, removedFile: true }
   }
+
+  if (!changed) {
+    return { changed: false, removedFile: false }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/framework/config.ts` around lines 502 - 514, The duplicate file-removal
check should be run once after determining the final `changed` value instead of
in both branches; remove the second block and the first duplicate, then after
computing `changed` call `if (options.state.ownership.createdTuiConfig &&
!hasMeaningfulConfigContent(config)) { await rm(options.filePath, { force: true
}); return { changed: true, removedFile: true }; }` so that
`rm(options.filePath)` and the `{ changed: true, removedFile: true }` return are
centralized (references: variable `changed`,
`options.state.ownership.createdTuiConfig`, `hasMeaningfulConfigContent`, `rm`,
and `options.filePath`).
src/framework/jsonc.ts (1)

47-52: Consider handling undefined entry values defensively.

Line 49 extracts value from entries but doesn't guard against undefined array elements (e.g., sparse arrays or explicit undefined entries). While value === spec would be false for undefined, value?.startsWith(...) correctly handles it via optional chaining.

The logic is functionally correct, but an explicit early continue for non-string/non-array entries could improve clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/framework/jsonc.ts` around lines 47 - 52, The hasPluginSpec function
currently computes value from entries without explicitly skipping undefined or
non-string/non-array items; update hasPluginSpec so it first checks each entry
and early-continues (returns false for that iteration) when entry is undefined
or not a string and not an array whose first element is a string, then only
compute value and compare using value === spec or value.startsWith(`${spec}@`);
target the hasPluginSpec function and the local variable value to add this
defensive guard for sparse/undefined array elements.
src/framework/file-write.ts (1)

11-26: Retry logic introduces a small race window.

The fallback path (Lines 19-25) removes the destination file then retries the rename. Between rm(filePath) and rename(tempPath, filePath), another process could create or lock the file, causing the retry to fail. This is unlikely in typical usage but could occur under concurrent framework operations.

Additionally, EEXIST is unexpected here since POSIX rename() atomically replaces the target. This branch likely only triggers on Windows with EPERM. Consider documenting this Windows-specific behavior or simplifying the condition.

💡 Suggested documentation improvement
+  // On Windows, rename can fail with EPERM when the target file is locked.
+  // EEXIST is included defensively but should not occur on POSIX systems.
   try {
     await rename(tempPath, filePath)
   } catch (error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/framework/file-write.ts` around lines 11 - 26, The fallback path that
does await rm(filePath) then a single retry of await rename(tempPath, filePath)
has a race window where another process can create or lock filePath between rm
and rename; update the logic in the catch block that handles (error as
NodeJS.ErrnoException).code === "EPERM" || "EEXIST" to perform a safe retry
loop: after rm(filePath, {force:true}) attempt rename(tempPath, filePath)
multiple times with a short exponential backoff and an existence/lock check
before each retry (e.g., stat or access on filePath) to avoid immediately
colliding with another creator, and limit retries before cleaning up tempPath
and rethrowing; also add a comment documenting that EEXIST is rare on POSIX and
this branch mainly targets Windows/EPERM behavior so callers understand the
platform nuance.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/framework/config.ts`:
- Around line 119-140: applyJsoncObjectEdits applies modify + applyEdits per key
recursively which can cause cumulative formatting drift; change it to gather all
TextEdit results (calls to modify) for the full traversal (use
applyJsoncObjectEdits to collect edits into an array) and then call applyEdits
once on the original sourceText, or alternatively perform a single modify call
for the whole object at the top-level, so that modify/applyEdits are not invoked
repeatedly; refer to applyJsoncObjectEdits, modify, applyEdits and pathSegments
when locating where to switch from per-key apply to batching edits.
- Around line 502-514: The duplicate file-removal check should be run once after
determining the final `changed` value instead of in both branches; remove the
second block and the first duplicate, then after computing `changed` call `if
(options.state.ownership.createdTuiConfig &&
!hasMeaningfulConfigContent(config)) { await rm(options.filePath, { force: true
}); return { changed: true, removedFile: true }; }` so that
`rm(options.filePath)` and the `{ changed: true, removedFile: true }` return are
centralized (references: variable `changed`,
`options.state.ownership.createdTuiConfig`, `hasMeaningfulConfigContent`, `rm`,
and `options.filePath`).

In `@src/framework/file-write.ts`:
- Around line 11-26: The fallback path that does await rm(filePath) then a
single retry of await rename(tempPath, filePath) has a race window where another
process can create or lock filePath between rm and rename; update the logic in
the catch block that handles (error as NodeJS.ErrnoException).code === "EPERM"
|| "EEXIST" to perform a safe retry loop: after rm(filePath, {force:true})
attempt rename(tempPath, filePath) multiple times with a short exponential
backoff and an existence/lock check before each retry (e.g., stat or access on
filePath) to avoid immediately colliding with another creator, and limit retries
before cleaning up tempPath and rethrowing; also add a comment documenting that
EEXIST is rare on POSIX and this branch mainly targets Windows/EPERM behavior so
callers understand the platform nuance.

In `@src/framework/jsonc.ts`:
- Around line 47-52: The hasPluginSpec function currently computes value from
entries without explicitly skipping undefined or non-string/non-array items;
update hasPluginSpec so it first checks each entry and early-continues (returns
false for that iteration) when entry is undefined or not a string and not an
array whose first element is a string, then only compute value and compare using
value === spec or value.startsWith(`${spec}@`); target the hasPluginSpec
function and the local variable value to add this defensive guard for
sparse/undefined array elements.

In `@tests/framework.test.mjs`:
- Around line 940-963: The test 'marks binary-backed MCPs disabled when binaries
are unavailable' constructs an env with PATH: '' which can unpredictably break
Node or test tooling; update the test to set PATH to a guaranteed-nonexistent
directory (e.g., '/no-such-path' or platform-appropriate bogus path) when
calling statusFramework({ env: { PATH, ... } }) so binaries cannot be found but
the runtime's PATH handling remains stable, leaving PATHEXT and other keys
unchanged.
- Around line 57-59: The beforeAll hook calling execFileAsync('bun', ['run',
'build'], { cwd: projectRoot, env: process.env, timeout: 60000 }) should handle
failures explicitly: wrap the await call in a try/catch inside beforeAll, log
the error (including stdout/stderr if available) and re-throw or throw a new
Error with context so the test run fails fast instead of proceeding with
stale/missing artifacts; update the beforeAll block to catch errors from
execFileAsync and surface them with a descriptive message referencing the build
step.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8840580d-37cc-493c-af60-76a0a0f0f72c

📥 Commits

Reviewing files that changed from the base of the PR and between e2bdd6f and cd583d7.

📒 Files selected for processing (13)
  • .gitignore
  • .npmignore
  • scripts/validate-structure.mjs
  • src/framework/config.ts
  • src/framework/engine.ts
  • src/framework/file-write.ts
  • src/framework/jsonc.ts
  • src/framework/manifest.ts
  • src/framework/prerequisites.ts
  • src/framework/state.ts
  • src/framework/types.ts
  • src/runtime/hooks.ts
  • tests/framework.test.mjs
✅ Files skipped from review due to trivial changes (4)
  • scripts/validate-structure.mjs
  • .npmignore
  • src/runtime/hooks.ts
  • src/framework/types.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/framework/manifest.ts
  • .gitignore
  • src/framework/state.ts
  • src/framework/prerequisites.ts
  • src/framework/engine.ts

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 4 new potential issues.

Open in Devin Review

Comment thread src/framework/config.ts
Comment on lines +260 to +288
const wasPreviouslyManaged = options.state?.ownership.addedMcpKeys.includes(diagnostic.name) ?? false
const previousManagedHash = options.state?.ownership.addedMcpHashes[diagnostic.name]
const divergedManagedEntry = currentValue !== undefined && previousManagedHash !== undefined && hashJsonValue(currentValue) !== previousManagedHash
const shouldRefreshManagedEntry = wasPreviouslyManaged && previousManagedHash !== undefined && !divergedManagedEntry

if (currentValue === undefined) {
addedMcpKeys.push(diagnostic.name)
}

// Managed MCP entries that still match their recorded hash should follow framework defaults on update.
// Diverged or pre-existing user entries keep their explicit values except for prerequisite-driven enablement.
const mergedValue = shouldRefreshManagedEntry
? mergeObjects({}, diagnostic.config)
: currentValue
? mergeObjects(diagnostic.config, currentValue)
: { ...diagnostic.config }

// Prerequisite diagnostics remain authoritative for runtime enablement.
mergedValue.enabled = diagnostic.enabled
const entryChanged = currentValue === undefined || !jsonValuesEqual(currentValue, mergedValue)
if (entryChanged) {
changed = true
}

mergedMcp[diagnostic.name] = mergedValue

if (currentValue === undefined || (wasPreviouslyManaged && (previousManagedHash === undefined || !divergedManagedEntry))) {
addedMcpHashes[diagnostic.name] = hashJsonValue(mergedValue)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: MCP ownership hash tracking preserves diverged entry hashes correctly

In patchOpencodeConfig at src/framework/config.ts:260-288, when a previously managed MCP entry has diverged (user modified it), the old hash is preserved in addedMcpHashes because the update condition on line 286 evaluates to false. This means subsequent updates continue to detect the divergence, and the user's modifications are never silently overwritten. During uninstall (removeFrameworkConfig at line 429), diverged entries produce conflicts and remain in the config. The hash-based ownership model is internally consistent.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/cli.ts
}

process.stdout.write(renderReport(report))
return report.items.some((item) => item.status === "conflict/manual action required") ? 2 : 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: CLI exit code 2 for conflicts causes execFileAsync rejection in tests

The runCli function at src/cli.ts:102 returns exit code 2 when conflicts are detected. The test at tests/framework.test.mjs:486-493 relies on execFileAsync rejecting on non-zero exit codes and accessing failure.stdout from the rejection error. This works because Node's child_process.execFile populates stdout and stderr on the error object when the child exits non-zero. This is a deliberate test pattern, not a fragile dependency.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread PROJECT_INDEX.json
@@ -0,0 +1,303 @@
{
"name": "super-opencode-framework",
"version": "1.0.1",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: PROJECT_INDEX.json contains hardcoded version 1.0.1

The PROJECT_INDEX.json at line 3 hardcodes "version": "1.0.1" while PROJECT_INDEX.md correctly defers to package.json and CHANGELOG.md for version. The JSON file's version will become stale after the next release bump. This is a documentation concern rather than a functional bug — the JSON file is a navigation aid and not consumed programmatically by the framework.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1 to +34
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"

function looksLikePackageRoot(candidate: string): boolean {
const packageJsonPath = path.join(candidate, "package.json")
if (!existsSync(packageJsonPath)) {
return false
}

try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string }
return packageJson.name === "super-opencode-framework"
} catch {
return false
}
}

export function findPackageRoot(fromUrl: string): string {
let current = path.dirname(fileURLToPath(fromUrl))

while (true) {
if (looksLikePackageRoot(current)) {
return current
}

const parent = path.dirname(current)
if (parent === current) {
throw new Error("Unable to resolve the super-opencode-framework package root")
}

current = parent
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Package root discovery uses synchronous fs operations

findPackageRoot at src/framework/package-root.ts:19-33 uses existsSync and readFileSync to walk up the directory tree looking for the package root. This is synchronous I/O which blocks the event loop. It's called from resolveScopePaths (which is called from every engine operation) and from loadFrameworkManifest. Since the manifest is cached after the first load and the path walk is typically only 2-3 directories deep, the practical impact is negligible. But it does mean the first call to any framework function has a synchronous I/O burst.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant