diff --git a/.gitignore b/.gitignore index aa5e519..6876184 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,11 @@ Thumbs.db *.log upstream-superclaude/ .serena/ -PROJECT_INDEX.md -PROJECT_INDEX.json .npmrc +BACKLOG*.md +FEATURE*.md +REFLECTION*.md +NOTES*.md docs/ pr-body.md pr-body-v2.md diff --git a/.npmignore b/.npmignore index b943495..a6197cc 100644 --- a/.npmignore +++ b/.npmignore @@ -4,6 +4,10 @@ tests/ coverage/ *.log .npmrc +BACKLOG*.md +FEATURE*.md +REFLECTION*.md +NOTES*.md docs/ pr-body.md pr-body-v2.md diff --git a/.opencode/plugins/super-opencode.ts b/.opencode/plugins/super-opencode.ts index ea474ca..c4f7fad 100644 --- a/.opencode/plugins/super-opencode.ts +++ b/.opencode/plugins/super-opencode.ts @@ -1,20 +1 @@ -import type { Plugin } from "@opencode-ai/plugin" -import { createCommandHooks } from "./super-opencode/commands.js" -import { createCompactionHooks } from "./super-opencode/compaction.js" -import { createSystemHooks } from "./super-opencode/system.js" - -export const SuperOpenCodePlugin: Plugin = async ({ client, worktree }) => { - await client.app.log({ - body: { - service: "super-opencode", - level: "info", - message: "Super OpenCode plugin initialized", - }, - }) - - return { - ...createSystemHooks(), - ...createCommandHooks(), - ...createCompactionHooks(worktree), - } -} +export { SuperOpenCodePlugin } from "../../src/runtime/plugin.js" diff --git a/.opencode/plugins/super-opencode/commands.ts b/.opencode/plugins/super-opencode/commands.ts index ab27125..8eebd17 100644 --- a/.opencode/plugins/super-opencode/commands.ts +++ b/.opencode/plugins/super-opencode/commands.ts @@ -1,30 +1 @@ -import { commandPersistenceHint } from './memory.js' - -const persistenceCommands = new Set(['sc-pm', 'sc-save', 'sc-load', 'sc-reflect']) -const checkpointCommands = new Set(['sc-save', 'sc-spawn', 'sc-workflow']) - -export const createCommandHooks = () => ({ - 'command.execute.before': async (input: { command: string; sessionID: string }, output: { parts: unknown[] }) => { - const normalized = input.command.replace(/^\//, '') - - if (persistenceCommands.has(normalized)) { - output.parts.push({ - id: 'super-opencode-persistence-hint', - sessionID: input.sessionID, - messageID: '', - type: 'text', - text: commandPersistenceHint, - }) - } - - if (checkpointCommands.has(normalized) && normalized !== 'sc-save') { - output.parts.push({ - id: 'super-opencode-checkpoint-hint', - sessionID: input.sessionID, - messageID: '', - type: 'text', - text: 'Consider using `/sc-save` to create a checkpoint before proceeding with long operations.', - }) - } - }, -}) +export { createCommandHooks } from "../../../src/runtime/hooks.js" diff --git a/.opencode/plugins/super-opencode/compaction.ts b/.opencode/plugins/super-opencode/compaction.ts index 102391b..2ff026c 100644 --- a/.opencode/plugins/super-opencode/compaction.ts +++ b/.opencode/plugins/super-opencode/compaction.ts @@ -1,14 +1 @@ -import { persistenceContract, autoCheckpointHint } from './memory.js' - -export const createCompactionHooks = (worktree: string) => ({ - 'experimental.session.compacting': async (_input: unknown, output: { context: string[] }) => { - output.context.push( - [ - '## Super OpenCode Memory', - `Worktree: ${worktree}`, - autoCheckpointHint, - persistenceContract, - ].join('\n'), - ) - }, -}) +export { createCompactionHooks } from "../../../src/runtime/hooks.js" diff --git a/.opencode/plugins/super-opencode/memory.ts b/.opencode/plugins/super-opencode/memory.ts index 410868c..64d8f42 100644 --- a/.opencode/plugins/super-opencode/memory.ts +++ b/.opencode/plugins/super-opencode/memory.ts @@ -1,17 +1 @@ -export const persistenceContract = [ - 'Serena is the persistence source of truth for this project.', - 'Use Serena memory keys `pm_context`, `current_plan`, `last_session`, `next_actions`, `checkpoint`, `decision`, and `summary` when relevant.', - 'For hierarchical task tracking: plan_[timestamp], phase_[1-5], task_[phase].[number], todo_[task].[number], checkpoint_[timestamp].', - 'When Serena is unavailable, state clearly that the session is operating in degraded persistence mode.', -].join(' ') - -export const commandPersistenceHint = [ - 'For `/sc-pm`, `/sc-save`, `/sc-load`, and `/sc-reflect`, prefer Serena memory tools first.', - 'For complex tasks: write_memory("plan_[timestamp]", goal_statement) → write_memory("phase_X", milestone) → write_memory("task_X.Y", deliverable).', - 'Use repo files only for public, durable documentation; keep session continuity in Serena rather than committed scratch files.', -].join(' ') - -export const autoCheckpointHint = [ - 'Consider creating a checkpoint with `/sc-save` every 30 minutes for long operations.', - 'Use `/sc-pm` to summarize current progress before pausing.', -].join(' ') +export { autoCheckpointHint, commandPersistenceHint, persistenceContract } from "../../../src/runtime/memory.js" diff --git a/.opencode/plugins/super-opencode/system.ts b/.opencode/plugins/super-opencode/system.ts index 7c50e6f..42b3ad7 100644 --- a/.opencode/plugins/super-opencode/system.ts +++ b/.opencode/plugins/super-opencode/system.ts @@ -1,7 +1 @@ -import { persistenceContract } from './memory.js' - -export const createSystemHooks = () => ({ - 'experimental.chat.system.transform': async (_input: unknown, output: { system: string[] }) => { - output.system.push(persistenceContract) - }, -}) +export { createSystemHooks } from "../../../src/runtime/hooks.js" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 344d11d..a44955b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,9 +2,14 @@ ## Overview -Super OpenCode is an npm-installable OpenCode plugin package. +Super OpenCode is a bi-target npm-installable OpenCode plugin package. -It packages a plugin runtime plus bundled command prompts, agent prompts, mode skills, and instruction files that together port key SuperClaude workflow ideas into the OpenCode ecosystem. +It packages: + +- a server runtime plugin for hooks +- a TUI plugin for Plugin Manager visibility, bootstrap UI, and diagnostics +- a shared manifest-driven bootstrap engine +- bundled command prompts, agent prompts, mode skills, and instruction files At runtime, the high-level flow is: @@ -58,41 +63,79 @@ These provide minimal but explicit support for the corresponding SuperClaude-ins ### Plugin Layer -Lives in `.opencode/plugins/`. +The runtime plugin is published through the npm package `./server` target. + +The local repo also keeps `.opencode/plugins/` wrappers for self-hosting during framework development, but the installer does not copy those plugin source files into user projects by default. -The plugin layer provides local behavior for: +The runtime layer provides: - persistence guidance - command hints - compaction/checkpoint hints - shared runtime glue +- duplicate-load protection so hooks stay single-active even if npm and local plugin copies coexist + +### TUI Layer + +The TUI target is published through `./tui`. + +It is responsible for: + +- Plugin Manager visibility +- first-load bootstrap prompting +- scope selection (`global` or `project`) +- install/status/update/uninstall UI +- final bootstrap reporting + +### Bootstrap Layer -This is the runtime plugin layer exposed by the npm package, not just internal repo code. +The shared bootstrap engine is the product-critical layer for installation correctness. + +It is manifest-driven and used by: + +- the npm TUI plugin +- the CLI entrypoint +- future maintenance flows + +It handles: + +- scope resolution +- asset sync for commands, agents, skills, and instructions +- `opencode.json` merge for plugin, instructions, and MCP config +- `tui.json` merge for TUI plugin registration +- MCP prerequisite diagnostics +- managed-file hashes for idempotent update and safe uninstall ### MCP Layer -Configured through `opencode.json` in the consuming project. +Configured through `opencode.json` in the chosen scope. -Expected strategy: +Current policy: -- `serena`: required for the full persistence workflow -- `context7`: recommended -- `sequential`: recommended -- `playwright`, `chrome-devtools`, `tavily`, `morph`: optional +- `serena`: enabled only when `uvx` is available +- `context7`: enabled only when `CONTEXT7_API_KEY` is present +- `sequential`: enabled only when `npx` is available +- `playwright`: enabled only when `npx` is available +- `chrome-devtools`: enabled only when `npx` is available +- `tavily`: enabled only when `npx` and `TAVILY_API_KEY` are present +- `morph`: enabled only when `npx` and `MORPH_API_KEY` are present Super OpenCode is designed to degrade gracefully when optional MCPs are absent. Serena is the main exception because it underpins the intended persistence model. ## Published Package Surface -The npm package publishes the plugin runtime and bundled assets: +The npm package publishes: +- `./server` +- `./tui` +- the shared bootstrap engine +- `framework.manifest.json` - `.opencode/commands/**/*.md` - `.opencode/agents/**/*.md` - `.opencode/skills/**/SKILL.md` -- `.opencode/plugins/**/*.ts` - `.opencode/examples/*.json` - `.opencode/instructions/*.md` -- installer script and package metadata +- the CLI wrapper and package metadata Repo-internal planning and memory files are not part of the public package contract. @@ -102,7 +145,7 @@ Repo-internal planning and memory files are not part of the public package contr - Bun 1.3.9+ - OpenCode -The public contract is intentionally modest: the package ships an OpenCode plugin runtime together with bundled `/sc-*` assets, and does not rely on a standalone executable runtime of its own. +The public contract is now explicit: the package ships an OpenCode server plugin, an OpenCode TUI plugin, and a bootstrap engine that materializes the framework assets into either global or project scope. ## Design Principles @@ -140,3 +183,9 @@ bun run release:check ``` `bun run release:check` is the package integrity gate because it rebuilds the package and validates the published surface. + +## See Also + +- `PROJECT_INDEX.md` for a compact repository map, entrypoints, and automation index +- `README.md` for installation and package-level usage +- `COMMANDS.md` for the `/sc-*` command catalog diff --git a/COMMANDS.md b/COMMANDS.md index d12280d..03f2d8a 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -111,6 +111,7 @@ Commands can load these skills when they materially improve execution quality. ## See Also +- `PROJECT_INDEX.md` for a compact repository map and entrypoint index - `USAGE.md` for practical usage patterns - `ARCHITECTURE.md` for the command-agent-skill-plugin model - `.opencode/commands/` for the source command definitions bundled by the package diff --git a/INSTALL.md b/INSTALL.md index 255c45d..da7afb8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,17 +16,18 @@ 4. Press `Shift+I` to install from npm. 5. Enter `super-opencode-framework`. -This is the preferred path when you want to install the package as an OpenCode plugin from the editor UX. +This is the preferred path when you want to install the package from the OpenCode UX. -What this installs: +What happens now: -- the npm plugin package itself -- the runtime plugin hooks exported by the package -- the bundled `/sc-*` commands, agents, skills, plugin files, and runtime instruction files that ship with the package +1. OpenCode installs the npm package. +2. Plugin Manager sees the package because it exposes both `./server` and `./tui`. +3. The TUI bootstrap asks you to confirm `project` or `global` scope. +4. The bootstrap syncs commands, agents, skills, instructions, `opencode.json`, and `tui.json` for that scope. +5. MCP config is merged and validated. +6. A final report explains what was installed, updated, skipped, or blocked. -If you want those bundled assets materialized as local files in your project, use the manual sync flow below. - -## Manual Install And Local Asset Sync +## Manual Install And Bootstrap ### 1. Install The Package @@ -47,55 +48,71 @@ npm install -D super-opencode-framework With Bun: ```bash -bunx super-opencode-framework install +bunx super-opencode-framework install --scope project +bunx super-opencode-framework install --scope global ``` With npm: ```bash -npx super-opencode-framework install +npx super-opencode-framework install --scope project +npx super-opencode-framework install --scope global ``` -Optional flags: +Maintenance commands: With Bun: ```bash -bunx super-opencode-framework install --target /path/to/project -bunx super-opencode-framework install --force +bunx super-opencode-framework status --scope project +bunx super-opencode-framework update --scope project +bunx super-opencode-framework uninstall --scope project +bunx super-opencode-framework install --scope project --force +bunx super-opencode-framework install --scope global --force ``` With npm: ```bash -npx super-opencode-framework install --target /path/to/project -npx super-opencode-framework install --force +npx super-opencode-framework status --scope project +npx super-opencode-framework update --scope project +npx super-opencode-framework uninstall --scope project +npx super-opencode-framework install --scope project --force +npx super-opencode-framework install --scope global --force ``` -### 3. Verify Your Project Config - -The sync command copies the bundled OpenCode assets into your project and updates `opencode.json` when it already exists. +### 3. Scope Behavior -Review these locations after installation: +Project scope writes only to the current repository: - `.opencode/commands` - `.opencode/agents` - `.opencode/skills` -- `.opencode/plugins` -- `.opencode/instructions/opencode-core.md` +- `.opencode/instructions` +- `opencode.json` +- `tui.json` -### 4. Configure MCPs +Global scope writes only to the OpenCode global config directory: -Super OpenCode expects Serena for the full persistence workflow. +- `~/.config/opencode/commands` +- `~/.config/opencode/agents` +- `~/.config/opencode/skills` +- `~/.config/opencode/instructions` +- `~/.config/opencode/opencode.json` +- `~/.config/opencode/tui.json` -Recommended MCP strategy: +The scope is always explicit. If both global and project are installed, project assets override global assets. + +### 4. Configure MCPs -- `serena`: enabled -- `context7`: optional, recommended -- `sequential`: optional, recommended -- `playwright`, `chrome-devtools`, `tavily`, `morph`: optional +The bootstrap always merges framework MCP definitions into `opencode.json` and evaluates prerequisites before enabling them. -Check the example config in `.opencode/examples/opencode.example.json`. +Reported states: + +- `configured and enabled` +- `configured but disabled by missing env` +- `configured but disabled by missing binary` +- `configured but requires auth/manual setup` ### 5. Start OpenCode @@ -127,28 +144,22 @@ If Node.js 24+ or Bun is missing, install them first and rerun the installer. ### No `opencode.json` In The Target Project -The sync command can copy the bundled assets without `opencode.json`, but it cannot update instructions automatically. - -Add this path manually to your OpenCode config: - -```json -{ - "instructions": [".opencode/instructions/opencode-core.md"] -} -``` +The bootstrap creates `opencode.json` and `tui.json` when they do not already exist. ### Re-Sync The Bundled Assets With Bun: ```bash -bunx super-opencode-framework install --force +bunx super-opencode-framework update --scope project +bunx super-opencode-framework install --scope project --force ``` With npm: ```bash -npx super-opencode-framework install --force +npx super-opencode-framework update --scope project +npx super-opencode-framework install --scope project --force ``` ### Validate The Package Locally diff --git a/PROJECT_INDEX.json b/PROJECT_INDEX.json new file mode 100644 index 0000000..4377f05 --- /dev/null +++ b/PROJECT_INDEX.json @@ -0,0 +1,303 @@ +{ + "name": "super-opencode-framework", + "version": "1.0.1", + "kind": "opencode-framework-plugin", + "summary": "OpenCode plugin package with a server runtime, a TUI plugin, and a manifest-driven bootstrap engine that syncs Super OpenCode commands, agents, skills, and instructions into project or global scope.", + "counts": { + "commands": 28, + "agents": 15, + "skills": 6, + "testFiles": 2, + "testCases": 33, + "scripts": 4, + "githubWorkflows": 2 + }, + "entrypoints": [ + { + "path": "src/server.ts", + "kind": "server-plugin", + "exports": ["default", "SuperOpenCodePlugin"] + }, + { + "path": "src/tui.ts", + "kind": "tui-plugin", + "exports": ["default"] + }, + { + "path": "src/cli.ts", + "kind": "cli-module", + "exports": ["runCli"] + }, + { + "path": "scripts/install-project.mjs", + "kind": "published-bin-shim", + "targets": ["dist/src/cli.js"] + } + ], + "structure": [ + { + "path": ".github/workflows", + "role": "CI and publish automation" + }, + { + "path": "src/framework", + "role": "bootstrap engine, manifest loading, config patching, state, and MCP diagnostics", + "files": [ + "config.ts", + "engine.ts", + "manifest.ts", + "package-root.ts", + "paths.ts", + "prerequisites.ts", + "state.ts", + "types.ts" + ] + }, + { + "path": "src/runtime", + "role": "runtime hooks and Serena-oriented persistence guidance", + "files": ["hooks.ts", "memory.ts", "plugin.ts"] + }, + { + "path": ".opencode/commands", + "role": "bundled /sc-* command assets" + }, + { + "path": ".opencode/agents", + "role": "bundled specialist agent prompts" + }, + { + "path": ".opencode/skills", + "role": "bundled mode and process skills" + }, + { + "path": ".opencode/instructions", + "role": "OpenCode-specific behavioral instructions" + }, + { + "path": "tests", + "role": "bootstrap and runtime regression coverage" + }, + { + "path": "scripts", + "role": "validation and published CLI wrapper scripts" + }, + { + "path": "dist", + "role": "generated build output" + }, + { + "path": "node_modules", + "role": "installed dependencies" + } + ], + "workflowAssets": { + "commandsPath": ".opencode/commands", + "agentsPath": ".opencode/agents", + "skillsPath": ".opencode/skills", + "instructionsPath": ".opencode/instructions/opencode-core.md", + "exampleConfigPath": ".opencode/examples/opencode.example.json" + }, + "docs": [ + { + "path": "README.md", + "role": "overview, install flow, CLI usage, MCP strategy" + }, + { + "path": "INSTALL.md", + "role": "installation details and troubleshooting" + }, + { + "path": "USAGE.md", + "role": "usage patterns and examples" + }, + { + "path": "COMMANDS.md", + "role": "command reference" + }, + { + "path": "ARCHITECTURE.md", + "role": "layer model and package surface" + }, + { + "path": "CHANGELOG.md", + "role": "release history" + }, + { + "path": "CONTRIBUTING.md", + "role": "contribution workflow" + }, + { + "path": "AGENTS.md", + "role": "repo-specific agent instructions" + } + ], + "config": [ + { + "path": "package.json", + "role": "package metadata, exports, bin, scripts" + }, + { + "path": "framework.manifest.json", + "role": "asset sync and MCP policy manifest" + }, + { + "path": "opencode.json", + "role": "OpenCode config and MCP defaults" + }, + { + "path": "tui.json", + "role": "TUI plugin registration" + }, + { + "path": "tsconfig.json", + "role": "base TypeScript config" + }, + { + "path": "tsconfig.build.json", + "role": "build output config" + }, + { + "path": ".github/workflows/ci.yml", + "role": "CI validation workflow" + }, + { + "path": ".github/workflows/publish.yml", + "role": "release and publish workflow" + } + ], + "automation": [ + { + "path": ".github/workflows/ci.yml", + "trigger": "push and pull_request to main", + "checks": [ + "typecheck", + "validate:structure", + "bun test", + "validate-cross-platform", + "release:check" + ] + }, + { + "path": ".github/workflows/publish.yml", + "trigger": "push tags matching v*", + "checks": [ + "typecheck", + "validate:structure", + "bun test", + "release:check", + "tag/version match", + "npm publish" + ] + } + ], + "scripts": [ + { + "path": "scripts/install-project.mjs", + "role": "published bin shim" + }, + { + "path": "scripts/validate-structure.mjs", + "role": "structure validation" + }, + { + "path": "scripts/validate-package.mjs", + "role": "package surface validation" + }, + { + "path": "scripts/validate-cross-platform.mjs", + "role": "scaffold/bootstrap portability validation" + } + ], + "api": { + "publicSurface": [ + { + "name": "default export", + "path": "src/server.ts", + "notes": "OpenCode server plugin module" + }, + { + "name": "SuperOpenCodePlugin", + "path": "src/server.ts", + "notes": "named runtime plugin export" + }, + { + "name": "./server", + "path": "dist/src/server.js", + "notes": "published package export target" + }, + { + "name": "./tui", + "path": "dist/src/tui.js", + "notes": "published package export target" + }, + { + "name": "super-opencode-framework", + "path": "scripts/install-project.mjs", + "notes": "published CLI bin" + } + ], + "keyInternalModules": [ + { + "path": "src/framework/engine.ts", + "symbols": [ + "installFramework", + "statusFramework", + "updateFramework", + "uninstallFramework", + "detectFrameworkScopes" + ] + }, + { + "path": "src/framework/config.ts", + "symbols": [ + "patchOpencodeConfig", + "patchTuiConfig", + "removeFrameworkConfig", + "removeFrameworkTuiConfig" + ] + }, + { + "path": "src/framework/manifest.ts", + "symbols": ["loadFrameworkManifest"] + }, + { + "path": "src/runtime/hooks.ts", + "symbols": ["createSystemHooks", "createCommandHooks", "createCompactionHooks"] + }, + { + "path": "src/cli.ts", + "symbols": ["runCli"] + } + ] + }, + "tests": [ + { + "path": "tests/framework.test.mjs", + "areas": [ + "package exports", + "scope detection", + "project/global install", + "idempotence", + "conflict handling", + "update and uninstall safety", + "MCP diagnostics", + "status reporting" + ] + }, + { + "path": "tests/plugin-hooks.test.mjs", + "areas": [ + "system hook injection", + "command hint deduplication", + "compaction guidance deduplication", + "runtime single-registration" + ] + } + ], + "manualDocsPreserved": true, + "notes": [ + "PROJECT_INDEX files are additive navigation artifacts, not architecture replacements.", + "Runtime contract is aligned on Node.js 24+ across docs and package metadata." + ] +} diff --git a/PROJECT_INDEX.md b/PROJECT_INDEX.md new file mode 100644 index 0000000..de897b1 --- /dev/null +++ b/PROJECT_INDEX.md @@ -0,0 +1,150 @@ +# Project Index + +Compact repository index for fast orientation. Existing hand-written docs remain the authoritative long-form references. + +## Snapshot + +| Item | Value | +|---|---| +| Package | `super-opencode-framework` | +| Version | See [`package.json`](package.json) and [`CHANGELOG.md`](CHANGELOG.md) for the current release metadata. | +| Language | TypeScript (`module: NodeNext`) | +| Package targets | `./server`, `./tui`, CLI bin | +| Core product shape | OpenCode plugin runtime + TUI plugin + manifest-driven bootstrap engine | +| Shipped framework assets | See [`framework.manifest.json`](framework.manifest.json) and [`.opencode/`](.opencode/) for the current packaged asset set. | +| Test files | See [`tests/`](tests/) for the current regression suite. | + +## README Summary + +Super OpenCode packages the SuperClaude-style `/sc-*` workflow layer as an npm-installable OpenCode plugin. The repository centers on a shared bootstrap engine that syncs command, agent, skill, and instruction assets into either project or global scope, while the runtime plugin adds persistence guidance and hook behavior. + +## Entry Points + +| Path | Role | +|---|---| +| [`src/server.ts`](src/server.ts) | Server plugin entrypoint. Exports the default plugin module and `SuperOpenCodePlugin`. | +| [`src/tui.ts`](src/tui.ts) | TUI plugin entrypoint. Exposes install, status, update, and uninstall actions through OpenCode UI dialogs. | +| [`scripts/install-project.mjs`](scripts/install-project.mjs) | CLI shim used by the published bin. Loads `dist/src/cli.js` and forwards argv. | +| [`src/cli.ts`](src/cli.ts) | CLI command parser and report renderer for `install`, `status`, `update`, `uninstall`, and `scopes`. | + +## Structure Overview + +| Area | Purpose | Key Files | +|---|---|---| +| [`src/framework/`](src/framework) | Bootstrap engine, manifest loading, config patching, scope resolution, install-state handling, MCP diagnostics | `engine.ts`, `config.ts`, `manifest.ts`, `prerequisites.ts`, `state.ts`, `paths.ts` | +| [`src/runtime/`](src/runtime) | Runtime hooks, persistence guidance, duplicate-load protection | `plugin.ts`, `hooks.ts`, `memory.ts` | +| [`src/`](src) | Public package entrypoints | `server.ts`, `tui.ts`, `cli.ts` | +| [`.opencode/commands/`](.opencode/commands) | User-facing `/sc-*` command assets | 28 markdown command definitions | +| [`.opencode/agents/`](.opencode/agents) | Specialist agent prompts | 15 agent definitions | +| [`.opencode/skills/`](.opencode/skills) | Mode and process skills | 6 packaged skills | +| [`.opencode/instructions/`](.opencode/instructions) | OpenCode-specific behavioral layer | `opencode-core.md` | +| [`tests/`](tests) | Bootstrap and runtime regression coverage | `framework.test.mjs`, `plugin-hooks.test.mjs` | +| [`scripts/`](scripts) | Package validation and CLI wrapper scripts | `install-project.mjs`, `validate-package.mjs`, `validate-structure.mjs`, `validate-cross-platform.mjs` | + +## Top-Level Map + +| Path | Kind | Notes | +|---|---|---| +| `.github/workflows/` | automation | CI and npm publish workflows | +| `.opencode/` | framework assets | Commands, agents, skills, instructions, examples, local dev helpers | +| `src/` | source | Plugin runtime, TUI, CLI, bootstrap engine | +| `scripts/` | tooling | Validation and published CLI shim | +| `tests/` | validation | Bun-based regression coverage | +| `dist/` | generated | Build output, not primary source of truth | +| `node_modules/` | generated | Installed dependencies | + +## Workflow Assets + +| Area | Summary | +|---|---| +| Commands | 28 `/sc-*` command prompts in [`.opencode/commands/`](.opencode/commands) | +| Agents | 15 specialist prompts in [`.opencode/agents/`](.opencode/agents) | +| Skills | 6 packaged skills in [`.opencode/skills/`](.opencode/skills) | +| Instructions | OpenCode runtime behavior in [`.opencode/instructions/opencode-core.md`](.opencode/instructions/opencode-core.md) | +| Example config | [`.opencode/examples/opencode.example.json`](.opencode/examples/opencode.example.json) shows a fuller reference setup | + +## Docs Index + +| File | Purpose | +|---|---| +| [`README.md`](README.md) | Product overview, install flow, CLI usage, MCP policy, release summary | +| [`INSTALL.md`](INSTALL.md) | Installation details and troubleshooting | +| [`USAGE.md`](USAGE.md) | Practical usage patterns and examples | +| [`COMMANDS.md`](COMMANDS.md) | `/sc-*` command reference | +| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Layer model, package surface, runtime flow, validation contract | +| [`CHANGELOG.md`](CHANGELOG.md) | Release history | +| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Contribution and development workflow | +| [`AGENTS.md`](AGENTS.md) | Repo-specific execution rules for agents | + +## API Index + +### Public package surface + +| Export | Source | Notes | +|---|---|---| +| package default export | [`src/server.ts`](src/server.ts) | OpenCode server plugin module with id `super-opencode-framework` | +| `SuperOpenCodePlugin` | [`src/server.ts`](src/server.ts) | Named export for runtime plugin hook registration | +| `./server` | [`package.json`](package.json) | Published target resolved to `dist/src/server.js` | +| `./tui` | [`package.json`](package.json) | Published target resolved to `dist/src/tui.js` | +| CLI bin `super-opencode-framework` | [`package.json`](package.json) | Published bin resolved through `scripts/install-project.mjs` | + +### Key internal modules + +| Module | Important symbols | +|---|---| +| [`src/framework/engine.ts`](src/framework/engine.ts) | `installFramework`, `statusFramework`, `updateFramework`, `uninstallFramework`, `detectFrameworkScopes` | +| [`src/framework/config.ts`](src/framework/config.ts) | `patchOpencodeConfig`, `patchTuiConfig`, `removeFrameworkConfig`, `removeFrameworkTuiConfig` | +| [`src/framework/manifest.ts`](src/framework/manifest.ts) | `loadFrameworkManifest` | +| [`src/runtime/plugin.ts`](src/runtime/plugin.ts) | `SuperOpenCodePlugin` | +| [`src/runtime/hooks.ts`](src/runtime/hooks.ts) | `createSystemHooks`, `createCommandHooks`, `createCompactionHooks` | +| [`src/cli.ts`](src/cli.ts) | `runCli` | + +## Config Surfaces + +| File | Role | +|---|---| +| [`package.json`](package.json) | Published package metadata, exports, bin, scripts, dependencies | +| [`framework.manifest.json`](framework.manifest.json) | Asset-group map, target locations, MCP policy definitions | +| [`opencode.json`](opencode.json) | Local framework config, instructions, watcher, MCP defaults | +| [`tui.json`](tui.json) | Local TUI plugin registration | +| [`tsconfig.json`](tsconfig.json) | Base TypeScript settings | +| [`tsconfig.build.json`](tsconfig.build.json) | Build output settings for `dist/` | +| [`.github/workflows/ci.yml`](.github/workflows/ci.yml) | CI validation workflow | +| [`.github/workflows/publish.yml`](.github/workflows/publish.yml) | Release/publish workflow | + +## Automation + +| File | Trigger | What it validates | +|---|---|---| +| [`.github/workflows/ci.yml`](.github/workflows/ci.yml) | pushes and PRs to `main` | typecheck, structure validation, tests, scaffold validation, release package validation | +| [`.github/workflows/publish.yml`](.github/workflows/publish.yml) | `v*` tags | install, validate, test, `release:check`, tag/version match, npm publish | + +## Scripts + +| File | Role | +|---|---| +| [`scripts/install-project.mjs`](scripts/install-project.mjs) | Published bin shim that loads the built CLI | +| [`scripts/validate-structure.mjs`](scripts/validate-structure.mjs) | Structural consistency validation | +| [`scripts/validate-package.mjs`](scripts/validate-package.mjs) | Package surface validation for release checks | +| [`scripts/validate-cross-platform.mjs`](scripts/validate-cross-platform.mjs) | Scaffold/bootstrap portability validation | + +## Tests + +| File | Coverage focus | +|---|---| +| [`tests/framework.test.mjs`](tests/framework.test.mjs) | package targets, scoped install flows, idempotence, conflict handling, update/uninstall safety, MCP diagnostics, status reporting | +| [`tests/plugin-hooks.test.mjs`](tests/plugin-hooks.test.mjs) | persistence contract injection, hook deduplication, runtime single-registration behavior | + +## Navigation Shortcuts + +- Bootstrap behavior: [`src/framework/engine.ts`](src/framework/engine.ts) +- Runtime behavior: [`src/runtime/plugin.ts`](src/runtime/plugin.ts), [`src/runtime/hooks.ts`](src/runtime/hooks.ts), [`src/runtime/memory.ts`](src/runtime/memory.ts) +- Command catalog: [`COMMANDS.md`](COMMANDS.md), [`.opencode/commands/`](.opencode/commands) +- Behavior rules: [`AGENTS.md`](AGENTS.md), [`.opencode/instructions/opencode-core.md`](.opencode/instructions/opencode-core.md) +- Installation policy: [`README.md`](README.md), [`INSTALL.md`](INSTALL.md), [`framework.manifest.json`](framework.manifest.json) +- Repo automation: [`.github/workflows/ci.yml`](.github/workflows/ci.yml), [`.github/workflows/publish.yml`](.github/workflows/publish.yml), [`scripts/`](scripts) + +## Alignment Notes + +- This index is additive and does not replace hand-written documentation. +- Runtime contract is aligned on Node.js `24+` across docs and package metadata. diff --git a/README.md b/README.md index e81fb66..6449508 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Super OpenCode -> OpenCode plugin package that ports key SuperClaude workflows and includes bundled `/sc-*` assets plus local sync support. +> OpenCode framework plugin package with a real post-install bootstrap for commands, agents, skills, instructions, MCP config, diagnostics, and explicit global/project scopes. [![CI](https://github.com/papastanb/super-opencode/actions/workflows/ci.yml/badge.svg)](https://github.com/papastanb/super-opencode/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -12,9 +12,11 @@ - 28 `/sc-*` commands for common engineering workflows - 15 specialist agent prompts - 6 reusable mode skills -- An npm-installable OpenCode plugin runtime -- Bundled command, agent, skill, plugin, and instruction assets +- An npm-installable OpenCode plugin runtime with explicit `./server` and `./tui` targets +- A manifest-driven bootstrap engine shared by the TUI, CLI, and maintenance flows +- Bundled command, agent, skill, and instruction assets with scope-aware sync - Serena-first persistence guidance for OpenCode sessions +- Idempotent install, status, update, and uninstall commands ## Runtime Contract @@ -32,37 +34,66 @@ Primary flow from the OpenCode UI: 4. Press `Shift+I` to install from npm. 5. Enter `super-opencode-framework`. -This installs the npm package that contains the Super OpenCode plugin runtime and its bundled assets. +This installs the package and makes it visible in Plugin Manager because the package now exposes a real TUI target. -## Sync Bundled Assets Locally +On first load, the TUI bootstrap asks you to confirm a scope: -If you want those packaged `/sc-*` commands, agents, skills, plugins, and runtime instruction files materialized as local files in the current repository, use the manual sync command below. +- `project`: sync into `/.opencode`, `/opencode.json`, and `/tui.json` +- `global`: sync into `~/.config/opencode`, `~/.config/opencode/opencode.json`, and `~/.config/opencode/tui.json` -## Manual Package Install And Sync +The bootstrap then: + +- syncs commands, agents, skills, and instructions +- merges plugin, instructions, and MCP config without duplication +- validates MCP prerequisites and reports enabled/disabled states +- records managed file hashes so re-runs are idempotent +- avoids copying local plugin source files by default + +## CLI Bootstrap With Bun: ```bash bun add -d super-opencode-framework -bunx super-opencode-framework install +bunx super-opencode-framework install --scope project +bunx super-opencode-framework install --scope global +bunx super-opencode-framework status --scope project +bunx super-opencode-framework update --scope project +bunx super-opencode-framework uninstall --scope project ``` With npm: ```bash npm install -D super-opencode-framework -npx super-opencode-framework install +npx super-opencode-framework install --scope project +npx super-opencode-framework install --scope global +npx super-opencode-framework status --scope project +npx super-opencode-framework update --scope project +npx super-opencode-framework uninstall --scope project ``` -This syncs the bundled OpenCode assets into the current project: +The CLI uses the same engine as the TUI bootstrap. The scope is always explicit and never guessed silently. + +Project scope syncs these locations: - `.opencode/commands` - `.opencode/agents` - `.opencode/skills` -- `.opencode/plugins` - `.opencode/instructions/opencode-core.md` +- `opencode.json` +- `tui.json` + +Global scope syncs these locations: + +- `~/.config/opencode/commands` +- `~/.config/opencode/agents` +- `~/.config/opencode/skills` +- `~/.config/opencode/instructions/opencode-core.md` +- `~/.config/opencode/opencode.json` +- `~/.config/opencode/tui.json` -If `opencode.json` already exists, the sync command appends `.opencode/instructions/opencode-core.md` to the `instructions` array when needed. +Project assets override global assets when both scopes are installed. ## Develop This Repository @@ -82,10 +113,28 @@ bun run release:check - `sequential`: optional, recommended for structured reasoning - `playwright`, `chrome-devtools`, `tavily`, `morph`: optional, task-dependent -The repo config enables `serena` and keeps the other MCPs available but disabled by default. See `.opencode/examples/opencode.example.json` for a fuller setup. +The bootstrap always writes MCP config for the framework set and then evaluates prerequisites. + +Possible states: + +- `configured and enabled` +- `configured but disabled by missing env` +- `configured but disabled by missing binary` +- `configured but requires auth/manual setup` + +Current policy covers: + +- `serena` +- `context7` +- `sequential` +- `playwright` +- `chrome-devtools` +- `tavily` +- `morph` ## Documentation +- [PROJECT_INDEX.md](PROJECT_INDEX.md): compact repository index for fast orientation - [INSTALL.md](INSTALL.md): installation details and troubleshooting - [USAGE.md](USAGE.md): usage patterns and command examples - [COMMANDS.md](COMMANDS.md): command reference @@ -95,7 +144,7 @@ The repo config enables `serena` and keeps the other MCPs available but disabled This repository is the source and development home for Super OpenCode. -The npm package is an OpenCode plugin package. Its `/sc-*` commands, agents, skills, plugins, and runtime instruction files are part of the package; the manual sync flow simply copies those bundled assets into the local repository when you want them as project files. +The npm package is a bi-target OpenCode plugin package. Runtime hooks stay in the npm plugin server target, while commands, agents, skills, and instructions are materialized through the bootstrap engine into the chosen scope. ## Publishing diff --git a/USAGE.md b/USAGE.md index f420ede..61a1109 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,6 +1,6 @@ # Usage Guide -Super OpenCode is primarily consumed as an OpenCode plugin package. +Super OpenCode is primarily consumed as an OpenCode plugin package with an explicit bootstrap step. Recommended install flow: @@ -10,11 +10,20 @@ Recommended install flow: 4. Press `Shift+I`. 5. Enter `super-opencode-framework`. -If you want the bundled `/sc-*` assets copied into the current repository as local files, run the manual sync flow described in `INSTALL.md`. +On first load from Plugin Manager, the TUI bootstrap asks for `project` or `global` scope and then syncs the framework into that location. + +The equivalent CLI flows are: + +```bash +npx super-opencode-framework install --scope project +npx super-opencode-framework status --scope project +npx super-opencode-framework update --scope project +npx super-opencode-framework uninstall --scope project +``` ## Start OpenCode -After installing the plugin, start or restart OpenCode so the package is loaded. +After the bootstrap changes files or config, restart OpenCode so commands, agents, skills, instructions, and MCP changes are rediscovered. ## First Commands To Know diff --git a/bun.lock b/bun.lock index c60f9b3..e60506d 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,10 @@ "name": "super-opencode", "dependencies": { "@opencode-ai/plugin": "^1.4.3", + "@opentui/core": "0.1.97", + "@opentui/solid": "0.1.97", + "jsonc-parser": "^3.3.1", + "solid-js": "^1.9.9", }, "devDependencies": { "@types/node": "^24.3.0", @@ -14,28 +18,424 @@ }, }, "packages": { + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.3", "", { "dependencies": { "@opencode-ai/sdk": "1.4.3", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.3", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A=="], + "@opentui/core": ["@opentui/core@0.1.97", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.97", "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-linux-arm64": "0.1.97", "@opentui/core-linux-x64": "0.1.97", "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2ENH0Dc4NUAeHeeQCQhF1lg68RuyntOUP68UvortvDqTz/hqLG0tIwF+DboCKtWi8Nmao4SAQEJ7lfmyQNEDOQ=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t7oMGEfMPQsqLEx7/rPqv/UGJ+vqhe4RWHRRQRYcuHuLKssZ2S8P9mSS7MBPtDqGcxg4PosCrh5nHYeZ94EXUw=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZuPWAawlVat6ZHb8vaH/CVUeGwI0pI4vd+6zz1ZocZn95ZWJztfyhzNZOJrq1WjHmUROieJ7cOuYUZfvYNuLrg=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXxhz654vXgEu2wrFFFFnrSWbyk6/r6nXNnDTcMRWofdMZQLx87NhbcsErNmz9KmFdzoPiQSmlpYubLflKKzqQ=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-v3z0QWpRS3p8blE/A7pTu15hcFMtSndeiYhRxhrjp6zAhQ+UlruQs9DAG1ifSuVO1RJJ0pUKklFivdbu0pMzuw=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-o/m9mD1dvOCwkxOUUyoEILl+d6tzh/85foJc4uqjXYi71NNcwg8u+Eq3/gdHuSKnlT1pusCPKoS1IDuBvZE24A=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-Rwp7JOwrYm4wtzPHY2vv+2l91LXmKSI7CtbmWN1sSUGhBPtPGSvfwux3W5xaAZQa2KPEXicPjaKJZc+pob3YRg=="], + + "@opentui/solid": ["@opentui/solid@0.1.97", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.97", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-ma/uihG38F+6oLJVD8yR7z82FWmR8QhfesNV5SBXbN74riMCRyy6kyQ6SI4xs4ykt9BbZOjrKLq+Xt/0Pd0SJQ=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.6", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ=="], + + "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + + "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + + "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + + "planck": ["planck@1.5.0", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-dlvqJE+FscZgrGUXJ5ybd0o5bvZ5XXyZNbm08xGsXp9WjXeAyWSFT6n9s/1PQcUBo4546fDXA5RMA4wbDyZw6g=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], + + "seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], + + "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], + + "stage-js": ["stage-js@1.0.2", "", {}, "sha512-EWTRBYlg7Qv9wGUao99/PfRe3KaiQqWmgSvTOXvaWnu1Jk/q/vV8yJVu6bi/3EqDZeMVnCPAjheba6OFc5k1GQ=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], } } diff --git a/framework.manifest.json b/framework.manifest.json new file mode 100644 index 0000000..80ced49 --- /dev/null +++ b/framework.manifest.json @@ -0,0 +1,188 @@ +{ + "manifestVersion": 1, + "packageName": "super-opencode-framework", + "pluginId": "super-opencode-framework", + "stateFile": "super-opencode/install-state.json", + "assetGroups": [ + { + "id": "commands", + "source": ".opencode/commands", + "targets": { + "global": "commands", + "project": ".opencode/commands" + } + }, + { + "id": "agents", + "source": ".opencode/agents", + "targets": { + "global": "agents", + "project": ".opencode/agents" + } + }, + { + "id": "skills", + "source": ".opencode/skills", + "targets": { + "global": "skills", + "project": ".opencode/skills" + } + }, + { + "id": "instructions", + "source": ".opencode/instructions", + "targets": { + "global": "instructions", + "project": ".opencode/instructions" + } + } + ], + "config": { + "opencode": { + "schema": "https://opencode.ai/config.json", + "instructions": { + "global": [ + "instructions/opencode-core.md" + ], + "project": [ + ".opencode/instructions/opencode-core.md" + ] + }, + "plugin": "super-opencode-framework" + }, + "tui": { + "schema": "https://opencode.ai/tui.json", + "plugin": "super-opencode-framework" + } + }, + "mcp": { + "serena": { + "config": { + "type": "local", + "command": [ + "uvx", + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide", + "--project-from-cwd" + ] + }, + "requirements": { + "binaries": [ + "uvx" + ] + }, + "reason": "Serena powers persistence and continuity workflows." + }, + "context7": { + "config": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + } + }, + "requirements": { + "env": [ + "CONTEXT7_API_KEY" + ] + }, + "reason": "Context7 adds official documentation lookup when an API key is available." + }, + "sequential": { + "config": { + "type": "local", + "command": [ + "npx", + "-y", + "@modelcontextprotocol/server-sequential-thinking" + ] + }, + "requirements": { + "binaries": [ + "npx" + ] + }, + "reason": "Sequential thinking supports structured reasoning workflows." + }, + "playwright": { + "config": { + "type": "local", + "command": [ + "npx", + "-y", + "@playwright/mcp@latest" + ] + }, + "requirements": { + "binaries": [ + "npx" + ] + }, + "reason": "Playwright MCP is enabled when npx is available for browser validation tasks." + }, + "chrome-devtools": { + "config": { + "type": "local", + "command": [ + "npx", + "-y", + "chrome-devtools-mcp@latest" + ] + }, + "requirements": { + "binaries": [ + "npx" + ] + }, + "reason": "Chrome DevTools MCP is enabled when the package runner is available." + }, + "tavily": { + "config": { + "type": "local", + "command": [ + "npx", + "-y", + "tavily-mcp@latest" + ], + "environment": { + "TAVILY_API_KEY": "{env:TAVILY_API_KEY}" + } + }, + "requirements": { + "binaries": [ + "npx" + ], + "env": [ + "TAVILY_API_KEY" + ] + }, + "reason": "Tavily MCP needs both the npm runner and an API key." + }, + "morph": { + "config": { + "type": "local", + "command": [ + "npx", + "-y", + "@morph-llm/morph-fast-apply" + ], + "environment": { + "MORPH_API_KEY": "{env:MORPH_API_KEY}" + } + }, + "requirements": { + "binaries": [ + "npx" + ], + "env": [ + "MORPH_API_KEY" + ] + }, + "reason": "Morph MCP needs both the npm runner and an API key." + } + } +} diff --git a/package.json b/package.json index ef6fa6e..3e5a715 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "super-opencode-framework", "version": "1.0.1", - "description": "OpenCode plugin package that ports key SuperClaude workflows and includes bundled /sc-* assets plus local sync support.", + "description": "OpenCode framework plugin package with explicit global/project bootstrap for commands, agents, skills, instructions, MCP config, and diagnostics.", "author": "SuperClaude Community", "license": "MIT", "homepage": "https://github.com/papastanb/super-opencode#readme", @@ -22,24 +22,32 @@ ], "type": "module", "packageManager": "bun@1.3.9", - "main": "./dist/.opencode/plugins/super-opencode.js", - "types": "./dist/.opencode/plugins/super-opencode.d.ts", + "main": "./dist/src/server.js", + "types": "./dist/src/server.d.ts", "bin": { "super-opencode-framework": "./scripts/install-project.mjs" }, "exports": { ".": { - "types": "./dist/.opencode/plugins/super-opencode.d.ts", - "import": "./dist/.opencode/plugins/super-opencode.js" + "types": "./dist/src/server.d.ts", + "import": "./dist/src/server.js" + }, + "./server": { + "types": "./dist/src/server.d.ts", + "import": "./dist/src/server.js" + }, + "./tui": { + "types": "./dist/src/tui.d.ts", + "import": "./dist/src/tui.js" }, "./package.json": "./package.json" }, "files": [ "dist/**/*", + "framework.manifest.json", ".opencode/commands/**/*.md", ".opencode/agents/**/*.md", ".opencode/skills/**/SKILL.md", - ".opencode/plugins/**/*.ts", ".opencode/examples/*.json", ".opencode/instructions/*.md", "scripts/install-project.mjs", @@ -54,16 +62,31 @@ "validate:structure": "node scripts/validate-structure.mjs", "validate:package": "node scripts/validate-package.mjs", "release:check": "bun run build && bun run validate:package", - "install:project": "node scripts/install-project.mjs install", + "install:project": "bun run src/cli.ts install --scope project", + "install:global": "bun run src/cli.ts install --scope global", + "status:project": "bun run src/cli.ts status --scope project", + "status:global": "bun run src/cli.ts status --scope global", + "update:project": "bun run src/cli.ts update --scope project", + "update:global": "bun run src/cli.ts update --scope global", + "uninstall:project": "bun run src/cli.ts uninstall --scope project", + "uninstall:global": "bun run src/cli.ts uninstall --scope global", "test": "bun test", "test:coverage": "bun test --coverage", "lint": "tsc --noEmit" }, "dependencies": { - "@opencode-ai/plugin": "^1.4.3" + "@opencode-ai/plugin": "^1.4.3", + "@opentui/core": "0.1.97", + "@opentui/solid": "0.1.97", + "jsonc-parser": "^3.3.1", + "solid-js": "^1.9.9" }, "devDependencies": { "@types/node": "^24.3.0", "typescript": "^5.9.2" + }, + "engines": { + "node": ">=24", + "opencode": ">=1.4.3" } } diff --git a/scripts/install-project.mjs b/scripts/install-project.mjs index c27fe1f..675cdd4 100644 --- a/scripts/install-project.mjs +++ b/scripts/install-project.mjs @@ -1,101 +1,18 @@ #!/usr/bin/env node -import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const installEntries = [ - ['.opencode/commands', '.opencode/commands'], - ['.opencode/agents', '.opencode/agents'], - ['.opencode/skills', '.opencode/skills'], - ['.opencode/plugins', '.opencode/plugins'], - ['.opencode/instructions', '.opencode/instructions'], -] - -function parseArgs(argv) { - const args = argv.slice(2) - const command = args[0] && !args[0].startsWith('--') ? args[0] : 'install' - const force = args.includes('--force') - const targetFlagIndex = args.findIndex((arg) => arg === '--target') - const target = targetFlagIndex >= 0 ? args[targetFlagIndex + 1] : process.cwd() - - return { command, force, target: path.resolve(target ?? process.cwd()) } -} - -async function copyRuntimeAssets(targetRoot, force) { - for (const [source, destination] of installEntries) { - const sourcePath = path.join(packageRoot, source) - const destinationPath = path.join(targetRoot, destination) - - await mkdir(path.dirname(destinationPath), { recursive: true }) - await cp(sourcePath, destinationPath, { - recursive: true, - force, - filter: (entry) => !entry.endsWith('.gitkeep'), - }) - } -} - -async function ensureProjectInstructions(targetRoot) { - const opencodeConfigPath = path.join(targetRoot, 'opencode.json') - if (!existsSync(opencodeConfigPath)) { - return false - } - - const config = JSON.parse(await readFile(opencodeConfigPath, 'utf8')) - const instructions = Array.isArray(config.instructions) ? config.instructions : [] - const requiredInstruction = '.opencode/instructions/opencode-core.md' - - if (!instructions.includes(requiredInstruction)) { - instructions.push(requiredInstruction) - config.instructions = instructions - await writeFile(opencodeConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') - } - - return true +import path from "node:path" +import process from "node:process" +import { fileURLToPath, pathToFileURL } from "node:url" + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const cliEntry = path.join(scriptDir, "..", "dist", "src", "cli.js") + +try { + const cliModule = await import(pathToFileURL(cliEntry).href) + const exitCode = await cliModule.runCli(process.argv.slice(2)) + process.exitCode = exitCode +} catch (error) { + const message = error instanceof Error ? error.stack ?? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 } - -async function installProject(targetRoot, force) { - console.log('Syncing bundled Super OpenCode assets...') - console.log(`Target: ${targetRoot}`) - - await copyRuntimeAssets(targetRoot, force) - const updatedConfig = await ensureProjectInstructions(targetRoot) - - console.log('') - console.log('Installed:') - console.log('- .opencode/commands') - console.log('- .opencode/agents') - console.log('- .opencode/skills') - console.log('- .opencode/plugins') - console.log('- .opencode/instructions/opencode-core.md') - console.log('') - - if (updatedConfig) { - console.log('Updated opencode.json instructions with .opencode/instructions/opencode-core.md') - } else { - console.log('No opencode.json found. Add .opencode/instructions/opencode-core.md to your project instructions manually.') - } - - console.log('') - console.log('Next steps:') - console.log('- Ensure Node.js 24 and Bun are installed') - console.log('- Review opencode.json and enable the MCPs you want to use') - console.log('- Start OpenCode in the target project') -} - -const { command, force, target } = parseArgs(process.argv) - -if (command !== 'install') { - console.error(`Unknown command: ${command}`) - console.error('Usage: super-opencode-framework install [--target ] [--force]') - process.exit(1) -} - -installProject(target, force).catch((error) => { - console.error(error) - process.exit(1) -}) diff --git a/scripts/validate-package.mjs b/scripts/validate-package.mjs index 3c45d18..6a3f938 100644 --- a/scripts/validate-package.mjs +++ b/scripts/validate-package.mjs @@ -7,17 +7,35 @@ import { execSync } from 'node:child_process' import { pathToFileURL } from 'node:url' const root = process.cwd() -const distEntry = path.join(root, 'dist', '.opencode', 'plugins', 'super-opencode.js') +const distServerEntry = path.join(root, 'dist', 'src', 'server.js') +const distTuiEntry = path.join(root, 'dist', 'src', 'tui.js') const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' -if (!existsSync(distEntry)) { - console.error(`Missing built plugin entry: ${distEntry}`) +if (!existsSync(distServerEntry)) { + console.error(`Missing built server entry: ${distServerEntry}`) process.exit(1) } -const pluginModule = await import(pathToFileURL(distEntry).href) -if (typeof pluginModule.SuperOpenCodePlugin !== 'function') { - console.error('Built plugin entry does not export SuperOpenCodePlugin') +if (!existsSync(distTuiEntry)) { + console.error(`Missing built TUI entry: ${distTuiEntry}`) + process.exit(1) +} + +const serverModule = await import(pathToFileURL(distServerEntry).href) +const tuiModule = await import(pathToFileURL(distTuiEntry).href) + +if (typeof serverModule.SuperOpenCodePlugin !== 'function') { + console.error('Built server entry does not export SuperOpenCodePlugin') + process.exit(1) +} + +if (typeof serverModule.default?.server !== 'function') { + console.error('Built server entry does not expose a default server plugin module') + process.exit(1) +} + +if (typeof tuiModule.default?.tui !== 'function') { + console.error('Built TUI entry does not expose a default TUI plugin module') process.exit(1) } @@ -48,8 +66,13 @@ for (const forbiddenPath of forbiddenPaths) { } } -if (!packedPaths.includes('dist/.opencode/plugins/super-opencode.js')) { - console.error('Built plugin entry is missing from npm pack output') +if (!packedPaths.includes('dist/src/server.js')) { + console.error('Built server entry is missing from npm pack output') + process.exit(1) +} + +if (!packedPaths.includes('dist/src/tui.js')) { + console.error('Built TUI entry is missing from npm pack output') process.exit(1) } diff --git a/scripts/validate-structure.mjs b/scripts/validate-structure.mjs index b904ff8..56a0c20 100644 --- a/scripts/validate-structure.mjs +++ b/scripts/validate-structure.mjs @@ -6,6 +6,9 @@ const requiredPaths = [ "opencode.json", "package.json", "tsconfig.json", + "framework.manifest.json", + "src/server.ts", + "src/tui.ts", ".opencode/plugins/super-opencode.ts", ".opencode/commands", ".opencode/agents", diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..a964799 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,120 @@ +import process from "node:process" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { detectFrameworkScopes, installFramework, statusFramework, uninstallFramework, updateFramework } from "./framework/engine.js" +import type { FrameworkOptions, FrameworkReport, Scope } from "./framework/types.js" + +function parseScope(value: string | undefined): Scope { + if (value === "global" || value === "project") { + return value + } + + throw new Error("Missing required --scope global|project flag") +} + +type CliOptions = Omit & { scope?: Scope } +const supportedCommands = new Set(["install", "status", "update", "uninstall", "scopes"]) +const usage = + "Usage: super-opencode-framework --scope [--force]\n super-opencode-framework scopes" + +function parseArgs(argv: string[]): { command: string; options: CliOptions } { + const [command = "install", ...rest] = argv + + if (!supportedCommands.has(command)) { + throw new Error(usage) + } + + const force = rest.includes("--force") + const scopeIndex = rest.findIndex((entry) => entry === "--scope") + const scope = command === "scopes" ? undefined : parseScope(scopeIndex >= 0 ? rest[scopeIndex + 1] : undefined) + + return { + command, + options: { + scope, + force, + }, + } +} + +function renderReport(report: FrameworkReport): string { + const nonMcpItems = report.items.filter((item) => item.kind !== "mcp") + const lines = [ + `Action: ${report.action}`, + `Scope: ${report.scope}`, + `Config dir: ${report.configDir}`, + `Project root: ${report.projectRoot}`, + `Package version: ${report.packageVersion}`, + `Restart required: ${report.restartRequired ? "yes" : "no"}`, + "", + "Items:", + ] + + for (const item of nonMcpItems) { + lines.push(`- [${item.status}] ${item.name}${item.detail ? `: ${item.detail}` : ""}`) + } + + lines.push("", "MCP:") + for (const diagnostic of report.mcp) { + lines.push(`- [${diagnostic.status}] ${diagnostic.name}${diagnostic.detail ? `: ${diagnostic.detail}` : ""}`) + } + + return `${lines.join("\n")}\n` +} + +/** Runs the framework CLI for install, status, update, uninstall, and scope discovery. */ +export async function runCli(argv = process.argv.slice(2)): Promise { + const { command, options } = parseArgs(argv) + + const requireScope = (): FrameworkOptions => { + if (!options.scope) { + throw new Error("Missing required --scope global|project flag") + } + + return options as FrameworkOptions + } + + let report: FrameworkReport + switch (command) { + case "install": + report = await installFramework(requireScope()) + break + case "status": + report = await statusFramework(requireScope()) + break + case "update": + report = await updateFramework(requireScope()) + break + case "uninstall": + report = await uninstallFramework(requireScope()) + break + case "scopes": { + const scopes = await detectFrameworkScopes(options) + process.stdout.write(`${JSON.stringify(scopes, null, 2)}\n`) + return 0 + } + default: + throw new Error(usage) + } + + process.stdout.write(renderReport(report)) + return report.items.some((item) => item.status === "conflict/manual action required") ? 2 : 0 +} + +const isDirectCliExecution = process.argv[1] + ? path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1]) + : false + +if (isDirectCliExecution) { + runCli().then( + (code) => { + process.exitCode = code + }, + (error: unknown) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 + }, + ) +} diff --git a/src/framework/config.ts b/src/framework/config.ts new file mode 100644 index 0000000..3b3cc5f --- /dev/null +++ b/src/framework/config.ts @@ -0,0 +1,518 @@ +import { createHash } from "node:crypto" +import { readFile, rm } from "node:fs/promises" + +import { applyEdits, modify } from "jsonc-parser" + +import { writeTextAtomically } from "./file-write.js" +import { hasPluginSpec, isObject, parseJsoncObject, type JsonObject } from "./jsonc.js" +import type { FrameworkInstallState, FrameworkManifest, McpDiagnostic, Scope } from "./types.js" + +type ConfigPatchResult = { + changed: boolean + created: boolean + addedPlugin: boolean + addedInstructions: string[] + addedMcpKeys: string[] + addedMcpHashes: Record +} + +type TuiPatchResult = { + changed: boolean + created: boolean + addedPlugin: boolean +} + +/** Produces a deterministic JSON-safe value shape for stable hashing and equality checks. */ +function stableJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => stableJsonValue(entry)) + } + + if (!isObject(value)) { + return value + } + + return Object.keys(value) + .sort() + .reduce((result, key) => { + result[key] = stableJsonValue(value[key]) + return result + }, {}) +} + +/** Hashes a JSON value after stable key ordering so persisted ownership checks are reproducible. */ +function hashJsonValue(value: unknown): string { + return createHash("sha256").update(JSON.stringify(stableJsonValue(value))).digest("hex") +} + + +function mergeObjects(base: JsonObject, override: JsonObject): JsonObject { + const result: JsonObject = { ...base } + + for (const [key, value] of Object.entries(override)) { + const existing = result[key] + if (isObject(existing) && isObject(value)) { + result[key] = mergeObjects(existing, value) + continue + } + + result[key] = value + } + + return result +} + +function jsonValuesEqual(left: unknown, right: unknown): boolean { + if (left === right) { + return true + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false + } + + return left.every((entry, index) => jsonValuesEqual(entry, right[index])) + } + + if (isObject(left) || isObject(right)) { + if (!isObject(left) || !isObject(right)) { + return false + } + + const leftKeys = Object.keys(left).sort() + const rightKeys = Object.keys(right).sort() + if (!jsonValuesEqual(leftKeys, rightKeys)) { + return false + } + + return leftKeys.every((key) => jsonValuesEqual(left[key], right[key])) + } + + return false +} + +async function readJsoncObject(filePath: string): Promise<{ created: boolean; value: JsonObject }> { + try { + const raw = await readFile(filePath, "utf8") + return { created: false, value: parseJsoncObject(raw, filePath) } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { created: true, value: {} } + } + + throw error + } +} + +/** Validates that an existing JSONC config file parses cleanly as an object. */ +export async function validateJsoncConfigFile(filePath: string): Promise { + await readJsoncObject(filePath) +} + +const jsonFormattingOptions = { + insertSpaces: true, + tabSize: 2, + eol: "\n", +} + +function applyJsoncObjectEdits(sourceText: string, currentValue: JsonObject, nextValue: JsonObject, pathSegments: string[] = []): string { + let updatedText = sourceText + const keys = new Set([...Object.keys(currentValue), ...Object.keys(nextValue)]) + + for (const key of keys) { + const currentChild = currentValue[key] + const nextHasKey = Object.prototype.hasOwnProperty.call(nextValue, key) + const nextChild = nextValue[key] + + if (isObject(currentChild) && nextHasKey && isObject(nextChild)) { + updatedText = applyJsoncObjectEdits(updatedText, currentChild, nextChild, [...pathSegments, key]) + continue + } + + const edits = modify(updatedText, [...pathSegments, key], nextHasKey ? nextChild : undefined, { + formattingOptions: jsonFormattingOptions, + }) + updatedText = applyEdits(updatedText, edits) + } + + return updatedText +} + +async function writeJson(filePath: string, value: JsonObject): Promise { + 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 writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`) + return + } + + // Re-read the source text here so JSONC edits preserve comments and user formatting from the current on-disk file. + const parsed = parseJsoncObject(originalText, filePath) + const updatedText = applyJsoncObjectEdits(originalText, parsed, value) + await writeTextAtomically(filePath, updatedText.endsWith("\n") ? updatedText : `${updatedText}\n`) +} + +function normalizePluginArray(input: unknown): Array]> { + if (!Array.isArray(input)) { + return [] + } + + return input.filter((entry): entry is string | [string, Record] => { + if (typeof entry === "string") { + return true + } + + return Array.isArray(entry) && typeof entry[0] === "string" + }) +} + +function ensureStringArray(input: unknown): string[] { + return Array.isArray(input) ? input.filter((entry): entry is string => typeof entry === "string") : [] +} + +function readArrayConfigValue(config: JsonObject, key: string, filePath: string): unknown[] { + const value = config[key] + if (value === undefined) { + return [] + } + + if (!Array.isArray(value)) { + throw new Error(`Invalid OpenCode config in ${filePath}: "${key}" must be an array.`) + } + + return [...value] +} + +// Dead code: ensureArray was replaced by readArrayConfigValue which validates array shapes before use +// function ensureArray(input: unknown): unknown[] { +// return Array.isArray(input) ? [...input] : [] +// } + +/** + * Merges framework requirements into opencode.json while preserving JSONC comments when possible. + * Existing user MCP entries keep their explicit fields unless the entry is still framework-managed + * and matches its recorded ownership hash, in which case framework defaults can refresh on update. + * For pre-existing or diverged entries, user-provided MCP fields keep priority so local overrides stay intact. + */ +export async function patchOpencodeConfig(options: { + filePath: string + manifest: FrameworkManifest + scope: Scope + diagnostics: McpDiagnostic[] + state?: FrameworkInstallState +}): Promise { + const { created, value } = await readJsoncObject(options.filePath) + const config: JsonObject = { ...value } + let changed = created + const addedMcpHashes: Record = { ...(options.state?.ownership.addedMcpHashes ?? {}) } + + if (config.$schema !== options.manifest.config.opencode.schema) { + config.$schema = options.manifest.config.opencode.schema + changed = true + } + + const existingPluginEntries = readArrayConfigValue(config, "plugin", options.filePath) + const plugins = normalizePluginArray(existingPluginEntries) + let addedPlugin = false + if (!hasPluginSpec(plugins, options.manifest.config.opencode.plugin)) { + config.plugin = [...existingPluginEntries, options.manifest.config.opencode.plugin] + changed = true + addedPlugin = true + } + + const instructions = readArrayConfigValue(config, "instructions", options.filePath) + const instructionStrings = ensureStringArray(instructions) + const addedInstructions: string[] = [] + for (const instruction of options.manifest.config.opencode.instructions[options.scope]) { + if (!instructionStrings.includes(instruction)) { + instructions.push(instruction) + instructionStrings.push(instruction) + addedInstructions.push(instruction) + changed = true + } + } + if (instructions.length > 0) { + config.instructions = instructions + } + + if (config.mcp !== undefined && !isObject(config.mcp)) { + throw new Error(`Invalid OpenCode config in ${options.filePath}: "mcp" must be an object.`) + } + + const existingMcp = isObject(config.mcp) ? config.mcp : {} + const mergedMcp: JsonObject = { ...existingMcp } + const addedMcpKeys: string[] = [] + for (const diagnostic of options.diagnostics) { + const currentEntry = mergedMcp[diagnostic.name] + if (currentEntry !== undefined && !isObject(currentEntry)) { + throw new Error(`Invalid OpenCode config in ${options.filePath}: "mcp.${diagnostic.name}" must be an object.`) + } + + const currentValue = isObject(currentEntry) ? (currentEntry as JsonObject) : undefined + 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) + } + } + config.mcp = mergedMcp + + if (changed) { + await writeJson(options.filePath, config) + } + + return { + changed, + created, + addedPlugin, + addedInstructions, + addedMcpKeys, + addedMcpHashes, + } +} + +/** Ensures the framework TUI plugin is present in the scope-local tui.json file. */ +export async function patchTuiConfig(options: { + filePath: string + manifest: FrameworkManifest +}): Promise { + const { created, value } = await readJsoncObject(options.filePath) + const config: JsonObject = { ...value } + let changed = created + + if (config.$schema !== options.manifest.config.tui.schema) { + config.$schema = options.manifest.config.tui.schema + changed = true + } + + const existingPluginEntries = readArrayConfigValue(config, "plugin", options.filePath) + const plugins = normalizePluginArray(existingPluginEntries) + let addedPlugin = false + if (!hasPluginSpec(plugins, options.manifest.config.tui.plugin)) { + config.plugin = [...existingPluginEntries, options.manifest.config.tui.plugin] + changed = true + addedPlugin = true + } + + if (changed) { + await writeJson(options.filePath, config) + } + + return { + changed, + created, + addedPlugin, + } +} + +function removePluginSpecFromEntries(entries: unknown[], spec: string): unknown[] { + return entries.filter((entry) => { + if (typeof entry === "string") { + return entry !== spec && !entry.startsWith(`${spec}@`) + } + + if (Array.isArray(entry) && typeof entry[0] === "string") { + return entry[0] !== spec && !entry[0].startsWith(`${spec}@`) + } + + return true + }) +} + +function removeManagedInstructionEntries(entries: unknown[], managed: string[]): unknown[] { + return entries.filter((entry) => typeof entry !== "string" || !managed.includes(entry)) +} + +/** Treats schema-only configs as disposable when the framework created the file. */ +function hasMeaningfulConfigContent(config: JsonObject): boolean { + return Object.keys(config).some((key) => key !== "$schema") +} + +/** 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 + conflicts: string[] + remainingAddedMcpKeys: string[] + remainingAddedMcpHashes: Record +}> { + const { created, value } = await readJsoncObject(options.filePath) + if (created) { + return { + changed: false, + removedFile: false, + conflicts: [], + remainingAddedMcpKeys: [], + remainingAddedMcpHashes: {}, + } + } + + const config: JsonObject = { ...value } + let changed = false + const conflicts: string[] = [] + const remainingAddedMcpKeys: string[] = [] + const remainingAddedMcpHashes: Record = {} + + if (options.state.ownership.addedOpencodePlugin) { + const plugins = removePluginSpecFromEntries(readArrayConfigValue(config, "plugin", options.filePath), 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 = removeManagedInstructionEntries( + readArrayConfigValue(config, "instructions", options.filePath), + 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) { + if (!Object.prototype.hasOwnProperty.call(nextMcp, key)) { + continue + } + + const expectedHash = options.state.ownership.addedMcpHashes[key] + if (expectedHash === undefined) { + conflicts.push(key) + remainingAddedMcpKeys.push(key) + continue + } + + if (hashJsonValue(nextMcp[key]) !== expectedHash) { + conflicts.push(key) + remainingAddedMcpKeys.push(key) + remainingAddedMcpHashes[key] = expectedHash + continue + } + + delete nextMcp[key] + changed = true + } + + if (Object.keys(nextMcp).length > 0) { + config.mcp = nextMcp + } else if (Object.prototype.hasOwnProperty.call(config, "mcp")) { + delete config.mcp + changed = true + } + } + + if (options.state.ownership.createdOpencodeConfig && conflicts.length === 0 && !hasMeaningfulConfigContent(config)) { + await rm(options.filePath, { force: true }) + return { + changed: true, + removedFile: true, + conflicts, + remainingAddedMcpKeys, + remainingAddedMcpHashes, + } + } + + if (!changed) { + return { + changed: false, + removedFile: false, + conflicts, + remainingAddedMcpKeys, + remainingAddedMcpHashes, + } + } + + await writeJson(options.filePath, config) + return { + changed: true, + removedFile: false, + conflicts, + remainingAddedMcpKeys, + remainingAddedMcpHashes, + } +} + +/** 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 = removePluginSpecFromEntries(readArrayConfigValue(config, "plugin", options.filePath), options.manifest.config.tui.plugin) + if (plugins.length > 0) { + config.plugin = plugins + } else { + delete config.plugin + } + changed = true + } + + 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)) { + await rm(options.filePath, { force: true }) + return { changed: true, removedFile: true } + } + + await writeJson(options.filePath, config) + return { changed: true, removedFile: false } +} diff --git a/src/framework/engine.ts b/src/framework/engine.ts new file mode 100644 index 0000000..4dc8a8a --- /dev/null +++ b/src/framework/engine.ts @@ -0,0 +1,844 @@ +import { createHash } from "node:crypto" +import { mkdir, readFile, readdir, rm, rmdir, unlink, writeFile } from "node:fs/promises" +import path from "node:path" + +import { + patchOpencodeConfig, + patchTuiConfig, + removeFrameworkConfig, + removeFrameworkTuiConfig, + validateJsoncConfigFile, +} from "./config.js" +import { hasPluginSpec, isObject, parseJsoncObject, type JsonObject } from "./jsonc.js" +import { loadFrameworkManifest } from "./manifest.js" +import { resolveScopePaths } from "./paths.js" +import { diagnoseMcpPolicies } from "./prerequisites.js" +import { createEmptyState, readInstallState, removeInstallState, writeInstallState } from "./state.js" +import type { + AssetGroup, + FrameworkAction, + FrameworkOptions, + FrameworkReport, + ManagedFileState, + ReportItem, + ScopeDetection, +} from "./types.js" + +type PackageMetadata = { + version: string +} + +type ConfigSnapshot = { + exists: boolean + value?: JsonObject + error?: string +} + +async function readJsoncSnapshot(filePath: string): Promise { + const raw = await readTextIfExists(filePath) + if (raw === undefined) { + return { exists: false } + } + + try { + return { + exists: true, + value: parseJsoncObject(raw, filePath), + } + } catch (error) { + return { + exists: true, + error: error instanceof Error ? error.message : String(error), + } + } +} + +function createConfigStatusItem(name: string, status: ReportItem["status"], detail: string): ReportItem { + return { + kind: "config", + name, + status, + detail, + } +} + +async function inspectOpencodeStatus(options: { + filePath: string + statePath: string + manifest: Awaited> + scope: FrameworkOptions["scope"] + diagnostics: Awaited> +}): Promise<{ item: ReportItem; ok: boolean }> { + const snapshot = await readJsoncSnapshot(options.filePath) + const name = path.basename(options.filePath) + + if (!snapshot.exists) { + return { + ok: false, + item: createConfigStatusItem(name, "config-drift", `Missing config file. Install/update will recreate it. Install state: ${options.statePath}`), + } + } + + if (snapshot.error) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `${snapshot.error} Install state: ${options.statePath}`), + } + } + + const config = snapshot.value + if (!config) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `Config could not be parsed. Install state: ${options.statePath}`), + } + } + + if (config.plugin !== undefined && !Array.isArray(config.plugin)) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `"plugin" must be an array. Install state: ${options.statePath}`), + } + } + + if (config.instructions !== undefined && !Array.isArray(config.instructions)) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `"instructions" must be an array. Install state: ${options.statePath}`), + } + } + + if (config.mcp !== undefined && !isObject(config.mcp)) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `"mcp" must be an object. Install state: ${options.statePath}`), + } + } + + const driftReasons: string[] = [] + if (config.$schema !== options.manifest.config.opencode.schema) { + driftReasons.push(`$schema differs from ${options.manifest.config.opencode.schema}`) + } + + const pluginEntries = Array.isArray(config.plugin) ? config.plugin : [] + if (!hasPluginSpec(pluginEntries, options.manifest.config.opencode.plugin)) { + driftReasons.push(`missing plugin ${options.manifest.config.opencode.plugin}`) + } + + const instructions = Array.isArray(config.instructions) + ? config.instructions.filter((entry): entry is string => typeof entry === "string") + : [] + const missingInstructions = options.manifest.config.opencode.instructions[options.scope].filter((instruction) => !instructions.includes(instruction)) + if (missingInstructions.length > 0) { + driftReasons.push(`missing instructions: ${missingInstructions.join(", ")}`) + } + + const mcp = isObject(config.mcp) ? config.mcp : undefined + const missingMcpEntries: string[] = [] + const mismatchedMcpEntries: string[] = [] + for (const diagnostic of options.diagnostics) { + const currentEntry = mcp?.[diagnostic.name] + if (currentEntry === undefined) { + missingMcpEntries.push(diagnostic.name) + continue + } + + if (!isObject(currentEntry)) { + return { + ok: false, + item: createConfigStatusItem( + name, + "invalid-config", + `"mcp.${diagnostic.name}" must be an object. Install state: ${options.statePath}`, + ), + } + } + + if (currentEntry.enabled !== diagnostic.enabled) { + mismatchedMcpEntries.push(`${diagnostic.name} enabled=${String(currentEntry.enabled)}`) + } + } + + if (missingMcpEntries.length > 0) { + driftReasons.push(`missing MCP entries: ${missingMcpEntries.join(", ")}`) + } + + if (mismatchedMcpEntries.length > 0) { + driftReasons.push(`stale MCP enablement: ${mismatchedMcpEntries.join(", ")}`) + } + + if (driftReasons.length > 0) { + return { + ok: false, + item: createConfigStatusItem(name, "config-drift", `${driftReasons.join("; ")}. Install state: ${options.statePath}`), + } + } + + return { + ok: true, + item: createConfigStatusItem(name, "already up to date", `Validated against manifest requirements. Install state: ${options.statePath}`), + } +} + +async function inspectTuiStatus(options: { + filePath: string + statePath: string + manifest: Awaited> +}): Promise<{ item: ReportItem; ok: boolean }> { + const snapshot = await readJsoncSnapshot(options.filePath) + const name = path.basename(options.filePath) + + if (!snapshot.exists) { + return { + ok: false, + item: createConfigStatusItem(name, "config-drift", `Missing config file. Install/update will recreate it. Install state: ${options.statePath}`), + } + } + + if (snapshot.error) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `${snapshot.error} Install state: ${options.statePath}`), + } + } + + const config = snapshot.value + if (!config) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `Config could not be parsed. Install state: ${options.statePath}`), + } + } + + if (config.plugin !== undefined && !Array.isArray(config.plugin)) { + return { + ok: false, + item: createConfigStatusItem(name, "invalid-config", `"plugin" must be an array. Install state: ${options.statePath}`), + } + } + + const driftReasons: string[] = [] + if (config.$schema !== options.manifest.config.tui.schema) { + driftReasons.push(`$schema differs from ${options.manifest.config.tui.schema}`) + } + + const pluginEntries = Array.isArray(config.plugin) ? config.plugin : [] + if (!hasPluginSpec(pluginEntries, options.manifest.config.tui.plugin)) { + driftReasons.push(`missing plugin ${options.manifest.config.tui.plugin}`) + } + + if (driftReasons.length > 0) { + return { + ok: false, + item: createConfigStatusItem(name, "config-drift", `${driftReasons.join("; ")}. Install state: ${options.statePath}`), + } + } + + return { + ok: true, + item: createConfigStatusItem(name, "already up to date", `Validated against manifest requirements. Install state: ${options.statePath}`), + } +} + +function hashContent(content: string): string { + return createHash("sha256").update(content).digest("hex") +} + +async function readPackageMetadata(packageRoot: string): Promise { + const packageJsonPath = path.join(packageRoot, "package.json") + let packageJson: unknown + + try { + packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) + } catch (error) { + throw new Error(`Failed to read package metadata from ${packageRoot}: ${(error as Error).message}`) + } + + if ( + !packageJson || + typeof packageJson !== "object" || + typeof (packageJson as { version?: unknown }).version !== "string" || + (packageJson as { version: string }).version.trim().length === 0 + ) { + throw new Error(`Invalid package metadata in ${packageRoot}: package.json is missing a non-empty version string.`) + } + + return { version: (packageJson as { version: string }).version } +} + +async function listRelativeFiles(root: string, prefix = ""): Promise { + const entries = await readdir(root, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + if (entry.name === ".gitkeep") { + continue + } + + const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name + const absolutePath = path.join(root, entry.name) + + if (entry.isDirectory()) { + files.push(...(await listRelativeFiles(absolutePath, relativePath))) + continue + } + + files.push(relativePath) + } + + return files.sort() +} + +async function readTextIfExists(filePath: string): Promise { + try { + return await readFile(filePath, "utf8") + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined + } + + throw error + } +} + +async function ensureFileParent(filePath: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }) +} + +async function removeEmptyDirectories(startDir: string, stopDir: string): Promise { + 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 + } + + let entries: string[] + try { + entries = await readdir(current) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === "ENOENT") { + return + } + + throw error + } + + if (entries.length > 0) { + return + } + + try { + await rmdir(current) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === "ENOENT" || code === "ENOTEMPTY") { + return + } + + throw error + } + + current = path.dirname(current) + } +} + +function createReport(action: FrameworkAction, scope: FrameworkOptions["scope"], packageVersion: string, configDir: string, projectRoot: string): FrameworkReport { + return { + action, + scope, + packageVersion, + configDir, + projectRoot, + restartRequired: false, + items: [], + mcp: [], + } +} + +async function syncAssets(options: { + groups: AssetGroup[] + packageRoot: string + scope: FrameworkOptions["scope"] + configDir: string + projectRoot: string + stateFiles: Record + reportItems: ReportItem[] + force: boolean + dryRun: boolean +}): Promise { + let changed = false + + for (const group of options.groups) { + const targetBase = options.scope === "global" ? options.configDir : options.projectRoot + const sourceRoot = path.join(options.packageRoot, group.source) + const groupTargetRoot = path.join(targetBase, group.targets[options.scope]) + const files = await listRelativeFiles(sourceRoot) + + for (const relativeFile of files) { + const sourcePath = path.join(sourceRoot, relativeFile) + const destinationPath = path.join(groupTargetRoot, relativeFile) + const destinationKey = path.relative(targetBase, destinationPath).split(path.sep).join("/") + const sourceContent = await readFile(sourcePath, "utf8") + const sourceHash = hashContent(sourceContent) + const destinationContent = await readTextIfExists(destinationPath) + const previousState = options.stateFiles[destinationKey] + + if (destinationContent === undefined) { + if (!options.dryRun) { + await ensureFileParent(destinationPath) + await writeFile(destinationPath, sourceContent, "utf8") + options.stateFiles[destinationKey] = { + group: group.id, + sourceHash, + installedHash: sourceHash, + origin: "installed", + } + } + + changed ||= !options.dryRun + options.reportItems.push({ + kind: "asset", + name: destinationKey, + status: options.dryRun ? "skipped" : "installed", + detail: options.dryRun ? "Asset is not installed in this scope." : undefined, + }) + continue + } + + const destinationHash = hashContent(destinationContent) + if (destinationHash === sourceHash) { + // Only mark as managed if it was previously installed by us + if (previousState) { + options.stateFiles[destinationKey] = { + group: group.id, + sourceHash, + installedHash: sourceHash, + origin: previousState.origin, + } + } else { + // For files that already exist with matching content but weren't previously managed, + // we add them to state.files as "adopted" so we know not to delete them during uninstall + options.stateFiles[destinationKey] = { + group: group.id, + sourceHash, + installedHash: sourceHash, + origin: "adopted", + } + } + options.reportItems.push({ + kind: "asset", + name: destinationKey, + status: previousState ? "already up to date" : "already present (unmanaged)", + detail: previousState ? undefined : "File already exists with matching content but is not managed by the framework." + }) + continue + } + + 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, + origin: "installed", + } + } + + changed ||= !options.dryRun + options.reportItems.push({ + kind: "asset", + name: destinationKey, + status: options.dryRun ? "skipped" : "updated", + detail: options.dryRun ? "Asset is outdated and would update on install/update." : undefined, + }) + continue + } + + if (options.force) { + if (!options.dryRun) { + await ensureFileParent(destinationPath) + await writeFile(destinationPath, sourceContent, "utf8") + options.stateFiles[destinationKey] = { + group: group.id, + sourceHash, + installedHash: sourceHash, + origin: "installed", + } + } + + changed ||= !options.dryRun + options.reportItems.push({ + kind: "asset", + name: destinationKey, + status: options.dryRun ? "skipped" : "updated", + detail: options.dryRun ? "Asset would be overwritten with --force." : "Overwritten with --force.", + }) + continue + } + + options.reportItems.push({ + kind: "asset", + name: destinationKey, + status: "conflict/manual action required", + detail: "Destination file diverged from the managed hash. Re-run with --force or resolve manually.", + }) + } + } + + return changed +} + +/** Installs or resynchronizes the framework into the requested scope. */ +export async function installFramework(options: FrameworkOptions): Promise { + const manifest = await loadFrameworkManifest() + const paths = resolveScopePaths(options, manifest.stateFile) + const packageMetadata = await readPackageMetadata(paths.packageRoot) + const report = createReport("install", options.scope, packageMetadata.version, paths.configDir, paths.projectRoot) + const state = (await readInstallState(paths.statePath)) ?? createEmptyState(options.scope, packageMetadata.version, manifest.manifestVersion) + const diagnostics = await diagnoseMcpPolicies(manifest, options.env ?? process.env) + report.mcp = diagnostics + + await validateJsoncConfigFile(paths.opencodeConfigPath) + await validateJsoncConfigFile(paths.tuiConfigPath) + + const assetsChanged = await syncAssets({ + groups: manifest.assetGroups, + packageRoot: paths.packageRoot, + scope: options.scope, + configDir: paths.configDir, + projectRoot: paths.projectRoot, + stateFiles: state.files, + reportItems: report.items, + force: options.force ?? false, + dryRun: false, + }) + + const opencodeResult = await patchOpencodeConfig({ + filePath: paths.opencodeConfigPath, + manifest, + scope: options.scope, + diagnostics, + state, + }) + state.ownership.createdOpencodeConfig ||= opencodeResult.created + state.ownership.addedOpencodePlugin ||= opencodeResult.addedPlugin + state.ownership.addedInstructions = Array.from(new Set([...state.ownership.addedInstructions, ...opencodeResult.addedInstructions])) + state.ownership.addedMcpKeys = Array.from(new Set([...state.ownership.addedMcpKeys, ...opencodeResult.addedMcpKeys])) + state.ownership.addedMcpHashes = { + ...state.ownership.addedMcpHashes, + ...opencodeResult.addedMcpHashes, + } + + report.items.push({ + kind: "config", + name: path.relative(paths.projectRoot, paths.opencodeConfigPath) || path.basename(paths.opencodeConfigPath), + status: opencodeResult.changed ? (opencodeResult.created ? "installed" : "updated") : "already up to date", + }) + + const tuiResult = await patchTuiConfig({ + filePath: paths.tuiConfigPath, + manifest, + }) + state.ownership.createdTuiConfig ||= tuiResult.created + state.ownership.addedTuiPlugin ||= tuiResult.addedPlugin + + report.items.push({ + kind: "config", + name: path.relative(paths.projectRoot, paths.tuiConfigPath) || path.basename(paths.tuiConfigPath), + status: tuiResult.changed ? (tuiResult.created ? "installed" : "updated") : "already up to date", + }) + + for (const diagnostic of diagnostics) { + // Keep MCP diagnostics in both collections: report.mcp drives dedicated views while report.items feeds aggregate summaries. + report.items.push({ + kind: "mcp", + name: diagnostic.name, + status: diagnostic.status, + detail: diagnostic.detail, + }) + } + + state.updatedAt = new Date().toISOString() + state.manifestVersion = manifest.manifestVersion + state.packageVersion = packageMetadata.version + await writeInstallState(paths.statePath, state) + + report.restartRequired = assetsChanged || opencodeResult.changed || tuiResult.changed + if (report.restartRequired) { + report.items.push({ + kind: "runtime", + name: "OpenCode runtime", + status: "updated", + detail: "Restart OpenCode to discover new commands, agents, skills, instructions, and MCP configuration.", + }) + } else { + report.items.push({ + kind: "runtime", + name: "OpenCode runtime", + status: "already up to date", + detail: "No restart is required because no bootstrap-managed files changed.", + }) + } + + return report +} + +/** Reuses the install flow to refresh an existing framework installation. */ +export async function updateFramework(options: FrameworkOptions): Promise { + const report = await installFramework(options) + report.action = "update" + return report +} + +/** Reports the current framework state for a scope without mutating files. */ +export async function statusFramework(options: FrameworkOptions): Promise { + const manifest = await loadFrameworkManifest() + const paths = resolveScopePaths(options, manifest.stateFile) + const packageMetadata = await readPackageMetadata(paths.packageRoot) + const report = createReport("status", options.scope, packageMetadata.version, paths.configDir, paths.projectRoot) + const state = (await readInstallState(paths.statePath)) ?? createEmptyState(options.scope, packageMetadata.version, manifest.manifestVersion) + const diagnostics = await diagnoseMcpPolicies(manifest, options.env ?? process.env) + report.mcp = diagnostics + + 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, + }) + + for (const diagnostic of diagnostics) { + // Keep MCP diagnostics in both collections: report.mcp drives dedicated views while report.items feeds aggregate summaries. + report.items.push({ + kind: "mcp", + name: diagnostic.name, + status: diagnostic.status, + detail: diagnostic.detail, + }) + } + + const [opencodeStatus, tuiStatus] = await Promise.all([ + inspectOpencodeStatus({ + filePath: paths.opencodeConfigPath, + statePath: paths.statePath, + manifest, + scope: options.scope, + diagnostics, + }), + inspectTuiStatus({ + filePath: paths.tuiConfigPath, + statePath: paths.statePath, + manifest, + }), + ]) + + report.items.push(opencodeStatus.item, tuiStatus.item) + + const runtimeStatus = opencodeStatus.ok && tuiStatus.ok ? "skipped" : report.items.some((item) => item.status === "invalid-config") ? "invalid-config" : "config-drift" + + report.items.push({ + kind: "runtime", + name: "OpenCode runtime", + status: runtimeStatus, + detail: + runtimeStatus === "skipped" + ? `Install state file: ${paths.statePath}` + : `Config validation found issues in bootstrap-managed files. Install state file: ${paths.statePath}`, + }) + + return report +} + +/** Uninstalls framework-managed files and config from the requested scope. */ +export async function uninstallFramework(options: FrameworkOptions): Promise { + const manifest = await loadFrameworkManifest() + const paths = resolveScopePaths(options, manifest.stateFile) + const packageMetadata = await readPackageMetadata(paths.packageRoot) + const report = createReport("uninstall", options.scope, packageMetadata.version, paths.configDir, paths.projectRoot) + const state = await readInstallState(paths.statePath) + + if (!state) { + report.items.push({ + kind: "runtime", + name: "install state", + status: "skipped", + detail: "No framework install state was found for this scope.", + }) + return report + } + + await validateJsoncConfigFile(paths.opencodeConfigPath) + await validateJsoncConfigFile(paths.tuiConfigPath) + + const targetBase = options.scope === "global" ? paths.configDir : paths.projectRoot + let changed = false + const remainingFiles: typeof state.files = {} + for (const [relativePath, fileState] of Object.entries(state.files)) { + const filePath = path.join(targetBase, relativePath) + const content = await readTextIfExists(filePath) + if (content === undefined) { + report.items.push({ kind: "asset", name: relativePath, status: "skipped", detail: "File already absent." }) + continue + } + + const currentHash = hashContent(content) + // Skip deleting adopted files unless --force is used + 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 + } + + 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 + } + + const opencodeResult = await removeFrameworkConfig({ + filePath: paths.opencodeConfigPath, + manifest, + state, + }) + if (opencodeResult.changed) { + changed = true + report.items.push({ + kind: "config", + name: path.basename(paths.opencodeConfigPath), + status: opencodeResult.removedFile ? "removed" : "updated", + }) + } + + for (const key of opencodeResult.conflicts) { + report.items.push({ + kind: "mcp", + name: key, + status: "conflict/manual action required", + detail: "Framework-added MCP entry was modified after install and was left in place.", + }) + } + + const tuiResult = await removeFrameworkTuiConfig({ + filePath: paths.tuiConfigPath, + manifest, + state, + }) + if (tuiResult.changed) { + changed = true + report.items.push({ + kind: "config", + name: path.basename(paths.tuiConfigPath), + status: tuiResult.removedFile ? "removed" : "updated", + }) + } + + if (opencodeResult.conflicts.length > 0) { + await writeInstallState(paths.statePath, { + ...state, + files: {}, + ownership: { + createdOpencodeConfig: state.ownership.createdOpencodeConfig && !opencodeResult.removedFile, + createdTuiConfig: state.ownership.createdTuiConfig && !tuiResult.removedFile, + addedOpencodePlugin: false, + addedTuiPlugin: false, + addedInstructions: [], + addedMcpKeys: opencodeResult.remainingAddedMcpKeys, + addedMcpHashes: opencodeResult.remainingAddedMcpHashes, + }, + updatedAt: new Date().toISOString(), + }) + report.restartRequired = changed + report.items.push({ + kind: "runtime", + name: "OpenCode runtime", + status: "conflict/manual action required", + detail: "Uninstall stopped because some framework-managed MCP entries were modified. Install state was preserved for the remaining config cleanup.", + }) + return report + } + + await removeInstallState(paths.statePath) + await removeEmptyDirectories( + path.dirname(paths.statePath), + options.scope === "global" ? paths.configDir : path.join(paths.projectRoot, ".opencode"), + ) + report.restartRequired = changed + report.items.push({ + kind: "runtime", + name: "OpenCode runtime", + status: changed ? "updated" : "skipped", + detail: changed + ? "Restart OpenCode to unload removed framework assets for this scope." + : "No managed framework files were removed.", + }) + + return report +} + +/** Detects whether global and project scopes currently have persisted framework state. */ +export async function detectFrameworkScopes(options: Omit = {}): Promise { + const manifest = await loadFrameworkManifest() + const globalPaths = resolveScopePaths({ ...options, scope: "global" }, manifest.stateFile) + const projectPaths = resolveScopePaths({ ...options, scope: "project" }, manifest.stateFile) + + const [globalState, projectState] = await Promise.all([ + readInstallState(globalPaths.statePath), + readInstallState(projectPaths.statePath), + ]) + + return [ + { + scope: "global", + installed: Boolean(globalState), + statePath: globalPaths.statePath, + }, + { + scope: "project", + installed: Boolean(projectState), + statePath: projectPaths.statePath, + }, + ] +} diff --git a/src/framework/file-write.ts b/src/framework/file-write.ts new file mode 100644 index 0000000..104f956 --- /dev/null +++ b/src/framework/file-write.ts @@ -0,0 +1,27 @@ +import { randomUUID } from "node:crypto" +import { mkdir, rename, rm, writeFile } from "node:fs/promises" +import path from "node:path" + +/** Writes text atomically via temp file + rename to avoid partial config/state files. */ +export async function writeTextAtomically(filePath: string, content: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }) + const tempPath = `${filePath}.${randomUUID()}.tmp` + await writeFile(tempPath, content, "utf8") + + 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 }) + try { + await rename(tempPath, filePath) + } catch (renameError) { + await rm(tempPath, { force: true }) + throw renameError + } + } +} diff --git a/src/framework/jsonc.ts b/src/framework/jsonc.ts new file mode 100644 index 0000000..393571e --- /dev/null +++ b/src/framework/jsonc.ts @@ -0,0 +1,52 @@ +import { parse, printParseErrorCode, type ParseError } from "jsonc-parser" + +export type JsonObject = Record + +export function isObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function getLineAndColumn(text: string, offset: number): { line: number; column: number } { + let line = 1 + let column = 1 + + for (let index = 0; index < offset; index += 1) { + if (text[index] === "\n") { + line += 1 + column = 1 + continue + } + + column += 1 + } + + return { line, column } +} + +/** Formats a JSONC parser error into a user-facing validation message. */ +export function formatJsoncParseError(filePath: string, raw: string, error: ParseError): Error { + const { line, column } = getLineAndColumn(raw, error.offset) + return new Error(`Invalid JSONC in ${filePath} at ${line}:${column}: ${printParseErrorCode(error.error)}.`) +} + +/** Parses a JSONC object and fails fast when the file cannot be safely mutated. */ +export function parseJsoncObject(raw: string, filePath: string): JsonObject { + const errors: ParseError[] = [] + const parsed = parse(raw, errors) + if (errors.length > 0) { + throw formatJsoncParseError(filePath, raw, errors[0]) + } + + if (!isObject(parsed)) { + throw new Error(`Invalid JSONC in ${filePath}: root value must be an object.`) + } + + return parsed +} + +export function hasPluginSpec(entries: unknown[], spec: string): boolean { + return entries.some((entry) => { + const value = typeof entry === "string" ? entry : Array.isArray(entry) && typeof entry[0] === "string" ? entry[0] : undefined + return value === spec || value?.startsWith(`${spec}@`) === true + }) +} diff --git a/src/framework/manifest.ts b/src/framework/manifest.ts new file mode 100644 index 0000000..93792d9 --- /dev/null +++ b/src/framework/manifest.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises" +import path from "node:path" + +import { findPackageRoot } from "./package-root.js" +import type { FrameworkManifest } from "./types.js" + +let cachedManifestPromise: Promise | undefined + +function isStringRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function assertFrameworkManifest(value: unknown): asserts value is FrameworkManifest { + if (!isStringRecord(value)) { + throw new Error("Framework manifest must be an object") + } + + if (typeof value.manifestVersion !== "number" || typeof value.packageName !== "string" || typeof value.pluginId !== "string") { + throw new Error("Framework manifest is missing required top-level metadata") + } + + if (!Array.isArray(value.assetGroups) || !isStringRecord(value.config) || !isStringRecord(value.mcp)) { + throw new Error("Framework manifest is missing required asset, config, or MCP sections") + } +} + +/** Loads the packaged framework manifest once per process; long-lived hosts need a restart to observe package updates. */ +export async function loadFrameworkManifest(): Promise { + 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") + const parsedManifest = JSON.parse(rawManifest) as unknown + assertFrameworkManifest(parsedManifest) + return parsedManifest + } catch (error) { + cachedManifestPromise = undefined + throw error + } + })() + + return cachedManifestPromise +} diff --git a/src/framework/package-root.ts b/src/framework/package-root.ts new file mode 100644 index 0000000..508d8af --- /dev/null +++ b/src/framework/package-root.ts @@ -0,0 +1,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 + } +} diff --git a/src/framework/paths.ts b/src/framework/paths.ts new file mode 100644 index 0000000..1f7e36c --- /dev/null +++ b/src/framework/paths.ts @@ -0,0 +1,59 @@ +import { homedir } from "node:os" +import path from "node:path" + +import { findPackageRoot } from "./package-root.js" +import type { FrameworkOptions, Scope } from "./types.js" + +export type ScopePaths = { + scope: Scope + packageRoot: string + projectRoot: string + configDir: string + opencodeConfigPath: string + tuiConfigPath: string + statePath: string +} + +function resolveDefaultGlobalConfigDir(env: NodeJS.ProcessEnv): string { + if (env.OPENCODE_CONFIG_DIR) { + return path.resolve(env.OPENCODE_CONFIG_DIR) + } + + return path.join(homedir(), ".config", "opencode") +} + +/** Resolves the project root used for project-scope framework operations. */ +export function resolveProjectRoot(input?: string): string { + return path.resolve(input ?? process.cwd()) +} + +/** Resolves package, config, and state file paths for a specific framework scope. */ +export function resolveScopePaths(options: FrameworkOptions, stateFile: string): ScopePaths { + const env = options.env ?? process.env + const packageRoot = findPackageRoot(import.meta.url) + const projectRoot = resolveProjectRoot(options.projectRoot) + + if (options.scope === "global") { + const configDir = path.resolve(options.globalConfigDir ?? resolveDefaultGlobalConfigDir(env)) + return { + scope: "global", + packageRoot, + projectRoot, + configDir, + opencodeConfigPath: path.join(configDir, "opencode.json"), + tuiConfigPath: path.join(configDir, "tui.json"), + statePath: path.join(configDir, stateFile), + } + } + + const configDir = path.join(projectRoot, ".opencode") + return { + scope: "project", + packageRoot, + projectRoot, + configDir, + opencodeConfigPath: path.join(projectRoot, "opencode.json"), + tuiConfigPath: path.join(projectRoot, "tui.json"), + statePath: path.join(configDir, stateFile), + } +} diff --git a/src/framework/prerequisites.ts b/src/framework/prerequisites.ts new file mode 100644 index 0000000..546a958 --- /dev/null +++ b/src/framework/prerequisites.ts @@ -0,0 +1,99 @@ +import { constants } from "node:fs" +import { access } from "node:fs/promises" +import path from "node:path" + +import type { FrameworkManifest, McpDiagnostic } from "./types.js" + +type BinaryResolver = (binary: string, env: NodeJS.ProcessEnv) => Promise + +async function isExecutable(filePath: string): Promise { + try { + await access(filePath, constants.X_OK) + return true + } catch { + return false + } +} + +function candidateExecutables(binary: string, env: NodeJS.ProcessEnv): string[] { + const pathEntries = (env.PATH ?? "").split(path.delimiter) + + if (process.platform !== "win32") { + return pathEntries.map((entry) => path.join(entry, binary)) + } + + const extensions = (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM") + .split(";") + .filter(Boolean) + const hasExtension = path.extname(binary) !== "" + + return pathEntries.flatMap((entry) => { + if (hasExtension) { + return [path.join(entry, binary)] + } + + return extensions.map((extension) => path.join(entry, `${binary}${extension.toLowerCase()}`)) + }) +} + +export const defaultBinaryResolver: BinaryResolver = async (binary, env) => { + const candidates = candidateExecutables(binary, env) + for (const candidate of candidates) { + if (await isExecutable(candidate)) { + return true + } + } + + return false +} + +/** Evaluates MCP prerequisites and returns the effective enabled state for each MCP entry. Env requirements take precedence over binary issues in the summary status. */ +export async function diagnoseMcpPolicies( + manifest: FrameworkManifest, + env: NodeJS.ProcessEnv, + resolveBinary: BinaryResolver = defaultBinaryResolver, +): Promise { + const diagnostics: McpDiagnostic[] = [] + + for (const [name, policy] of Object.entries(manifest.mcp ?? {})) { + const requiredEnv = policy.requirements?.env ?? [] + const requiredBinaries = policy.requirements?.binaries ?? [] + const missingEnv = requiredEnv.filter((variable) => env[variable] === undefined || env[variable] === "") + + const binaryChecks = await Promise.all( + requiredBinaries.map(async (binary) => ({ + binary, + present: await resolveBinary(binary, env), + })), + ) + const missingBinaries = binaryChecks.filter((entry) => !entry.present).map((entry) => entry.binary) + + let status: McpDiagnostic["status"] = "configured and enabled" + let enabled = true + 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 + } + + diagnostics.push({ + name, + status, + enabled, + missingEnv, + missingBinaries, + detail: policy.reason ?? "", + config: { + ...policy.config, + enabled, + }, + }) + } + + return diagnostics.sort((left, right) => left.name.localeCompare(right.name)) +} diff --git a/src/framework/state.ts b/src/framework/state.ts new file mode 100644 index 0000000..932c0c0 --- /dev/null +++ b/src/framework/state.ts @@ -0,0 +1,70 @@ +import { readFile, rm } from "node:fs/promises" + +import { writeTextAtomically } from "./file-write.js" +import type { FrameworkInstallState, Scope } from "./types.js" + +/** Creates an empty persisted state object for a scope that has not been bootstrapped yet. */ +export function createEmptyState(scope: Scope, packageVersion: string, manifestVersion: number): FrameworkInstallState { + return { + scope, + manifestVersion, + packageVersion, + updatedAt: new Date().toISOString(), + files: {}, + ownership: { + createdOpencodeConfig: false, + createdTuiConfig: false, + addedOpencodePlugin: false, + addedTuiPlugin: false, + addedInstructions: [], + addedMcpKeys: [], + addedMcpHashes: {}, + }, + } +} + +/** Backfills newer ownership fields so older persisted install states remain safe to consume. */ +function normalizeInstallState(state: FrameworkInstallState): FrameworkInstallState { + const ownership = state.ownership ?? createEmptyState(state.scope, state.packageVersion, state.manifestVersion).ownership + + return { + ...state, + files: state.files ?? {}, + ownership: { + createdOpencodeConfig: ownership.createdOpencodeConfig ?? false, + createdTuiConfig: ownership.createdTuiConfig ?? false, + addedOpencodePlugin: ownership.addedOpencodePlugin ?? false, + addedTuiPlugin: ownership.addedTuiPlugin ?? false, + addedInstructions: Array.isArray(ownership.addedInstructions) ? ownership.addedInstructions : [], + addedMcpKeys: Array.isArray(ownership.addedMcpKeys) ? ownership.addedMcpKeys : [], + addedMcpHashes: + ownership.addedMcpHashes && typeof ownership.addedMcpHashes === "object" && !Array.isArray(ownership.addedMcpHashes) + ? ownership.addedMcpHashes + : {}, + }, + } +} + +/** Reads persisted framework state for the requested scope when it exists. */ +export async function readInstallState(filePath: string): Promise { + try { + const raw = await readFile(filePath, "utf8") + return normalizeInstallState(JSON.parse(raw) as FrameworkInstallState) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined + } + + throw error + } +} + +/** Persists the framework state used for idempotent updates and safe uninstalls. */ +export async function writeInstallState(filePath: string, state: FrameworkInstallState): Promise { + await writeTextAtomically(filePath, `${JSON.stringify(state, null, 2)}\n`) +} + +/** Removes the persisted framework state file for a scope. */ +export async function removeInstallState(filePath: string): Promise { + await rm(filePath, { force: true }) +} diff --git a/src/framework/types.ts b/src/framework/types.ts new file mode 100644 index 0000000..191f58f --- /dev/null +++ b/src/framework/types.ts @@ -0,0 +1,139 @@ +export type Scope = "global" | "project" + +export type FrameworkAction = "install" | "status" | "update" | "uninstall" + +export type ReportStatus = + | "installed" + | "updated" + | "already up to date" + | "already present (unmanaged)" + | "skipped" + | "invalid-config" + | "config-drift" + | "blocked by missing env" + | "blocked by missing binary" + | "configured and enabled" + | "configured but disabled by missing env" + | "configured but disabled by missing binary" + | "configured but requires auth/manual setup" + | "conflict/manual action required" + | "removed" + +export type AssetGroupId = "commands" | "agents" | "skills" | "instructions" + +export type AssetGroup = { + id: AssetGroupId + source: string + targets: Record +} + +export type FrameworkConfigManifest = { + opencode: { + schema: string + instructions: Record + plugin: string + } + tui: { + schema: string + plugin: string + } +} + +export type McpRequirement = { + binaries?: string[] + env?: string[] + manual?: boolean +} + +export type McpPolicy = { + config: Record + requirements?: McpRequirement + reason?: string +} + +export type FrameworkManifest = { + manifestVersion: number + packageName: string + pluginId: string + stateFile: string + assetGroups: AssetGroup[] + config: FrameworkConfigManifest + mcp: Record +} + +export type ManagedFileOrigin = "installed" | "adopted" + +export type ManagedFileState = { + group: AssetGroupId + sourceHash: string + installedHash: string + origin: ManagedFileOrigin +} + +export type FrameworkInstallState = { + scope: Scope + manifestVersion: number + packageVersion: string + updatedAt: string + files: Record + ownership: { + createdOpencodeConfig: boolean + createdTuiConfig: boolean + addedOpencodePlugin: boolean + addedTuiPlugin: boolean + addedInstructions: string[] + addedMcpKeys: string[] + addedMcpHashes: Record + } +} + +export type ReportItem = { + kind: "asset" | "config" | "mcp" | "runtime" + name: string + status: ReportStatus + detail?: string +} + +export type McpDiagnosticStatus = Extract< + ReportStatus, + | "configured and enabled" + | "configured but disabled by missing env" + | "configured but disabled by missing binary" + | "configured but requires auth/manual setup" +> + +export type McpDiagnostic = { + name: string + status: McpDiagnosticStatus + enabled: boolean + missingEnv: string[] + missingBinaries: string[] + detail: string + config: Record +} + +/** Framework reports expose MCP diagnostics twice: `mcp` is the dedicated view and `items` may also include MCP entries for aggregate consumers. */ +export type FrameworkReport = { + action: FrameworkAction + scope: Scope + packageVersion: string + configDir: string + projectRoot: string + restartRequired: boolean + items: ReportItem[] + mcp: McpDiagnostic[] +} + +export type FrameworkOptions = { + scope: Scope + projectRoot?: string + globalConfigDir?: string + force?: boolean + env?: NodeJS.ProcessEnv +} + +export type ScopeDetection = { + scope: Scope + installed: boolean + statePath: string +} diff --git a/src/runtime/hooks.ts b/src/runtime/hooks.ts new file mode 100644 index 0000000..8413a30 --- /dev/null +++ b/src/runtime/hooks.ts @@ -0,0 +1,80 @@ +import type { Hooks } from "@opencode-ai/plugin" + +import { autoCheckpointHint, commandPersistenceHint, persistenceContract } from "./memory.js" + +const persistenceCommands = new Set(["sc-pm", "sc-save", "sc-load", "sc-reflect"]) +// Checkpoint hints target longer-running execution commands rather than planning/persistence commands. +const checkpointCommands = new Set(["sc-implement", "sc-build", "sc-test", "sc-document", "sc-task"]) + +type TextLikePart = { + id?: string + text?: string + [key: string]: unknown +} + +function hasTextPart(parts: unknown[], id: string, text: string): boolean { + return parts.some((part) => { + if (!part || typeof part !== "object") { + return false + } + + const candidate = part as TextLikePart + return candidate.id === id || candidate.text === text + }) +} + +function pushUniquePart(parts: unknown[], part: TextLikePart & { text: string }): void { + if (!hasTextPart(parts, part.id ?? "", part.text)) { + parts.push(part) + } +} + +function pushUniqueText(lines: string[], value: string): void { + if (!lines.includes(value)) { + lines.push(value) + } +} + +/** Creates command hooks that inject persistence and checkpoint guidance without duplicate parts. */ +export const createCommandHooks = (): Hooks => ({ + "command.execute.before": async (input, output) => { + const normalized = (input.command ?? "").replace(/^\//, "") + + if (persistenceCommands.has(normalized)) { + pushUniquePart(output.parts, { + id: "super-opencode-persistence-hint", + sessionID: input.sessionID, + messageID: "", + type: "text", + text: commandPersistenceHint, + }) + } + + if (checkpointCommands.has(normalized)) { + pushUniquePart(output.parts, { + id: "super-opencode-checkpoint-hint", + sessionID: input.sessionID, + messageID: "", + type: "text", + text: "Consider using `/sc-save` to create a checkpoint before proceeding with long operations.", + }) + } + }, +}) + +/** Creates system hooks that inject the framework persistence contract exactly once. */ +export const createSystemHooks = (): Hooks => ({ + "experimental.chat.system.transform": async (_input, output) => { + pushUniqueText(output.system, persistenceContract) + }, +}) + +/** Creates compaction hooks that preserve framework memory guidance across compaction. */ +export const createCompactionHooks = (worktree: string): Hooks => ({ + "experimental.session.compacting": async (_input, output) => { + pushUniqueText( + output.context, + ["## Super OpenCode Memory", `Worktree: ${worktree}`, autoCheckpointHint, persistenceContract].join("\n"), + ) + }, +}) diff --git a/src/runtime/memory.ts b/src/runtime/memory.ts new file mode 100644 index 0000000..df8973a --- /dev/null +++ b/src/runtime/memory.ts @@ -0,0 +1,17 @@ +export const persistenceContract = [ + "Serena is the persistence source of truth for this project.", + "Use Serena memory keys `pm_context`, `current_plan`, `last_session`, `next_actions`, `checkpoint`, `decision`, and `summary` when relevant.", + "For hierarchical task tracking: plan_[timestamp], phase_[1-5], task_[phase].[number], todo_[task].[number], checkpoint_[timestamp].", + "When Serena is unavailable, state clearly that the session is operating in degraded persistence mode.", +].join(" ") + +export const commandPersistenceHint = [ + "For `/sc-pm`, `/sc-save`, `/sc-load`, and `/sc-reflect`, prefer Serena memory tools first.", + 'For complex tasks: write_memory("plan_[timestamp]", goal_statement) -> write_memory("phase_X", milestone) -> write_memory("task_X.Y", deliverable).', + "Use repo files only for public, durable documentation; keep session continuity in Serena rather than committed scratch files.", +].join(" ") + +export const autoCheckpointHint = [ + "Consider creating a checkpoint with `/sc-save` every 30 minutes for long operations.", + "Use `/sc-pm` to summarize current progress before pausing.", +].join(" ") diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts new file mode 100644 index 0000000..54ebc13 --- /dev/null +++ b/src/runtime/plugin.ts @@ -0,0 +1,70 @@ +import type { Hooks, Plugin } from "@opencode-ai/plugin" + +import { createCommandHooks, createCompactionHooks, createSystemHooks } from "./hooks.js" + +const runtimeLoadMarker = Symbol.for("super-opencode.runtime-loaded") + +type GlobalRuntimeState = typeof globalThis & { + [key: symbol]: boolean | undefined +} + +async function safeLog(logOperation: () => Promise): Promise { + try { + await logOperation() + } catch { + // Logging should never block hook initialization. + } +} + +function mergeHooks(...hookSets: Hooks[]): Hooks { + const merged: Hooks = {} + + for (const hookSet of hookSets) { + for (const [key, handler] of Object.entries(hookSet)) { + if (Object.prototype.hasOwnProperty.call(merged, key)) { + throw new Error(`Super OpenCode hook collision for '${key}'`) + } + + ;(merged as Record)[key] = handler + } + } + + return merged +} + +/** Creates the runtime plugin hooks and skips duplicate registration within one process. */ +export const SuperOpenCodePlugin: Plugin = async ({ client, worktree }) => { + const runtimeState = globalThis as GlobalRuntimeState + if (runtimeState[runtimeLoadMarker]) { + await safeLog(() => + client.app.log({ + body: { + service: "super-opencode", + level: "info", + message: "Super OpenCode runtime already active, skipping duplicate hook registration", + }, + }), + ) + + return createCompactionHooks(worktree) + } + + runtimeState[runtimeLoadMarker] = true + + try { + await safeLog(() => + client.app.log({ + body: { + service: "super-opencode", + level: "info", + message: "Super OpenCode plugin initialized", + }, + }), + ) + + return mergeHooks(createSystemHooks(), createCommandHooks(), createCompactionHooks(worktree)) + } catch (error) { + delete runtimeState[runtimeLoadMarker] + throw error + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..fd7be15 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,11 @@ +import type { PluginModule } from "@opencode-ai/plugin" + +import { SuperOpenCodePlugin } from "./runtime/plugin.js" + +const pluginModule: PluginModule & { id: string } = { + id: "super-opencode-framework", + server: SuperOpenCodePlugin, +} + +export { SuperOpenCodePlugin } +export default pluginModule diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..cc45200 --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,163 @@ +import type { TuiDialogSelectOption, TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" + +import { detectFrameworkScopes, installFramework, statusFramework, uninstallFramework, updateFramework } from "./framework/engine.js" +import type { FrameworkAction, FrameworkReport, Scope } from "./framework/types.js" + +function summarizeReport(report: FrameworkReport): string { + const summary = [ + `Action: ${report.action}`, + `Scope: ${report.scope}`, + `Restart required: ${report.restartRequired ? "yes" : "no"}`, + ] + + const conflicts = report.items.filter((item) => item.status === "conflict/manual action required") + const changed = report.items.filter((item) => item.status === "installed" || item.status === "updated" || item.status === "removed") + const alreadyCurrent = report.items.filter((item) => item.status === "already up to date") + + summary.push(`Changed items: ${changed.length}`) + summary.push(`Already current: ${alreadyCurrent.length}`) + if (conflicts.length > 0) { + summary.push(`Conflicts: ${conflicts.length}`) + } + + return summary.join("\n") +} + +const tui: TuiPlugin = async (api) => { + const projectRoot = () => { + const worktree = api.state.path.worktree + if (worktree && worktree !== "/") { + return worktree + } + + return api.state.path.directory + } + + let promptedThisSession = false + + const runAction = async (action: FrameworkAction, scope: Scope) => { + const sharedOptions = { + scope, + projectRoot: projectRoot(), + env: process.env, + } + + try { + const report = + action === "install" + ? await installFramework(sharedOptions) + : action === "status" + ? await statusFramework(sharedOptions) + : action === "update" + ? await updateFramework(sharedOptions) + : await uninstallFramework(sharedOptions) + + api.ui.dialog.replace(() => + api.ui.DialogAlert({ + title: "Super OpenCode", + message: summarizeReport(report), + onConfirm: () => { + api.ui.dialog.clear() + }, + }), + ) + } catch (error) { + api.ui.dialog.replace(() => + api.ui.DialogAlert({ + title: "Super OpenCode Error", + message: error instanceof Error ? error.message : String(error), + onConfirm: () => { + api.ui.dialog.clear() + }, + }), + ) + } + } + + const openScopeDialog = (action: FrameworkAction) => { + const options: TuiDialogSelectOption[] = [ + { + title: "Project scope (recommended)", + value: "project", + description: "Sync into .opencode and opencode.json for the current repo.", + }, + { + title: "Global scope", + value: "global", + description: "Sync into ~/.config/opencode without touching the current project.", + }, + ] + + api.ui.dialog.replace(() => + api.ui.DialogSelect({ + title: "Choose Super OpenCode scope", + options, + onSelect: (option) => { + void runAction(action, option.value) + }, + }), + ) + } + + const openMainDialog = () => { + const options: TuiDialogSelectOption[] = [ + { + title: "Bootstrap / install", + value: "install", + description: "Install or resync framework assets for a chosen scope.", + }, + { + title: "Status", + value: "status", + description: "Inspect the current framework install state and MCP diagnostics.", + }, + { + title: "Update", + value: "update", + description: "Refresh assets and config using the latest package contents.", + }, + { + title: "Uninstall", + value: "uninstall", + description: "Remove the framework from a chosen scope.", + }, + ] + + api.ui.dialog.replace(() => + api.ui.DialogSelect({ + title: "Super OpenCode", + options, + onSelect: (option) => { + openScopeDialog(option.value) + }, + }), + ) + } + + api.command.register(() => [ + { + title: "Super OpenCode", + value: "super-opencode.framework", + description: "Bootstrap, diagnose, update, or uninstall the framework.", + category: "Plugins", + onSelect: openMainDialog, + }, + ]) + + try { + const scopes = await detectFrameworkScopes({ projectRoot: projectRoot(), env: process.env }) + if (!promptedThisSession && scopes.every((entry) => !entry.installed)) { + promptedThisSession = true + openScopeDialog("install") + } + } catch { + // Keep the plugin usable through the command palette even if bootstrap state detection fails. + } +} + +const pluginModule: TuiPluginModule & { id: string } = { + id: "super-opencode-framework", + tui, +} + +export default pluginModule diff --git a/tests/framework.test.mjs b/tests/framework.test.mjs index 539f690..b6b813a 100644 --- a/tests/framework.test.mjs +++ b/tests/framework.test.mjs @@ -1,86 +1,1046 @@ -import { describe, test, expect } from 'bun:test' -import { readFileSync, existsSync } from 'fs' -import { join } from 'path' +import { beforeAll, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { createHash } from 'node:crypto' +import { parse } from 'jsonc-parser' -const projectRoot = join(import.meta.dir, '..') +import { installFramework, statusFramework, uninstallFramework, updateFramework } from '../src/framework/engine.ts' +import { patchOpencodeConfig } from '../src/framework/config.ts' +import { loadFrameworkManifest } from '../src/framework/manifest.ts' +import { diagnoseMcpPolicies } from '../src/framework/prerequisites.ts' -describe('Project Structure', () => { - test('package.json exists and is valid', () => { - const pkgPath = join(projectRoot, 'package.json') - expect(existsSync(pkgPath)).toBe(true) - - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) - expect(pkg.name).toBe('super-opencode-framework') - expect(pkg.version).toBe('1.0.1') - }) +const execFileAsync = promisify(execFile) +const projectRoot = path.join(import.meta.dir, '..') +const cliEntry = path.join(projectRoot, 'scripts', 'install-project.mjs') + +function hashContent(content) { + return createHash('sha256').update(content).digest('hex') +} + +async function readJson(filePath) { + return parse(await readFile(filePath, 'utf8')) +} - test('opencode.json exists and is valid', () => { - const configPath = join(projectRoot, 'opencode.json') - expect(existsSync(configPath)).toBe(true) - - const config = JSON.parse(readFileSync(configPath, 'utf-8')) - expect(config.mcp?.serena?.enabled).toBe(true) +async function runCli(cwd, args, env = process.env) { + return execFileAsync('node', [cliEntry, ...args], { + cwd, + env: { ...process.env, ...env }, }) +} + +async function createSandbox(label) { + const root = await mkdtemp(path.join(tmpdir(), `super-opencode-${label}-`)) + const workspace = path.join(root, 'workspace') + const globalConfigDir = path.join(root, 'global-config') + await mkdir(workspace, { recursive: true }) + await mkdir(globalConfigDir, { recursive: true }) + return { root, workspace, globalConfigDir } +} + +async function assertProjectInstallLayout(workspace) { + expect(await readFile(path.join(workspace, '.opencode', 'agents', 'pm-agent.md'), 'utf8')).toContain('pm-agent') + expect(await readFile(path.join(workspace, '.opencode', 'commands', 'sc-help.md'), 'utf8')).toContain('List available `/sc-*` commands') + expect(await readFile(path.join(workspace, '.opencode', 'skills', 'sc-orchestration', 'SKILL.md'), 'utf8')).toContain('sc-orchestration') + expect(await readFile(path.join(workspace, '.opencode', 'instructions', 'opencode-core.md'), 'utf8')).toContain('Super OpenCode Core Instructions') +} + +async function assertGlobalInstallLayout(globalConfigDir) { + expect(await readFile(path.join(globalConfigDir, 'agents', 'pm-agent.md'), 'utf8')).toContain('pm-agent') + expect(await readFile(path.join(globalConfigDir, 'commands', 'sc-help.md'), 'utf8')).toContain('List available `/sc-*` commands') + expect(await readFile(path.join(globalConfigDir, 'skills', 'sc-orchestration', 'SKILL.md'), 'utf8')).toContain('sc-orchestration') + expect(await readFile(path.join(globalConfigDir, 'instructions', 'opencode-core.md'), 'utf8')).toContain('Super OpenCode Core Instructions') +} - test('AGENTS.md exists', () => { - expect(existsSync(join(projectRoot, 'AGENTS.md'))).toBe(true) +beforeAll(async () => { + await execFileAsync('bun', ['run', 'build'], { cwd: projectRoot, env: process.env, timeout: 60000 }) +}, 120000) + +describe('Package surface', () => { + test('package exposes explicit server and tui targets', async () => { + const pkg = await readJson(path.join(projectRoot, 'package.json')) + expect(pkg.exports['./server'].import).toBe('./dist/src/server.js') + expect(pkg.exports['./tui'].import).toBe('./dist/src/tui.js') }) - test('runtime instructions exist', () => { - expect(existsSync(join(projectRoot, '.opencode/instructions/opencode-core.md'))).toBe(true) + test('scopes command works without --scope', async () => { + const sandbox = await createSandbox('scopes-command') + + try { + const { stdout } = await runCli(sandbox.workspace, ['scopes'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + expect(stdout).toContain('"scope": "global"') + expect(stdout).toContain('"scope": "project"') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) }) -describe('OpenCode Commands', () => { - test('commands directory exists', () => { - expect(existsSync(join(projectRoot, '.opencode/commands'))).toBe(true) +describe('Framework bootstrap', () => { + test('installs global scope into ~/.config/opencode-compatible layout', async () => { + const sandbox = await createSandbox('global-only') + + try { + const { stdout } = await runCli(sandbox.workspace, ['install', '--scope', 'global'], { + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + TAVILY_API_KEY: '', + MORPH_API_KEY: '', + }) + + expect(stdout).toContain('Scope: global') + await assertGlobalInstallLayout(sandbox.globalConfigDir) + + const opencodeConfig = await readJson(path.join(sandbox.globalConfigDir, 'opencode.json')) + const tuiConfig = await readJson(path.join(sandbox.globalConfigDir, 'tui.json')) + expect(opencodeConfig.plugin).toContain('super-opencode-framework') + expect(opencodeConfig.instructions).toContain('instructions/opencode-core.md') + expect(tuiConfig.plugin).toContain('super-opencode-framework') + expect(opencodeConfig.mcp.serena).toBeDefined() + expect(opencodeConfig.mcp.context7.enabled).toBe(false) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) - test('sc-pm command exists', () => { - expect(existsSync(join(projectRoot, '.opencode/commands/sc-pm.md'))).toBe(true) + test('installs project scope into .opencode and project configs', async () => { + const sandbox = await createSandbox('project-only') + + try { + await writeFile( + path.join(sandbox.workspace, 'opencode.json'), + '{\n // keep this comment\n "instructions": ["docs/local.md"]\n}\n', + 'utf8', + ) + + const { stdout } = await runCli(sandbox.workspace, ['install', '--scope', 'project'], { + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + TAVILY_API_KEY: '', + MORPH_API_KEY: '', + }) + + expect(stdout).toContain('Scope: project') + await assertProjectInstallLayout(sandbox.workspace) + + const opencodeConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + const tuiConfig = await readJson(path.join(sandbox.workspace, 'tui.json')) + expect(await readFile(path.join(sandbox.workspace, 'opencode.json'), 'utf8')).toContain('// keep this comment') + expect(opencodeConfig.plugin).toContain('super-opencode-framework') + expect(opencodeConfig.instructions).toContain('.opencode/instructions/opencode-core.md') + expect(tuiConfig.plugin).toContain('super-opencode-framework') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) - test('sc-save command exists', () => { - expect(existsSync(join(projectRoot, '.opencode/commands/sc-save.md'))).toBe(true) + test('supports global then project without mixing target directories', async () => { + const sandbox = await createSandbox('global-then-project') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'global'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + await assertGlobalInstallLayout(sandbox.globalConfigDir) + await assertProjectInstallLayout(sandbox.workspace) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) - test('sc-load command exists', () => { - expect(existsSync(join(projectRoot, '.opencode/commands/sc-load.md'))).toBe(true) + test('supports project then global without duplicating config entries', async () => { + const sandbox = await createSandbox('project-then-global') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + await runCli(sandbox.workspace, ['install', '--scope', 'global'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + const projectConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + const globalConfig = await readJson(path.join(sandbox.globalConfigDir, 'opencode.json')) + + expect(projectConfig.plugin.filter((entry) => entry === 'super-opencode-framework')).toHaveLength(1) + expect(globalConfig.plugin.filter((entry) => entry === 'super-opencode-framework')).toHaveLength(1) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) -}) -describe('OpenCode Agents', () => { - test('agents directory exists', () => { - expect(existsSync(join(projectRoot, '.opencode/agents'))).toBe(true) + test('is idempotent on a second install', async () => { + const sandbox = await createSandbox('idempotent') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + const secondRun = await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + expect(secondRun.stdout).toContain('already up to date') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) - test('pm-agent exists', () => { - expect(existsSync(join(projectRoot, '.opencode/agents/pm-agent.md'))).toBe(true) + test('does not rewrite MCP config when only key order differs', async () => { + const sandbox = await createSandbox('mcp-order-idempotent') + + try { + const manifest = await loadFrameworkManifest() + const diagnostics = await diagnoseMcpPolicies(manifest, { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + }) + + await writeFile( + path.join(sandbox.workspace, 'opencode.json'), + `{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["super-opencode-framework"], + "instructions": [".opencode/instructions/opencode-core.md"], + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "enabled": true, + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + } + } + } +} +`, + 'utf8', + ) + + const result = await patchOpencodeConfig({ + filePath: path.join(sandbox.workspace, 'opencode.json'), + manifest, + scope: 'project', + diagnostics: diagnostics.filter((entry) => entry.name === 'context7'), + }) + + expect(result.changed).toBe(false) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) -}) -describe('OpenCode Skills', () => { - test('skills directory exists', () => { - expect(existsSync(join(projectRoot, '.opencode/skills'))).toBe(true) + test('preserves unrelated plugin entries when the framework plugin is already present', async () => { + const sandbox = await createSandbox('plugin-array-preserved') + + try { + const manifest = await loadFrameworkManifest() + const diagnostics = await diagnoseMcpPolicies(manifest, { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + }) + + await writeFile( + path.join(sandbox.workspace, 'opencode.json'), + `{ + "$schema": "https://example.invalid/outdated-schema.json", + "plugin": ["super-opencode-framework", null, 42, {"custom": true}], + "instructions": [".opencode/instructions/opencode-core.md"], + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "enabled": true, + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + } + } + } +} +`, + 'utf8', + ) + + await patchOpencodeConfig({ + filePath: path.join(sandbox.workspace, 'opencode.json'), + manifest, + scope: 'project', + diagnostics: diagnostics.filter((entry) => entry.name === 'context7'), + }) + + const updatedConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + expect(updatedConfig.plugin).toEqual(['super-opencode-framework', null, 42, { custom: true }]) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) - test('sc-brainstorming skill exists', () => { - expect(existsSync(join(projectRoot, '.opencode/skills/sc-brainstorming/SKILL.md'))).toBe(true) + test('preserves unrelated plugin and instruction entries when adding framework requirements', async () => { + const sandbox = await createSandbox('plugin-instruction-preserved-on-add') + + try { + const manifest = await loadFrameworkManifest() + const diagnostics = await diagnoseMcpPolicies(manifest, { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + }) + + await writeFile( + path.join(sandbox.workspace, 'opencode.json'), + `{ + "$schema": "https://example.invalid/outdated-schema.json", + "plugin": [null, 42, {"custom": true}], + "instructions": [null, 7], + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "enabled": true, + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + } + } + } +} +`, + 'utf8', + ) + + await patchOpencodeConfig({ + filePath: path.join(sandbox.workspace, 'opencode.json'), + manifest, + scope: 'project', + diagnostics: diagnostics.filter((entry) => entry.name === 'context7'), + }) + + const updatedConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + expect(updatedConfig.plugin).toEqual([null, 42, { custom: true }, 'super-opencode-framework']) + expect(updatedConfig.instructions).toEqual([null, 7, '.opencode/instructions/opencode-core.md']) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) - test('sc-orchestration skill exists', () => { - expect(existsSync(join(projectRoot, '.opencode/skills/sc-orchestration/SKILL.md'))).toBe(true) + test('cli renders MCP diagnostics once in the dedicated section', async () => { + const sandbox = await createSandbox('cli-mcp-rendering') + + try { + const { stdout } = await runCli(sandbox.workspace, ['status', '--scope', 'project'], { + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + TAVILY_API_KEY: '', + MORPH_API_KEY: '', + }) + + expect(stdout).toContain('\nMCP:\n') + expect(stdout).not.toContain('- [configured but disabled by missing env] context7\n\nMCP:') + expect(stdout.match(/\[configured but disabled by missing env\] context7/g)?.length).toBe(1) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('updates unchanged managed MCP config when framework defaults change', async () => { + const sandbox = await createSandbox('mcp-managed-refresh') + + try { + await installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + }, + }) + + const manifest = await loadFrameworkManifest() + const diagnostics = await diagnoseMcpPolicies(manifest, { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + }) + const state = await readJson(path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json')) + + await patchOpencodeConfig({ + filePath: path.join(sandbox.workspace, 'opencode.json'), + manifest, + scope: 'project', + diagnostics: diagnostics + .filter((entry) => entry.name === 'context7') + .map((entry) => ({ + ...entry, + config: { + ...entry.config, + url: 'https://example.invalid/updated-context7', + }, + })), + state, + }) + + const updatedConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + expect(updatedConfig.mcp.context7.url).toBe('https://example.invalid/updated-context7') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('fails fast when top-level opencode config shapes are invalid', async () => { + const sandbox = await createSandbox('invalid-opencode-shapes') + + try { + const manifest = await loadFrameworkManifest() + const diagnostics = await diagnoseMcpPolicies(manifest, { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + }) + + await writeFile( + path.join(sandbox.workspace, 'opencode.json'), + `{ + "plugin": { "bad": true }, + "instructions": [".opencode/instructions/opencode-core.md"], + "mcp": {} +} +`, + 'utf8', + ) + + await expect( + patchOpencodeConfig({ + filePath: path.join(sandbox.workspace, 'opencode.json'), + manifest, + scope: 'project', + diagnostics: diagnostics.filter((entry) => entry.name === 'context7'), + }), + ).rejects.toThrow(/"plugin" must be an array/) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('does not claim ownership when bootstrap mutates a pre-existing MCP entry', async () => { + const sandbox = await createSandbox('mcp-preexisting-owned-after-mutation') + + try { + const manifest = await loadFrameworkManifest() + const diagnostics = await diagnoseMcpPolicies(manifest, { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + }) + + await writeFile( + path.join(sandbox.workspace, 'opencode.json'), + `{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["super-opencode-framework"], + "instructions": [".opencode/instructions/opencode-core.md"], + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + }, + "enabled": true + } + } +} +`, + 'utf8', + ) + + const result = await patchOpencodeConfig({ + filePath: path.join(sandbox.workspace, 'opencode.json'), + manifest, + scope: 'project', + diagnostics: diagnostics.filter((entry) => entry.name === 'context7'), + }) + + expect(result.addedMcpKeys).not.toContain('context7') + expect(result.addedMcpHashes.context7).toBeUndefined() + + const updatedConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + expect(updatedConfig.mcp.context7.enabled).toBe(false) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('reports conflicts instead of overwriting a user-modified managed file', async () => { + const sandbox = await createSandbox('conflict') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + const commandPath = path.join(sandbox.workspace, '.opencode', 'commands', 'sc-help.md') + const userEditedContent = '# user override\n' + await writeFile(commandPath, userEditedContent, 'utf8') + + let failure = null + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + } catch (error) { + failure = error + } + + expect(failure).not.toBeNull() + expect(failure.stdout).toContain('conflict/manual action required') + expect(await readFile(commandPath, 'utf8')).toBe(userEditedContent) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('updates a previously managed asset revision safely', async () => { + const sandbox = await createSandbox('update') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + const commandPath = path.join(sandbox.workspace, '.opencode', 'commands', 'sc-help.md') + const statePath = path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json') + const syntheticOldContent = '# old managed revision\n' + await writeFile(commandPath, syntheticOldContent, 'utf8') + + const state = await readJson(statePath) + state.files['.opencode/commands/sc-help.md'].installedHash = hashContent(syntheticOldContent) + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8') + + const report = await updateFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + expect(report.items.some((item) => item.name === '.opencode/commands/sc-help.md' && item.status === 'updated')).toBe(true) + expect(await readFile(commandPath, 'utf8')).toContain('List available `/sc-*` commands') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('preserves install state and config when uninstall hits a conflicted managed file', async () => { + const sandbox = await createSandbox('uninstall-conflict') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + const commandPath = path.join(sandbox.workspace, '.opencode', 'commands', 'sc-help.md') + const statePath = path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json') + await writeFile(commandPath, '# user modified uninstall conflict\n', 'utf8') + + const report = await uninstallFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + expect(report.items.some((item) => item.status === 'conflict/manual action required')).toBe(true) + expect(await readFile(commandPath, 'utf8')).toBe('# user modified uninstall conflict\n') + + const state = await readJson(statePath) + expect(state.files['.opencode/commands/sc-help.md']).toBeDefined() + + const opencodeConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + const tuiConfig = await readJson(path.join(sandbox.workspace, 'tui.json')) + expect(opencodeConfig.plugin).toContain('super-opencode-framework') + expect(tuiConfig.plugin).toContain('super-opencode-framework') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('preserves a user-modified framework-added MCP entry during uninstall', async () => { + const sandbox = await createSandbox('uninstall-mcp-conflict') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + TAVILY_API_KEY: '', + MORPH_API_KEY: '', + }) + + const opencodePath = path.join(sandbox.workspace, 'opencode.json') + const statePath = path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json') + const config = await readJson(opencodePath) + config.mcp.context7.url = 'https://example.invalid/custom-context7' + await writeFile(opencodePath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') + + const report = await uninstallFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + TAVILY_API_KEY: '', + MORPH_API_KEY: '', + }, + }) + + const preservedConfig = await readJson(opencodePath) + expect(preservedConfig.mcp.context7.url).toBe('https://example.invalid/custom-context7') + expect(report.items.some((item) => item.kind === 'mcp' && item.name === 'context7' && item.status === 'conflict/manual action required')).toBe(true) + + const state = await readJson(statePath) + expect(state.ownership.addedMcpKeys).toContain('context7') + expect(typeof state.ownership.addedMcpHashes.context7).toBe('string') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('does not delete pre-existing identical files during uninstall', async () => { + const sandbox = await createSandbox('uninstall-adopted-files') + + try { + // Create a pre-existing file with content that matches the framework asset + const commandPath = path.join(sandbox.workspace, '.opencode', 'commands', 'sc-agent.md') + const commandContent = await readFile(path.join(import.meta.dir, '..', '.opencode', 'commands', 'sc-agent.md'), 'utf8') + await mkdir(path.dirname(commandPath), { recursive: true }) + await writeFile(commandPath, commandContent, 'utf8') + + // Install the framework (should adopt the existing file) + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + // Verify the file is still there + expect(await readFile(commandPath, 'utf8')).toBe(commandContent) + + // Check the install state to see if the file was recorded + const statePath = path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json') + const state = await readJson(statePath) + + // Verify that the file is in the state with origin "adopted" + expect(state.files['.opencode/commands/sc-agent.md']).toBeDefined() + expect(state.files['.opencode/commands/sc-agent.md'].origin).toBe('adopted') + + // Uninstall the framework + const report = await uninstallFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + // Verify the file is still there after uninstall + expect(await readFile(commandPath, 'utf8')).toBe(commandContent) + + // Verify the report indicates the file was skipped + const skippedItem = report.items.find(item => + item.name === '.opencode/commands/sc-agent.md' && + item.status === 'skipped' + ) + expect(skippedItem).toBeDefined() + expect(skippedItem?.detail).toContain('unmanaged') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('uninstalls project scope cleanly', async () => { + const sandbox = await createSandbox('uninstall-project') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + const { stdout } = await runCli(sandbox.workspace, ['uninstall', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + expect(stdout).toContain('Action: uninstall') + + await expect(readFile(path.join(sandbox.workspace, 'opencode.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(path.join(sandbox.workspace, 'tui.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('uninstall cleans config and state even when adopted files are preserved', async () => { + const sandbox = await createSandbox('uninstall-adopted-cleanup') + + try { + const commandPath = path.join(sandbox.workspace, '.opencode', 'commands', 'sc-agent.md') + const commandContent = await readFile(path.join(import.meta.dir, '..', '.opencode', 'commands', 'sc-agent.md'), 'utf8') + await mkdir(path.dirname(commandPath), { recursive: true }) + await writeFile(commandPath, commandContent, 'utf8') + + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + const report = await uninstallFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + expect(await readFile(commandPath, 'utf8')).toBe(commandContent) + await expect(readFile(path.join(sandbox.workspace, 'opencode.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(path.join(sandbox.workspace, 'tui.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(report.items.some((item) => item.name === '.opencode/commands/sc-agent.md' && item.status === 'skipped')).toBe(true) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('uninstall preserves unrelated plugin and instruction entries while removing framework config', async () => { + const sandbox = await createSandbox('uninstall-preserves-unrelated-config') + + try { + await writeFile( + path.join(sandbox.workspace, 'opencode.json'), + `{ + "$schema": "https://example.invalid/outdated-schema.json", + "plugin": [null, 42, {"custom": true}], + "instructions": [null, 7] +} +`, + 'utf8', + ) + await writeFile( + path.join(sandbox.workspace, 'tui.json'), + `{ + "$schema": "https://example.invalid/outdated-schema.json", + "plugin": [null, 42, {"custom": true}] +} +`, + 'utf8', + ) + + await installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + const report = await uninstallFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + const opencodeConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + const tuiConfig = await readJson(path.join(sandbox.workspace, 'tui.json')) + + expect(opencodeConfig.plugin).toEqual([null, 42, { custom: true }]) + expect(opencodeConfig.instructions).toEqual([null, 7]) + expect(tuiConfig.plugin).toEqual([null, 42, { custom: true }]) + expect(report.items.some((item) => item.kind === 'config' && item.status === 'updated')).toBe(true) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('fails uninstall without rewriting malformed owned plugin arrays', async () => { + const sandbox = await createSandbox('uninstall-malformed-plugin-array') + + try { + await installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + const opencodePath = path.join(sandbox.workspace, 'opencode.json') + const malformedConfig = `{ + "$schema": "https://opencode.ai/config.json", + "plugin": { "broken": true }, + "instructions": [".opencode/instructions/opencode-core.md"], + "mcp": {} +} +` + await writeFile(opencodePath, malformedConfig, 'utf8') + + await expect( + uninstallFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }), + ).rejects.toThrow(/"plugin" must be an array/) + + expect(await readFile(opencodePath, 'utf8')).toBe(malformedConfig) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('fails uninstall without rewriting malformed owned instruction arrays', async () => { + const sandbox = await createSandbox('uninstall-malformed-instruction-array') + + try { + await installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + const opencodePath = path.join(sandbox.workspace, 'opencode.json') + const malformedConfig = `{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["super-opencode-framework"], + "instructions": { "broken": true }, + "mcp": {} +} +` + await writeFile(opencodePath, malformedConfig, 'utf8') + + await expect( + uninstallFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }), + ).rejects.toThrow(/"instructions" must be an array/) + + expect(await readFile(opencodePath, 'utf8')).toBe(malformedConfig) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('fails install without rewriting an invalid opencode.json', async () => { + const sandbox = await createSandbox('invalid-opencode-install') + + try { + const opencodePath = path.join(sandbox.workspace, 'opencode.json') + const invalidJsonc = '{\n "instructions": ["docs/local.md"\n}\n' + await writeFile(opencodePath, invalidJsonc, 'utf8') + + await expect( + installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }), + ).rejects.toThrow(/Invalid JSONC/) + + expect(await readFile(opencodePath, 'utf8')).toBe(invalidJsonc) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('fails install without rewriting an invalid tui.json', async () => { + const sandbox = await createSandbox('invalid-tui-install') + + try { + const tuiPath = path.join(sandbox.workspace, 'tui.json') + const invalidJsonc = '{\n "plugin": ["super-opencode-framework",\n}\n' + await writeFile(tuiPath, invalidJsonc, 'utf8') + + await expect( + installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }), + ).rejects.toThrow(/Invalid JSONC/) + + expect(await readFile(tuiPath, 'utf8')).toBe(invalidJsonc) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('update refreshes stale schema values in config files', async () => { + const sandbox = await createSandbox('schema-refresh') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + const opencodePath = path.join(sandbox.workspace, 'opencode.json') + const tuiPath = path.join(sandbox.workspace, 'tui.json') + const opencodeConfig = await readJson(opencodePath) + const tuiConfig = await readJson(tuiPath) + opencodeConfig.$schema = 'https://example.invalid/old-opencode-schema.json' + tuiConfig.$schema = 'https://example.invalid/old-tui-schema.json' + await writeFile(opencodePath, `${JSON.stringify(opencodeConfig, null, 2)}\n`, 'utf8') + await writeFile(tuiPath, `${JSON.stringify(tuiConfig, null, 2)}\n`, 'utf8') + + const report = await updateFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + const refreshedOpencode = await readJson(opencodePath) + const refreshedTui = await readJson(tuiPath) + expect(refreshedOpencode.$schema).not.toBe('https://example.invalid/old-opencode-schema.json') + expect(refreshedTui.$schema).not.toBe('https://example.invalid/old-tui-schema.json') + expect(report.items.some((item) => item.name === 'opencode.json' && item.status === 'updated')).toBe(true) + expect(report.items.some((item) => item.name === 'tui.json' && item.status === 'updated')).toBe(true) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) }) -describe('Plugin', () => { - test('plugin main file exists', () => { - expect(existsSync(join(projectRoot, '.opencode/plugins/super-opencode.ts'))).toBe(true) +describe('MCP diagnostics', () => { + test('marks env-backed MCPs disabled when secrets are absent', async () => { + const sandbox = await createSandbox('mcp-missing-env') + + try { + const report = await installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + TAVILY_API_KEY: '', + MORPH_API_KEY: '', + }, + }) + + expect(report.mcp.find((entry) => entry.name === 'context7')?.status).toBe('configured but disabled by missing env') + expect(report.mcp.find((entry) => entry.name === 'tavily')?.status).toBe('configured but disabled by missing env') + expect(report.mcp.find((entry) => entry.name === 'morph')?.status).toBe('configured but disabled by missing env') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) - test('plugin modules exist', () => { - expect(existsSync(join(projectRoot, '.opencode/plugins/super-opencode/memory.ts'))).toBe(true) - expect(existsSync(join(projectRoot, '.opencode/plugins/super-opencode/system.ts'))).toBe(true) - expect(existsSync(join(projectRoot, '.opencode/plugins/super-opencode/commands.ts'))).toBe(true) + test('re-enables env-backed MCPs on update once prerequisites appear', async () => { + const sandbox = await createSandbox('mcp-reenable') + + try { + await installFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: '', + TAVILY_API_KEY: '', + MORPH_API_KEY: '', + }, + }) + + const firstConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + expect(firstConfig.mcp.context7.enabled).toBe(false) + + const report = await updateFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + TAVILY_API_KEY: 'token', + MORPH_API_KEY: 'token', + }, + }) + + expect(report.mcp.find((entry) => entry.name === 'context7')?.status).toBe('configured and enabled') + + const updatedConfig = await readJson(path.join(sandbox.workspace, 'opencode.json')) + expect(updatedConfig.mcp.context7.enabled).toBe(true) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('marks binary-backed MCPs disabled when binaries are unavailable', async () => { + const sandbox = await createSandbox('mcp-missing-bin') + + try { + const report = await statusFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { + PATH: '', + PATHEXT: process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM', + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + CONTEXT7_API_KEY: 'token', + TAVILY_API_KEY: 'token', + MORPH_API_KEY: 'token', + }, + }) + + expect(report.mcp.find((entry) => entry.name === 'serena')?.status).toBe('configured but disabled by missing binary') + expect(report.mcp.find((entry) => entry.name === 'sequential')?.status).toBe('configured but disabled by missing binary') + expect(report.mcp.find((entry) => entry.name === 'playwright')?.status).toBe('configured but disabled by missing binary') + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('status reports missing assets truthfully in a clean workspace', async () => { + const sandbox = await createSandbox('status-clean-workspace') + + try { + const report = await statusFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: sandbox.globalConfigDir, + }, + }) + + const missingAssetItems = report.items.filter( + (item) => item.kind === 'asset' && item.detail === 'Asset is not installed in this scope.', + ) + + expect(missingAssetItems.length).toBeGreaterThan(0) + expect(missingAssetItems.every((item) => item.status === 'skipped')).toBe(true) + expect(report.items.some((item) => item.status === 'installed')).toBe(false) + expect(report.items.some((item) => item.kind === 'config' && item.name === 'opencode.json' && item.status === 'config-drift')).toBe(true) + expect(report.items.some((item) => item.kind === 'config' && item.name === 'tui.json' && item.status === 'config-drift')).toBe(true) + expect(report.items.some((item) => item.kind === 'runtime' && item.name === 'OpenCode runtime' && item.status === 'config-drift')).toBe(true) + await expect( + readFile(path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json'), 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('status reports outdated managed assets without claiming they were updated', async () => { + const sandbox = await createSandbox('status-outdated') + + try { + await runCli(sandbox.workspace, ['install', '--scope', 'project'], { OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }) + + const commandPath = path.join(sandbox.workspace, '.opencode', 'commands', 'sc-help.md') + const statePath = path.join(sandbox.workspace, '.opencode', 'super-opencode', 'install-state.json') + const previousManagedContent = '# old managed revision\n' + await writeFile(commandPath, previousManagedContent, 'utf8') + + const state = await readJson(statePath) + state.files['.opencode/commands/sc-help.md'].installedHash = hashContent(previousManagedContent) + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8') + + const report = await statusFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + const assetItem = report.items.find((item) => item.name === '.opencode/commands/sc-help.md') + expect(assetItem?.status).toBe('skipped') + expect(assetItem?.detail).toBe('Asset is outdated and would update on install/update.') + expect(report.restartRequired).toBe(false) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } + }) + + test('status reports invalid config files without attempting bootstrap', async () => { + const sandbox = await createSandbox('status-invalid-configs') + + try { + await writeFile(path.join(sandbox.workspace, 'opencode.json'), '{\n "instructions": ["broken"\n}\n', 'utf8') + await writeFile(path.join(sandbox.workspace, 'tui.json'), '{\n "plugin": ["broken"\n}\n', 'utf8') + + const report = await statusFramework({ + scope: 'project', + projectRoot: sandbox.workspace, + env: { ...process.env, OPENCODE_CONFIG_DIR: sandbox.globalConfigDir }, + }) + + expect(report.items.some((item) => item.kind === 'config' && item.name === 'opencode.json' && item.status === 'invalid-config')).toBe(true) + expect(report.items.some((item) => item.kind === 'config' && item.name === 'tui.json' && item.status === 'invalid-config')).toBe(true) + expect(report.items.some((item) => item.kind === 'runtime' && item.name === 'OpenCode runtime' && item.status === 'invalid-config')).toBe(true) + } finally { + await rm(sandbox.root, { recursive: true, force: true }) + } }) }) diff --git a/tests/plugin-hooks.test.mjs b/tests/plugin-hooks.test.mjs index a9d37fd..3dfccd0 100644 --- a/tests/plugin-hooks.test.mjs +++ b/tests/plugin-hooks.test.mjs @@ -1,52 +1,97 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { createCommandHooks } from '../.opencode/plugins/super-opencode/commands.ts' -import { createCompactionHooks } from '../.opencode/plugins/super-opencode/compaction.ts' -import { commandPersistenceHint, persistenceContract } from '../.opencode/plugins/super-opencode/memory.ts' -import { createSystemHooks } from '../.opencode/plugins/super-opencode/system.ts' +import { createCommandHooks, createCompactionHooks, createSystemHooks } from '../src/runtime/hooks.ts' +import { commandPersistenceHint, persistenceContract } from '../src/runtime/memory.ts' +import { SuperOpenCodePlugin } from '../src/runtime/plugin.ts' -describe('Super OpenCode plugin hooks', () => { - test('system hook injects the persistence contract', async () => { +const runtimeLoadMarker = Symbol.for('super-opencode.runtime-loaded') + +beforeEach(() => { + delete globalThis[runtimeLoadMarker] +}) + +afterEach(() => { + delete globalThis[runtimeLoadMarker] +}) + +describe('Super OpenCode runtime hooks', () => { + test('system hook injects the persistence contract only once', async () => { const hooks = createSystemHooks() const output = { system: [] } + await hooks['experimental.chat.system.transform']({}, output) await hooks['experimental.chat.system.transform']({}, output) - expect(output.system).toContain(persistenceContract) + expect(output.system).toEqual([persistenceContract]) }) - test('command hook adds only the persistence hint for sc-save', async () => { + test('command hook deduplicates the persistence hint', async () => { const hooks = createCommandHooks() const output = { parts: [] } + await hooks['command.execute.before']({ command: '/sc-save', sessionID: 'session-1' }, output) await hooks['command.execute.before']({ command: '/sc-save', sessionID: 'session-1' }, output) expect(output.parts).toHaveLength(1) expect(output.parts[0].text).toBe(commandPersistenceHint) }) - test('command hook stays silent for unrelated commands', async () => { - const hooks = createCommandHooks() - const output = { parts: [] } - - await hooks['command.execute.before']({ command: '/sc-implement', sessionID: 'session-2' }, output) - - expect(output.parts).toHaveLength(0) - }) - - test('compaction hook injects persistence guidance', async () => { + test('compaction hook deduplicates persistence guidance', async () => { const worktree = mkdtempSync(path.join(tmpdir(), 'super-opencode-')) - const hooks = createCompactionHooks(worktree) const output = { context: [] } + await hooks['experimental.session.compacting']({}, output) await hooks['experimental.session.compacting']({}, output) expect(output.context).toHaveLength(1) expect(output.context[0]).toContain('## Super OpenCode Memory') expect(output.context[0]).toContain('Serena is the persistence source of truth') }) + + test('runtime plugin only registers hooks once per process', async () => { + const logs = [] + const client = { + app: { + log: async (entry) => { + logs.push(entry.body.message) + }, + }, + } + + const first = await SuperOpenCodePlugin({ client, worktree: 'D:/repo' }) + const second = await SuperOpenCodePlugin({ client, worktree: 'D:/repo2' }) + const compactionOutput = { context: [] } + + await second['experimental.session.compacting']({}, compactionOutput) + + expect(typeof first['experimental.chat.system.transform']).toBe('function') + expect(typeof second['experimental.session.compacting']).toBe('function') + expect(second['experimental.chat.system.transform']).toBeUndefined() + expect(compactionOutput.context).toHaveLength(1) + expect(compactionOutput.context[0]).toContain('Worktree: D:/repo2') + expect(logs).toEqual([ + 'Super OpenCode plugin initialized', + 'Super OpenCode runtime already active, skipping duplicate hook registration', + ]) + }) + + test('runtime plugin still initializes when logging fails', async () => { + const client = { + app: { + log: async () => { + throw new Error('log unavailable') + }, + }, + } + + const hooks = await SuperOpenCodePlugin({ client, worktree: 'D:/repo' }) + + expect(typeof hooks['experimental.chat.system.transform']).toBe('function') + expect(typeof hooks['command.execute.before']).toBe('function') + expect(typeof hooks['experimental.session.compacting']).toBe('function') + }) }) diff --git a/tsconfig.build.json b/tsconfig.build.json index f7d02be..56b12e4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -9,6 +9,8 @@ "rootDir": "." }, "include": [ + "src/**/*.ts", + "src/**/*.tsx", ".opencode/plugins/**/*.ts" ] } diff --git a/tsconfig.json b/tsconfig.json index bf660ec..2c9c19e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "jsxImportSource": "@opentui/solid", "strict": true, "noEmit": true, "skipLibCheck": true, @@ -12,6 +14,8 @@ "types": ["node"] }, "include": [ + "src/**/*.ts", + "src/**/*.tsx", ".opencode/**/*.ts", "scripts/**/*.ts" ]