diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 555a92a0aed..a6e87755dca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,16 +63,12 @@ jobs: # Individual files that use mock.module and cause cross-file pollution bun test tests/tools/delegate-agent/sync-executor.test.ts bun test tests/tools/delegate-agent/session-creator.test.ts - bun test tests/features/opencode-skill-loader/loader.test.ts - bun test tests/features/opencode-skill-loader/agents-skills-global.test.ts bun test tests/tools/session-manager/storage.test.ts - bun test tests/hooks/prometheus-md-only/index.test.ts bun test tests/hooks/architect/index.test.ts bun test tests/hooks/matrix-loop/index.test.ts bun test tests/hooks/start-work/index.test.ts bun test tests/hooks/auto-update-checker/hook/background-update-check.test.ts bun test tests/hooks/auto-update-checker/hook.test.ts - bun test tests/features/skill-mcp-manager/manager.test.ts bun test tests/features/background-agent/manager.test.ts bun test tests/hooks/comment-checker/cli.test.ts bun test tests/hooks/comment-checker/hook.apply-patch.test.ts @@ -82,6 +78,7 @@ jobs: bun test tests/hooks/compaction-todo-preserver/index.test.ts bun test tests/hooks/preemptive-compaction.test.ts bun test tests/tools/lsp/client.test.ts + bun test tests/tools/lsp/lsp-process.test.ts bun test tests/tools/skill/tools.test.ts bun test tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts bun test tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts @@ -95,7 +92,12 @@ jobs: bun test tests/agents/utils.test.ts bun test tests/hooks/task-notepad/hook.test.ts bun test tests/plugin/tool-execute-before.test.ts - bun test tests/features/mission-state/plan-storage.test.ts + bun test tests/features/mission-state/ + bun test tests/features/handoff/ + bun test tests/features/task-storage/ + bun test tests/hooks/session-recovery/ + bun test tests/plugin/session-agent-resolver.test.ts + bun test tests/hooks/auto-update-checker/checker/pinned-version-updater.test.ts - name: Run remaining tests run: | @@ -108,16 +110,12 @@ jobs: -e 'tests/features/tmux-subagent/' \ -e 'tests/tools/delegate-agent/sync-executor.test.ts' \ -e 'tests/tools/delegate-agent/session-creator.test.ts' \ - -e 'tests/features/opencode-skill-loader/loader.test.ts' \ - -e 'tests/features/opencode-skill-loader/agents-skills-global.test.ts' \ -e 'tests/tools/session-manager/storage.test.ts' \ - -e 'tests/hooks/prometheus-md-only/index.test.ts' \ -e 'tests/hooks/architect/index.test.ts' \ -e 'tests/hooks/matrix-loop/index.test.ts' \ -e 'tests/hooks/start-work/index.test.ts' \ -e 'tests/hooks/auto-update-checker/hook/background-update-check.test.ts' \ -e 'tests/hooks/auto-update-checker/hook.test.ts' \ - -e 'tests/features/skill-mcp-manager/manager.test.ts' \ -e 'tests/features/background-agent/manager.test.ts' \ -e 'tests/hooks/comment-checker/cli.test.ts' \ -e 'tests/hooks/comment-checker/hook.apply-patch.test.ts' \ @@ -127,6 +125,7 @@ jobs: -e 'tests/hooks/compaction-todo-preserver/index.test.ts' \ -e 'tests/hooks/preemptive-compaction.test.ts' \ -e 'tests/tools/lsp/client.test.ts' \ + -e 'tests/tools/lsp/lsp-process.test.ts' \ -e 'tests/tools/skill/tools.test.ts' \ -e 'tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts' \ -e 'tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts' \ @@ -137,7 +136,12 @@ jobs: -e 'tests/tools/bdd-parse-gherkin/tools.test.ts' \ -e 'tests/tools/bdd-pipeline/pipeline-runner.test.ts' \ -e 'tests/plugin/tool-execute-before.test.ts' \ - -e 'tests/features/mission-state/plan-storage.test.ts' \ + -e 'tests/features/mission-state/' \ + -e 'tests/features/handoff/' \ + -e 'tests/features/task-storage/' \ + -e 'tests/hooks/session-recovery/' \ + -e 'tests/plugin/session-agent-resolver.test.ts' \ + -e 'tests/hooks/auto-update-checker/checker/pinned-version-updater.test.ts' \ | xargs bun test typecheck: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd8454b050b..c4d3930f2f0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,140 +42,98 @@ jobs: - name: Run mock-heavy tests (isolated) run: | - # These files use mock.module() which pollutes the module cache. - # Each runs in its own bun test invocation to prevent cross-contamination. - - # --- Plugin handlers & original isolated --- + # These files use mock.module() which pollutes bun's module cache + # when run in parallel. Each invocation runs in a separate process. + # + # Directories (all test files within run together, isolated from other dirs) bun test tests/plugin-handlers bun test tests/hooks/compaction-context-injector bun test tests/features/tmux-subagent + # + # Individual files that use mock.module and cause cross-file pollution bun test tests/tools/delegate-agent/sync-executor.test.ts bun test tests/tools/delegate-agent/session-creator.test.ts - bun test tests/features/opencode-skill-loader/loader.test.ts - - # --- Hook directories that use mock.module() --- - bun test tests/hooks/architect - bun test tests/hooks/comment-checker - bun test tests/hooks/compaction-todo-preserver - bun test tests/hooks/directory-agents-injector - bun test tests/hooks/directory-readme-injector - bun test tests/hooks/prometheus-md-only - bun test tests/hooks/rules-injector - - # --- Non-hook files that use mock.module() --- - bun test tests/shared/opencode-message-dir.test.ts + bun test tests/tools/session-manager/storage.test.ts + bun test tests/hooks/architect/index.test.ts + bun test tests/hooks/matrix-loop/index.test.ts + bun test tests/hooks/start-work/index.test.ts + bun test tests/hooks/auto-update-checker/hook/background-update-check.test.ts + bun test tests/hooks/auto-update-checker/hook.test.ts bun test tests/features/background-agent/manager.test.ts + bun test tests/hooks/comment-checker/cli.test.ts + bun test tests/hooks/comment-checker/hook.apply-patch.test.ts + bun test tests/hooks/directory-agents-injector/injector.test.ts + bun test tests/hooks/directory-readme-injector/injector.test.ts + bun test tests/hooks/rules-injector/injector.test.ts + bun test tests/hooks/compaction-todo-preserver/index.test.ts + bun test tests/hooks/preemptive-compaction.test.ts + bun test tests/tools/lsp/client.test.ts + bun test tests/tools/lsp/lsp-process.test.ts + bun test tests/tools/skill/tools.test.ts + bun test tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts + bun test tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts + bun test tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts bun test tests/tools/bdd-create-contract/tools.test.ts bun test tests/tools/bdd-parse-gherkin/tools.test.ts bun test tests/tools/bdd-pipeline/pipeline-runner.test.ts + # spyOn() leaks from earlier tests pollute these (pass in isolation, fail + # in batch) + bun test tests/agents/utils.test.ts + bun test tests/hooks/task-notepad/hook.test.ts bun test tests/plugin/tool-execute-before.test.ts - bun test tests/features/mission-state/plan-storage.test.ts - # OPENCODE_CONFIG_DIR env var pollution — must run isolated to prevent - # parallel test interference from other files that modify this global var + bun test tests/features/mission-state/ + bun test tests/features/handoff/ + bun test tests/features/task-storage/ + bun test tests/hooks/session-recovery/ + bun test tests/plugin/session-agent-resolver.test.ts + bun test tests/hooks/auto-update-checker/checker/pinned-version-updater.test.ts + bun test tests/shared/opencode-message-dir.test.ts - name: Run remaining tests run: | - # Only pure (non-mock.module) test files/dirs run together here. - # Excluded: mock.module files (run isolated above), pre-existing failures. - # Pre-existing failures excluded: - # src/shared/model-requirements.test.ts (stale model expectations) - # src/hooks/anthropic-context-window-limit-recovery (CI-only failures, bun version) - # src/hooks/todo-continuation-enforcer (7 fail) - # src/hooks/auto-update-checker (4 fail) - # src/hooks/context-window-monitor.test.ts (2 fail) - # src/hooks/preemptive-compaction.test.ts (3 fail) - # src/agents/utils.test.ts (CI-only failures: stale model expectations after merovingian model refactor; passes locally) - bun test tests/config tests/mcp tests/index.test.ts \ - tests/agents/builtin-agents/resolve-file-uri.test.ts \ - tests/agents/cipher.test.ts \ - tests/agents/dynamic-agent-prompt-builder.test.ts \ - tests/agents/momus.test.ts \ - tests/agents/mouse/index.test.ts \ - tests/agents/oracle-prompt.test.ts \ - tests/agents/types.test.ts \ - tests/shared/agent-config-integration.test.ts \ - tests/shared/agent-display-names.test.ts \ - tests/shared/agent-variant.test.ts \ - tests/shared/connected-providers-cache.test.ts \ - tests/shared/deep-merge.test.ts \ - tests/shared/external-plugin-detector.test.ts \ - tests/shared/file-utils.test.ts \ - tests/shared/first-message-variant.test.ts \ - tests/shared/frontmatter.test.ts \ - tests/shared/git-worktree \ - tests/shared/jsonc-parser.test.ts \ - tests/shared/merge-categories.test.ts \ - tests/shared/migration.test.ts \ - tests/shared/model-availability.test.ts \ - tests/shared/model-resolver.test.ts \ - tests/shared/model-suggestion-retry.test.ts \ - tests/shared/normalize-sdk-response.test.ts \ - tests/shared/opencode-config-dir.test.ts \ - tests/shared/opencode-http-api.test.ts \ - tests/shared/opencode-server-auth.test.ts \ - tests/shared/opencode-storage-detection.test.ts \ - tests/shared/opencode-version.test.ts \ - tests/shared/permission-compat.test.ts \ - tests/shared/port-utils.test.ts \ - tests/shared/safe-create-hook.test.ts \ - tests/shared/session-cursor.test.ts \ - tests/shared/session-directory-resolver.test.ts \ - tests/shared/session-tools-store.test.ts \ - tests/shared/skill-path-resolver.test.ts \ - tests/shared/system-directive.test.ts \ - tests/shared/tmux \ - tests/shared/truncate-description.test.ts \ - tests/tools/ast-grep tests/tools/background-task tests/tools/delegate-task \ - tests/tools/glob tests/tools/grep tests/tools/hashline-edit tests/tools/interactive-bash \ - tests/tools/look-at \ - tests/tools/lsp/lsp-process.test.ts \ - tests/tools/lsp/server-config-loader.test.ts \ - tests/tools/lsp/utils.test.ts \ - tests/tools/lsp/config.test.ts \ - tests/tools/session-manager/tools.test.ts \ - tests/tools/session-manager/utils.test.ts \ - tests/tools/skill-mcp tests/tools/slashcommand tests/tools/task \ - tests/tools/delegate-agent/background-agent-executor.test.ts \ - tests/tools/delegate-agent/background-executor.test.ts \ - tests/tools/delegate-agent/subagent-session-creator.test.ts \ - tests/hooks/anthropic-effort \ - tests/hooks/auto-slash-command \ - tests/hooks/category-skill-reminder \ - tests/hooks/delegate-task-retry \ - tests/hooks/edit-error-recovery \ - tests/hooks/env-file-write-guard \ - tests/hooks/handoff-injector \ - tests/hooks/hashline-read-enhancer \ - tests/hooks/keyword-detector \ - tests/hooks/matrix-loop \ - tests/hooks/non-interactive-env \ - tests/hooks/question-label-truncator \ - tests/hooks/secret-leak-guard \ - tests/hooks/session-recovery \ - tests/hooks/start-work \ - tests/hooks/stop-continuation-guard \ - tests/hooks/task-reminder \ - tests/hooks/task-resume-info \ - tests/hooks/tasks-todowrite-disabler \ - tests/hooks/think-mode \ - tests/hooks/unstable-agent-babysitter \ - tests/hooks/write-existing-file-guard \ - tests/hooks/session-notification.test.ts \ - tests/hooks/tool-output-truncator.test.ts \ - tests/features/background-agent/manager.polling.test.ts \ - tests/features/background-agent/task-poller.test.ts \ - tests/features/background-agent/parent-session-notifier.test.ts \ - tests/features/background-agent/concurrency.test.ts \ - tests/features/background-agent/task-history.test.ts \ - tests/features/builtin-commands \ - tests/features/builtin-skills \ - tests/features/session-state \ - tests/features/hook-message-injector \ - tests/features/opencode-skill-loader/config-source-discovery.test.ts \ - tests/features/opencode-skill-loader/merger.test.ts \ - tests/features/opencode-skill-loader/skill-content.test.ts \ - tests/features/opencode-skill-loader/blocking.test.ts \ - tests/features/skill-mcp-manager/env-cleaner.test.ts + # Run all other tests together. The mock-heavy files above are excluded + # via --exclude patterns to prevent double-running and pollution. + find tests script -name '*.test.ts' -type f \ + | grep -v -F \ + -e 'tests/plugin-handlers/' \ + -e 'tests/hooks/compaction-context-injector/' \ + -e 'tests/features/tmux-subagent/' \ + -e 'tests/tools/delegate-agent/sync-executor.test.ts' \ + -e 'tests/tools/delegate-agent/session-creator.test.ts' \ + -e 'tests/tools/session-manager/storage.test.ts' \ + -e 'tests/hooks/architect/index.test.ts' \ + -e 'tests/hooks/matrix-loop/index.test.ts' \ + -e 'tests/hooks/start-work/index.test.ts' \ + -e 'tests/hooks/auto-update-checker/hook/background-update-check.test.ts' \ + -e 'tests/hooks/auto-update-checker/hook.test.ts' \ + -e 'tests/features/background-agent/manager.test.ts' \ + -e 'tests/hooks/comment-checker/cli.test.ts' \ + -e 'tests/hooks/comment-checker/hook.apply-patch.test.ts' \ + -e 'tests/hooks/directory-agents-injector/injector.test.ts' \ + -e 'tests/hooks/directory-readme-injector/injector.test.ts' \ + -e 'tests/hooks/rules-injector/injector.test.ts' \ + -e 'tests/hooks/compaction-todo-preserver/index.test.ts' \ + -e 'tests/hooks/preemptive-compaction.test.ts' \ + -e 'tests/tools/lsp/client.test.ts' \ + -e 'tests/tools/lsp/lsp-process.test.ts' \ + -e 'tests/tools/skill/tools.test.ts' \ + -e 'tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts' \ + -e 'tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts' \ + -e 'tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts' \ + -e 'tests/agents/utils.test.ts' \ + -e 'tests/hooks/task-notepad/hook.test.ts' \ + -e 'tests/tools/bdd-create-contract/tools.test.ts' \ + -e 'tests/tools/bdd-parse-gherkin/tools.test.ts' \ + -e 'tests/tools/bdd-pipeline/pipeline-runner.test.ts' \ + -e 'tests/plugin/tool-execute-before.test.ts' \ + -e 'tests/features/mission-state/' \ + -e 'tests/features/handoff/' \ + -e 'tests/features/task-storage/' \ + -e 'tests/hooks/session-recovery/' \ + -e 'tests/plugin/session-agent-resolver.test.ts' \ + -e 'tests/hooks/auto-update-checker/checker/pinned-version-updater.test.ts' \ + -e 'tests/shared/opencode-message-dir.test.ts' \ + | xargs bun test typecheck: runs-on: ubuntu-latest diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 00000000000..c997c0309f7 --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "list" + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1c4ce6187ce..0ea5dc68539 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ matrixx/ │ ├── agents/ → 14 agents (incl. Sati, Cipher, Sentinel) + AGENTS.md │ ├── hooks/ → ~52 hooks in 3 tiers │ ├── tools/ → 16 dirs (LSP, AST-grep, delegate-task, session, handoff, glob, grep, look-at, delegate-agent, background-task, interactive-bash, hashline-edit, skill, skill-mcp, slashcommand, task) -│ ├── features/ → 20 dirs (background-agent, skills, commands, tasks, tmux, MCP, oauth, handoff, CC compat) +│ ├── features/ → 19 dirs (background-agent, skills, commands, tasks, tmux, handoff, CC compat) │ ├── shared/ → 80+ utilities │ ├── mcp/ → 4 built-in MCPs (websearch, context7, grep_app, document-reader) │ ├── cli/ → CLI installer, doctor, config-manager @@ -309,11 +309,10 @@ For full model/temp/fallback details see the per-agent file or `src/agents/AGENT - JSONC: comments + trailing commas via `jsonc-parser` (use `src/shared/jsonc-parser.ts`, not raw `JSON.parse`) - Legacy config auto-migrated by `src/shared/migration/` (agent names, hook names, model versions) -## MCP ARCHITECTURE (3 tiers) +## MCP ARCHITECTURE (2 tiers) -1. **Built-in** (`src/mcp/`): websearch (Exa/Tavily), context7, grep_app, document-reader -2. **Claude Code compat** (`features/claude-code-mcp-loader/`): `.mcp.json` with `${VAR}` expansion -3. **Skill-embedded** (`features/opencode-skill-loader/`): YAML frontmatter in `SKILL.md` +1. **Built-in** (`src/mcp/`): websearch, context7, grep_app, document-reader +2. **Plugin-config / user-configured**: MCPs defined in plugin configuration ## KNOWN HOTSPOTS (largest files) diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md new file mode 100644 index 00000000000..96937ef1005 --- /dev/null +++ b/MIGRATION_PLAN.md @@ -0,0 +1,872 @@ +# Loader Removal & Simplification Plan + +## Executive Summary + +Remove all local directory loaders from Matrixx. Built-in skills/commands/agents/MCPs will be registered directly via OpenCode's `config` hook. This eliminates ~7,169 LOC across 42 files and removes Claude Code compatibility layers. + +**What we're removing:** +- `src/features/opencode-skill-loader/` (17 files, ~2850 LOC) — loads skills from `.opencode/skills/`, `.claude/skills/`, `.agents/skills/`, and config sources +- `src/features/command-loader/` (3 files, ~300 LOC) — loads commands from `.opencode/command/` +- `src/features/agent-loader/` (3 files, ~200 LOC) — loads agents from `.opencode/agents/` +- `src/features/mcp-oauth/` (7 files, ~2050 LOC) — OAuth 2.0 for MCP servers +- `src/features/skill-mcp-manager/` (12 files, ~1769 LOC) — MCP client lifecycle per session + +**What we're keeping:** +- `src/features/builtin-skills/` — 45 built-in skills (code, not loaded from disk) +- `src/features/builtin-commands/` — built-in commands (code, not loaded from disk) +- `src/agents/` — 14 built-in agents (code, not loaded from disk) +- `src/mcp/` — 4 built-in MCPs (code, not loaded from disk) + +--- + +## Current Architecture + +### Skill Loading Pipeline + +``` +createSkillContext() [src/plugin/skill-context.ts] + ├─ discoverConfigSourceSkills() ← matrixx.jsonc skills.sources paths + ├─ discoverOpencodeGlobalSkills() ← ~/.config/opencode/skills/ + ├─ discoverOpencodeProjectSkills() ← .opencode/skills/ + ├─ discoverProjectAgentsSkills() ← .agents/skills/ (project) + ├─ discoverGlobalAgentsSkills() ← ~/.agents/skills/ (global) + ├─ createBuiltinSkills() ← 45 built-in skills (KEEP) + └─ mergeSkills() ← priority-based merging + ↓ + mergedSkills: LoadedSkill[] + ↓ + ├─ createToolRegistry() ← skill tool, slashcommand tool + ├─ command-config-handler.ts ← registered as commands + ├─ agent-config-handler.ts ← agent configs with skill awareness + └─ auto-slash-command hook ← auto-detection +``` + +### Command Loading Pipeline + +``` +applyCommandConfig() [src/plugin-handlers/command-config-handler.ts] + ├─ loadBuiltinCommands() ← Matrixx built-in commands (KEEP) + ├─ loadOpencodeGlobalCommands() ← ~/.config/opencode/command/ + ├─ loadOpencodeProjectCommands() ← .opencode/command/ + ├─ skillsToCommandDefinitionRecord() ← skills converted to commands + └─ pluginComponents.commands/skills ← (currently empty) + ↓ + params.config.command = { ...merged } +``` + +### Agent Loading Pipeline + +``` +applyAgentConfig() [src/plugin-handlers/agent-config-handler.ts] + ├─ createBuiltinAgents() ← 14 built-in agents (KEEP) + ├─ createMouseAgentWithOverrides() ← Mouse agent (KEEP) + ├─ loadUserAgents() ← ~/.config/opencode/agents/ + ├─ loadProjectAgents() ← .opencode/agents/ + └─ pluginComponents.agents ← (currently empty) + ↓ + params.config.agent = { ...merged } +``` + +### MCP Loading Pipeline + +``` +applyMcpConfig() [src/plugin-handlers/mcp-config-handler.ts] + ├─ createBuiltinMcps() ← 4 built-in MCPs (KEEP) + ├─ userMcp ← user config + └─ pluginComponents.mcpServers ← (currently empty) + ↓ + params.config.mcp = { ...merged } + +SkillMcpManager [src/features/skill-mcp-manager/] + └─ Manages MCP lifecycle for skills with embedded MCPs +``` + +--- + +## Target Architecture + +### Skill Loading Pipeline (Simplified) + +``` +createSkillContext() [src/plugin/skill-context.ts] + ├─ createBuiltinSkills() ← 45 built-in skills + └─ filterDisabledSkills() ← remove disabled_skills + ↓ + builtinSkills: BuiltinSkill[] + ↓ + ├─ createToolRegistry() ← skill tool, slashcommand tool + ├─ command-config-handler.ts ← registered as commands + ├─ agent-config-handler.ts ← agent configs with skill awareness + └─ auto-slash-command hook ← auto-detection +``` + +### Command Loading Pipeline (Simplified) + +``` +applyCommandConfig() [src/plugin-handlers/command-config-handler.ts] + ├─ loadBuiltinCommands() ← Matrixx built-in commands + └─ builtinSkillsToCommands() ← built-in skills as commands + ↓ + params.config.command = { ...merged } +``` + +### Agent Loading Pipeline (Simplified) + +``` +applyAgentConfig() [src/plugin-handlers/agent-config-handler.ts] + ├─ createBuiltinAgents() ← 14 built-in agents + └─ createMouseAgentWithOverrides() ← Mouse agent + ↓ + params.config.agent = { ...merged } +``` + +### MCP Loading Pipeline (Simplified) + +``` +applyMcpConfig() [src/plugin-handlers/mcp-config-handler.ts] + ├─ createBuiltinMcps() ← 4 built-in MCPs + └─ userMcp ← user config + ↓ + params.config.mcp = { ...merged } + +[SkillMcpManager removed — OpenCode handles MCP lifecycle] +``` + +--- + +## Phase 1: Simplify Skill Context + +### Objectives +- Remove all discovery functions from `opencode-skill-loader` +- `createSkillContext()` should only use `createBuiltinSkills()` +- Remove `mergeSkills()` — no longer needed + +### Tasks + +1. **Refactor `src/plugin/skill-context.ts`** + - Remove imports: `discoverConfigSourceSkills`, `discoverGlobalAgentsSkills`, `discoverOpencodeGlobalSkills`, `discoverOpencodeProjectSkills`, `discoverProjectAgentsSkills`, `mergeSkills` + - Remove `SkillScope` type (no longer needed) + - Simplify `createSkillContext()`: + ```typescript + export async function createSkillContext(args: { + directory: string + pluginConfig: MatrixxConfig + }): Promise { + const { pluginConfig } = args + + const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright" + const disabledSkills = new Set(pluginConfig.disabled_skills ?? []) + if (!pluginConfig.tdd_enforcer?.enabled) { + disabledSkills.add("tdd-enforcer") + } + + const builtinSkills = createBuiltinSkills({ + browserProvider, + disabledSkills, + }) + + const availableSkills: AvailableSkill[] = builtinSkills.map((skill) => ({ + name: skill.name, + description: skill.description, + location: "plugin", + })) + + return { + builtinSkills, + availableSkills, + browserProvider, + disabledSkills, + } + } + ``` + - Update `SkillContext` type: + ```typescript + export type SkillContext = { + builtinSkills: BuiltinSkill[] // was: mergedSkills: LoadedSkill[] + availableSkills: AvailableSkill[] + browserProvider: BrowserAutomationProvider + disabledSkills: Set + } + ``` + +2. **Update `src/create-tools.ts`** + - Change `mergedSkills: LoadedSkill[]` to `builtinSkills: BuiltinSkill[]` + - Update return type + +3. **Update `src/create-hooks.ts`** + - Change `mergedSkills: LoadedSkill[]` to `builtinSkills: BuiltinSkill[]` + +4. **Update `src/plugin/hooks/create-skill-hooks.ts`** + - Change `LoadedSkill` to `BuiltinSkill` + +### Deliverables +- `src/plugin/skill-context.ts` simplified to ~40 LOC +- All references to `LoadedSkill` replaced with `BuiltinSkill` +- No more skill discovery from disk + +### Testing +- Verify `createSkillContext()` returns only built-in skills +- Verify `disabled_skills` config still works +- Verify `browserProvider` selection still works + +### Risks +- **Medium**: Many files import `LoadedSkill` — need to update all of them +- **Mitigation**: Use grep to find all imports, update systematically + +--- + +## Phase 2: Simplify Command Config + +### Objectives +- Remove `loadOpencodeGlobalCommands()`, `loadOpencodeProjectCommands()` +- `applyCommandConfig()` should only use `loadBuiltinCommands()` +- Remove skill-to-command conversion (skills are registered separately) + +### Tasks + +1. **Refactor `src/plugin-handlers/command-config-handler.ts`** + - Remove imports: `loadOpencodeGlobalCommands`, `loadOpencodeProjectCommands`, `discoverConfigSourceSkills`, `loadOpencodeGlobalSkills`, `loadOpencodeProjectSkills`, `skillsToCommandDefinitionRecord` + - Simplify `applyCommandConfig()`: + ```typescript + export async function applyCommandConfig(params: { + config: Record; + pluginConfig: MatrixxConfig; + ctx: { directory: string }; + pluginComponents: PluginComponents; + }): Promise { + const builtinCommands = loadBuiltinCommands(params.pluginConfig.disabled_commands); + const systemCommands = (params.config.command as Record) ?? {}; + + params.config.command = { + ...builtinCommands, + ...systemCommands, + ...params.pluginComponents.commands, + ...params.pluginComponents.skills, + }; + } + ``` + +2. **Update `src/features/builtin-commands/`** + - Remove import of `CommandDefinition` from `command-loader` + - Define `CommandDefinition` locally or in a shared types file + +### Deliverables +- `command-config-handler.ts` simplified to ~20 LOC +- No more command discovery from disk +- `command-loader/` can be deleted + +### Testing +- Verify all built-in commands still register +- Verify slash commands still work +- Verify `disabled_commands` config still works + +### Risks +- **Low**: Command loading is straightforward +- **Mitigation**: Built-in commands are already well-tested + +--- + +## Phase 3: Simplify Agent Config + +### Objectives +- Remove `loadUserAgents()`, `loadProjectAgents()` +- `applyAgentConfig()` should only use `createBuiltinAgents()` + +### Tasks + +1. **Refactor `src/plugin-handlers/agent-config-handler.ts`** + - Remove imports: `loadProjectAgents`, `loadUserAgents`, `discoverConfigSourceSkills`, `discoverOpencodeGlobalSkills`, `discoverOpencodeProjectSkills` + - Remove skill discovery logic (lines 47-70) + - Simplify agent loading: + ```typescript + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + params.pluginConfig.agents, + params.ctx.directory, + undefined, + params.pluginConfig.categories, + [], // allDiscoveredSkills — now empty + params.ctx.client, + browserProvider, + currentModel, + disabledSkills, + useTaskSystem, + params.pluginConfig.global_model, + availableToolNames, + ); + + const rawPluginAgents = params.pluginComponents.agents; + const pluginAgents = Object.fromEntries( + Object.entries(rawPluginAgents).map(([key, value]) => [ + key, + value ? migrateAgentConfig(value as Record) : value, + ]), + ); + + // ... rest of the logic stays the same + ``` + - Remove `loadUserAgents()` and `loadProjectAgents()` calls + +2. **Update `src/agents/builtin-agents.ts`** + - Remove `LoadedSkill` import + - Update `createBuiltinAgents()` signature to not require skills array + +3. **Update `src/agents/agent-builder.ts`** + - Remove `resolveMultipleSkills` import + - Simplify agent building to not resolve skills + +4. **Update `src/agents/builtin-agents/available-skills.ts`** + - Remove `LoadedSkill`, `SkillScope` imports + - Simplify to use `BuiltinSkill` only + +### Deliverables +- `agent-config-handler.ts` simplified +- No more agent discovery from disk +- `agent-loader/` can be deleted + +### Testing +- Verify all 14 agents still register +- Verify agent overrides still work +- Verify `disabled_agents` config still works + +### Risks +- **Medium**: Agent building is complex — need to carefully remove skill resolution +- **Mitigation**: Keep the core agent building logic, only remove disk loading + +--- + +## Phase 4: Simplify MCP Config + +### Objectives +- Remove `SkillMcpManager` from `createManagers()` +- Remove `mcp-oauth/` and `skill-mcp-manager/` +- `applyMcpConfig()` should only use `createBuiltinMcps()` + +### Tasks + +1. **Refactor `src/create-managers.ts`** + - Remove `SkillMcpManager` import + - Remove `skillMcpManager` from `Managers` type + - Remove `new SkillMcpManager()` instantiation + - Update return type + +2. **Refactor `src/plugin-handlers/mcp-config-handler.ts`** + - Already simple — no changes needed + - Just verify it doesn't reference `SkillMcpManager` + +3. **Update `src/create-tools.ts`** + - Remove `skillMcpManager` from `managers` parameter + - Update `createToolRegistry()` call + +4. **Update `src/tools/skill/tools.ts`** + - Remove `SkillMcpManager`, `SkillMcpClientInfo`, `SkillMcpServerContext` imports + - Remove MCP-related logic from skill tool + +5. **Update `src/tools/skill-mcp/tools.ts`** + - Delete entire file — skill-mcp tool no longer needed + +6. **Update `src/tools/skill/types.ts`** + - Remove `SkillMcpManager` import + - Remove `mcpConfig` from skill types + +### Deliverables +- `SkillMcpManager` removed from codebase +- `mcp-oauth/` and `skill-mcp-manager/` can be deleted +- MCP lifecycle delegated to OpenCode + +### Testing +- Verify all 4 built-in MCPs still start +- Verify MCP tools still work +- Verify `disabled_mcps` config still works + +### Risks +- **High**: MCP lifecycle is complex — need to ensure OpenCode handles it correctly +- **Mitigation**: Test thoroughly with MCP-dependent skills (playwright, websearch) + +--- + +## Phase 5: Delete Loader Modules + +### Objectives +- Delete all 5 loader modules + +### Tasks + +1. **Delete directories:** + ```bash + rm -rf src/features/opencode-skill-loader/ + rm -rf src/features/command-loader/ + rm -rf src/features/agent-loader/ + rm -rf src/features/mcp-oauth/ + rm -rf src/features/skill-mcp-manager/ + ``` + +2. **Update `src/features/index.ts`** (if it exists) + - Remove exports of deleted modules + +3. **Update `src/features/AGENTS.md`** + - Remove documentation for deleted modules + +### Deliverables +- 5 directories deleted (~7,169 LOC removed) +- Clean feature set + +### Testing +- Run `bun run typecheck` — should pass +- Run `bun run lint` — should pass +- Run `bun test` — should pass + +### Risks +- **Low**: All dependencies should be resolved in previous phases +- **Mitigation**: If typecheck fails, fix remaining imports + +--- + +## Phase 6: Update Config Schema + +### Objectives +- Remove `skills.sources` from config schema (no longer needed) +- Remove `skills.enable`/`skills.disable` (use `disabled_skills` instead) + +### Tasks + +1. **Update `src/config/schema/skills.ts`** + - Remove `sources` field + - Remove `enable`/`disable` fields + - Keep only `disabled_skills` at root level + +2. **Regenerate schema:** + ```bash + bun run build:schema + ``` + +3. **Update `dist/matrixx.schema.json`** + - Should be auto-generated + +### Deliverables +- Config schema simplified +- No more `skills.sources` configuration + +### Testing +- Verify config validation still works +- Verify `disabled_skills` still works + +### Risks +- **Low**: Config schema changes are straightforward +- **Mitigation**: Keep backward compatibility for one release cycle + +--- + +## Phase 7: Update Dependencies + +### Objectives +- Update all imports that reference deleted modules +- Remove `SkillMcpManager` from `Managers` type +- Remove `mergedSkills` from `SkillContext` + +### Tasks + +1. **Find all imports:** + ```bash + grep -r "from.*opencode-skill-loader" src/ --include="*.ts" + grep -r "from.*command-loader" src/ --include="*.ts" + grep -r "from.*agent-loader" src/ --include="*.ts" + grep -r "from.*mcp-oauth" src/ --include="*.ts" + grep -r "from.*skill-mcp-manager" src/ --include="*.ts" + ``` + +2. **Update each file:** + - Replace `LoadedSkill` with `BuiltinSkill` + - Remove discovery function calls + - Remove MCP-related logic + +3. **Key files to update:** + - `src/tools/slashcommand/command-discovery.ts` + - `src/tools/slashcommand/types.ts` + - `src/tools/slashcommand/slashcommand-tool.ts` + - `src/tools/slashcommand/skill-command-converter.ts` + - `src/tools/skill/types.ts` + - `src/tools/skill/tools.ts` + - `src/tools/skill-mcp/tools.ts` (delete) + - `src/tools/delegate-task/skill-resolver.ts` + - `src/hooks/auto-slash-command/executor.ts` + - `src/hooks/auto-slash-command/hook.ts` + - `src/agents/builtin-agents.ts` + - `src/agents/agent-builder.ts` + - `src/agents/builtin-agents/available-skills.ts` + - `src/plugin/skill-context.ts` + - `src/plugin/hooks/create-skill-hooks.ts` + - `src/create-tools.ts` + - `src/create-hooks.ts` + - `src/create-managers.ts` + - `src/plugin-handlers/command-config-handler.ts` + - `src/plugin-handlers/agent-config-handler.ts` + - `src/features/builtin-skills/types.ts` + - `src/features/builtin-commands/types.ts` + - `src/features/builtin-commands/commands.ts` + +### Deliverables +- All imports updated +- No references to deleted modules + +### Testing +- Run `bun run typecheck` — should pass +- Run `bun run lint` — should pass + +### Risks +- **Medium**: Many files to update +- **Mitigation**: Use grep to find all imports, update systematically + +--- + +## Phase 8: Testing & Validation + +### Objectives +- Verify all functionality still works +- Ensure no regressions + +### Tasks + +1. **Type checking:** + ```bash + bun run typecheck + ``` + +2. **Linting:** + ```bash + bun run lint + ``` + +3. **Unit tests:** + ```bash + bun test + ``` + +4. **Integration tests:** + - Start OpenCode with Matrixx plugin + - Verify all 45 built-in skills load + - Verify all built-in commands work + - Verify all 14 agents register + - Verify all 4 built-in MCPs start + - Test skill invocation via slash commands + - Test agent delegation + - Test MCP tools (websearch, context7, etc.) + +5. **Manual testing:** + - Create a new session + - Invoke a built-in skill (e.g., `/git-master`) + - Delegate to an agent (e.g., `@oracle`) + - Use an MCP tool (e.g., websearch) + - Verify no errors in logs + +### Deliverables +- All tests pass +- No regressions + +### Risks +- **High**: Complex system with many moving parts +- **Mitigation**: Test each component individually, then integration + +--- + +## Phase 9: Documentation & Cleanup + +### Objectives +- Update AGENTS.md files +- Update README.md +- Remove obsolete code comments + +### Tasks + +1. **Update `src/features/AGENTS.md`** + - Remove documentation for deleted modules + - Update structure diagram + +2. **Update `src/AGENTS.md`** + - Update plugin initialization steps + - Remove references to deleted loaders + +3. **Update `README.md`** + - Remove mentions of local directory loading + - Update configuration examples + +4. **Remove obsolete comments:** + - Search for "claude code", "local directory", "skill discovery" + - Remove or update comments + +5. **Update `docs/configurations.md`** + - Remove `skills.sources` documentation + - Update skill configuration examples + +### Deliverables +- Documentation updated +- No obsolete references + +### Testing +- Review documentation for accuracy +- Verify examples still work + +### Risks +- **Low**: Documentation updates are straightforward +- **Mitigation**: Review carefully for accuracy + +--- + +## Risk Mitigation + +### High-Risk Areas + +1. **MCP Lifecycle (Phase 4)** + - **Risk**: OpenCode may not handle MCP lifecycle the same way as `SkillMcpManager` + - **Mitigation**: Test thoroughly with MCP-dependent skills + - **Fallback**: Keep `SkillMcpManager` if OpenCode's handling is insufficient + +2. **Skill Resolution (Phase 3)** + - **Risk**: Agent building may break without skill resolution + - **Mitigation**: Keep core agent building logic, only remove disk loading + - **Fallback**: Simplify skill resolution instead of removing it + +3. **Import Updates (Phase 7)** + - **Risk**: Many files to update, easy to miss some + - **Mitigation**: Use grep to find all imports, update systematically + - **Fallback**: Fix typecheck errors iteratively + +### Medium-Risk Areas + +1. **Config Schema Changes (Phase 6)** + - **Risk**: Breaking change for users with `skills.sources` config + - **Mitigation**: Keep backward compatibility for one release cycle + - **Fallback**: Deprecate instead of remove + +2. **Command Registration (Phase 2)** + - **Risk**: Slash commands may break + - **Mitigation**: Test all slash commands thoroughly + - **Fallback**: Keep skill-to-command conversion + +### Low-Risk Areas + +1. **Agent Loading (Phase 3)** + - **Risk**: Minimal — agent loading is straightforward + - **Mitigation**: Built-in agents are well-tested + +2. **Documentation (Phase 9)** + - **Risk**: Minimal — documentation updates are straightforward + - **Mitigation**: Review carefully for accuracy + +--- + +## Rollback Strategy + +If migration fails at any phase: + +1. **Revert the phase:** + ```bash + git revert HEAD + ``` + +2. **Restore deleted modules:** + ```bash + git checkout HEAD~1 -- src/features/opencode-skill-loader/ + git checkout HEAD~1 -- src/features/command-loader/ + git checkout HEAD~1 -- src/features/agent-loader/ + git checkout HEAD~1 -- src/features/mcp-oauth/ + git checkout HEAD~1 -- src/features/skill-mcp-manager/ + ``` + +3. **Revert import updates:** + ```bash + git checkout HEAD~1 -- src/ + ``` + +4. **Verify:** + ```bash + bun run typecheck + bun run lint + bun test + ``` + +--- + +## Success Criteria + +Migration is complete when: + +- [ ] All 5 loader modules deleted +- [ ] ~7,169 LOC removed +- [ ] All 45 built-in skills load correctly +- [ ] All built-in commands work +- [ ] All 14 agents register +- [ ] All 4 built-in MCPs start +- [ ] `bun run typecheck` passes +- [ ] `bun run lint` passes +- [ ] `bun test` passes +- [ ] Integration tests pass +- [ ] Documentation updated +- [ ] No obsolete references remain + +--- + +## Estimated Effort + +| Phase | Complexity | Time Estimate | +|-------|-----------|---------------| +| Phase 1: Simplify Skill Context | Medium | 2-3 hours | +| Phase 2: Simplify Command Config | Low | 1 hour | +| Phase 3: Simplify Agent Config | Medium | 2-3 hours | +| Phase 4: Simplify MCP Config | High | 3-4 hours | +| Phase 5: Delete Loader Modules | Low | 30 minutes | +| Phase 6: Update Config Schema | Low | 1 hour | +| Phase 7: Update Dependencies | Medium | 3-4 hours | +| Phase 8: Testing & Validation | High | 4-5 hours | +| Phase 9: Documentation & Cleanup | Low | 1-2 hours | +| **Total** | | **18-24 hours** | + +--- + +## Dependencies + +``` +Phase 1 (Skill Context) + ↓ +Phase 2 (Command Config) ──┐ + ↓ │ +Phase 3 (Agent Config) ────┤ + ↓ │ +Phase 4 (MCP Config) ──────┤ + ↓ │ +Phase 5 (Delete Modules) ←─┘ + ↓ +Phase 6 (Config Schema) + ↓ +Phase 7 (Update Dependencies) + ↓ +Phase 8 (Testing) + ↓ +Phase 9 (Documentation) +``` + +**Critical path:** Phase 1 → Phase 7 → Phase 8 + +**Parallelizable:** Phases 2, 3, 4 can be done in parallel after Phase 1 + +--- + +## Code Changes Summary + +### Files to Delete (42 files, ~7,169 LOC) + +``` +src/features/opencode-skill-loader/ (17 files) +src/features/command-loader/ (3 files) +src/features/agent-loader/ (3 files) +src/features/mcp-oauth/ (7 files) +src/features/skill-mcp-manager/ (12 files) +``` + +### Files to Modify (~30 files) + +``` +src/plugin/skill-context.ts +src/create-tools.ts +src/create-hooks.ts +src/create-managers.ts +src/plugin/hooks/create-skill-hooks.ts +src/plugin-handlers/command-config-handler.ts +src/plugin-handlers/agent-config-handler.ts +src/tools/slashcommand/command-discovery.ts +src/tools/slashcommand/types.ts +src/tools/slashcommand/slashcommand-tool.ts +src/tools/slashcommand/skill-command-converter.ts +src/tools/skill/types.ts +src/tools/skill/tools.ts +src/tools/skill-mcp/tools.ts (delete) +src/tools/delegate-task/skill-resolver.ts +src/hooks/auto-slash-command/executor.ts +src/hooks/auto-slash-command/hook.ts +src/agents/builtin-agents.ts +src/agents/agent-builder.ts +src/agents/builtin-agents/available-skills.ts +src/features/builtin-skills/types.ts +src/features/builtin-commands/types.ts +src/features/builtin-commands/commands.ts +src/config/schema/skills.ts +``` + +### Files to Create (0 files) + +No new files needed — we're simplifying, not adding. + +--- + +## API Mapping Table + +### Before (v1 with loaders) + +```typescript +// Skill loading +import { discoverOpencodeProjectSkills, mergeSkills } from "./features/opencode-skill-loader" +const skills = await discoverOpencodeProjectSkills(directory) +const merged = mergeSkills(builtinSkills, skills, ...) + +// Command loading +import { loadOpencodeProjectCommands } from "./features/command-loader" +const commands = await loadOpencodeProjectCommands(directory) + +// Agent loading +import { loadProjectAgents } from "./features/agent-loader" +const agents = loadProjectAgents(directory) + +// MCP management +import { SkillMcpManager } from "./features/skill-mcp-manager" +const manager = new SkillMcpManager() +``` + +### After (v1 without loaders) + +```typescript +// Skill loading +import { createBuiltinSkills } from "./features/builtin-skills" +const skills = createBuiltinSkills({ browserProvider, disabledSkills }) + +// Command loading +import { loadBuiltinCommands } from "./features/builtin-commands" +const commands = loadBuiltinCommands(disabledCommands) + +// Agent loading +import { createBuiltinAgents } from "./agents" +const agents = await createBuiltinAgents(...) + +// MCP management +import { createBuiltinMcps } from "./mcp" +const mcps = createBuiltinMcps(disabledMcps, config) +// OpenCode handles MCP lifecycle +``` + +--- + +## Notes + +### Why v2 Migration Isn't Possible + +OpenCode's plugin loader (`packages/opencode/src/plugin/index.ts`) only calls `readV1Plugin()`. There is no `readV2Plugin()` function. The v2 API exists in the `@opencode-ai/plugin` package but OpenCode's loader doesn't recognize or load v2 plugins. v2 is pre-release infrastructure — it's built but not wired up. + +### Why Local Directory Loading Exists + +Matrixx's loaders provide functionality OpenCode doesn't have natively: +- Local file discovery (`.opencode/skills/`, `.opencode/command/`) +- YAML frontmatter parsing +- Multi-scope priority merging +- Claude Code compatibility (`.claude/` paths) + +However, the user has decided this functionality is not needed. Users should use OpenCode's native mechanisms (if/when they're added) or rely on Matrixx's built-in skills/commands. + +### Backward Compatibility + +This is a **breaking change**. Users with custom skills/commands/agents in `.opencode/` directories will lose that functionality. They should: +1. Use OpenCode's native mechanisms (if available) +2. Request features from Matrixx to be added as built-in skills/commands +3. Fork Matrixx and add custom loaders + +### Future Work + +If OpenCode adds native support for local directory loading in the future, Matrixx can re-add loaders that delegate to OpenCode's APIs. For now, we're simplifying to reduce maintenance burden. diff --git a/assets/matrixx.schema.json b/assets/matrixx.schema.json index 7e16600d3c1..416ca529446 100644 --- a/assets/matrixx.schema.json +++ b/assets/matrixx.schema.json @@ -136,7 +136,7 @@ "auto-slash-command", "edit-error-recovery", "delegate-task-retry", - "prometheus-md-only", + "oracle-md-only", "plan-persister", "mouse-notepad", "start-work", @@ -4079,21 +4079,487 @@ "default": true, "type": "boolean" }, - "switch_script": { - "type": "string" - }, "profiles": { - "type": "array", - "items": { + "default": { + "economy": { + "pruneNotification": "off", + "compress": { + "maxContextLimit": "30%", + "minContextLimit": "20%", + "nudgeFrequency": 2, + "nudgeForce": "strong", + "iterationNudgeThreshold": 7 + }, + "turnProtection": { + "enabled": false + }, + "experimental": { + "allowSubAgents": false + }, + "strategies": { + "purgeErrors": { + "turns": 1 + } + } + }, + "balanced": { + "pruneNotification": "minimal", + "compress": { + "maxContextLimit": "60%", + "minContextLimit": "30%", + "nudgeFrequency": 3, + "nudgeForce": "strong", + "iterationNudgeThreshold": 10 + }, + "turnProtection": { + "enabled": true, + "turns": 2 + }, + "experimental": { + "allowSubAgents": true + }, + "strategies": { + "purgeErrors": { + "turns": 2 + } + } + }, + "performance": { + "pruneNotification": "minimal", + "compress": { + "maxContextLimit": "80%", + "minContextLimit": "35%", + "nudgeFrequency": 4, + "nudgeForce": "strong", + "iterationNudgeThreshold": 12 + }, + "turnProtection": { + "enabled": true, + "turns": 3 + }, + "experimental": { + "allowSubAgents": true + }, + "strategies": { + "purgeErrors": { + "turns": 2 + } + } + }, + "ultimate": { + "pruneNotification": "detailed", + "compress": { + "maxContextLimit": "85%", + "minContextLimit": "40%", + "nudgeFrequency": 5, + "nudgeForce": "strong", + "iterationNudgeThreshold": 15, + "protectTags": true + }, + "turnProtection": { + "enabled": true, + "turns": 5 + }, + "experimental": { + "allowSubAgents": true + }, + "strategies": { + "purgeErrors": { + "turns": 4 + } + } + } + }, + "type": "object", + "propertyNames": { "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "pruneNotification": { + "type": "string", + "enum": [ + "off", + "minimal", + "detailed" + ] + }, + "compress": { + "type": "object", + "properties": { + "maxContextLimit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\d+%$" + } + ] + }, + "minContextLimit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\d+%$" + } + ] + }, + "nudgeFrequency": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "iterationNudgeThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "nudgeForce": { + "type": "string", + "enum": [ + "strong", + "soft" + ] + }, + "protectTags": { + "type": "boolean" + }, + "protectedTools": { + "type": "array", + "items": { + "type": "string" + } + }, + "protectUserMessages": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "turnProtection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "turns": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "experimental": { + "type": "object", + "properties": { + "allowSubAgents": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "strategies": { + "type": "object", + "properties": { + "purgeErrors": { + "type": "object", + "properties": { + "turns": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "default_profile": { "type": "string" + }, + "base": { + "default": { + "autoUpdate": false, + "debug": false, + "pruneNotificationType": "chat", + "compress": { + "mode": "range", + "permission": "allow", + "showCompression": true, + "summaryBuffer": true, + "nudgeForce": "strong", + "iterationNudgeThreshold": 5, + "protectedTools": [], + "protectUserMessages": false + }, + "strategies": { + "deduplication": { + "enabled": true, + "protectedTools": [] + }, + "purgeErrors": { + "enabled": true, + "protectedTools": [] + } + }, + "commands": { + "enabled": true, + "protectedTools": [] + }, + "manualMode": { + "enabled": false, + "automaticStrategies": true + }, + "protectedFilePatterns": [] + }, + "type": "object", + "properties": { + "autoUpdate": { + "default": false, + "type": "boolean" + }, + "debug": { + "default": false, + "type": "boolean" + }, + "pruneNotificationType": { + "default": "chat", + "type": "string", + "enum": [ + "chat", + "toast" + ] + }, + "compress": { + "default": { + "mode": "range", + "permission": "allow", + "showCompression": true, + "summaryBuffer": true, + "nudgeForce": "strong", + "iterationNudgeThreshold": 5, + "protectedTools": [], + "protectUserMessages": false + }, + "type": "object", + "properties": { + "mode": { + "default": "range", + "type": "string", + "enum": [ + "range", + "message" + ] + }, + "permission": { + "default": "allow", + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "showCompression": { + "default": true, + "type": "boolean" + }, + "summaryBuffer": { + "default": true, + "type": "boolean" + }, + "nudgeForce": { + "default": "strong", + "type": "string", + "enum": [ + "strong", + "soft" + ] + }, + "iterationNudgeThreshold": { + "default": 5, + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "protectedTools": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "protectUserMessages": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mode", + "permission", + "showCompression", + "summaryBuffer", + "nudgeForce", + "iterationNudgeThreshold", + "protectedTools", + "protectUserMessages" + ], + "additionalProperties": false + }, + "strategies": { + "default": { + "deduplication": { + "enabled": true, + "protectedTools": [] + }, + "purgeErrors": { + "enabled": true, + "protectedTools": [] + } + }, + "type": "object", + "properties": { + "deduplication": { + "default": { + "enabled": true, + "protectedTools": [] + }, + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "protectedTools": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "enabled", + "protectedTools" + ], + "additionalProperties": false + }, + "purgeErrors": { + "default": { + "enabled": true, + "protectedTools": [] + }, + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "protectedTools": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "enabled", + "protectedTools" + ], + "additionalProperties": false + } + }, + "required": [ + "deduplication", + "purgeErrors" + ], + "additionalProperties": false + }, + "commands": { + "default": { + "enabled": true, + "protectedTools": [] + }, + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "protectedTools": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "enabled", + "protectedTools" + ], + "additionalProperties": false + }, + "manualMode": { + "default": { + "enabled": false, + "automaticStrategies": true + }, + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "automaticStrategies": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "enabled", + "automaticStrategies" + ], + "additionalProperties": false + }, + "protectedFilePatterns": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "autoUpdate", + "debug", + "pruneNotificationType", + "compress", + "strategies", + "commands", + "manualMode", + "protectedFilePatterns" + ], + "additionalProperties": false } }, "required": [ - "enabled" + "enabled", + "profiles", + "base" ], "additionalProperties": false }, diff --git a/docs/cli-guide.md b/docs/cli-guide.md index a8cd06780ce..e55f0098b8e 100644 --- a/docs/cli-guide.md +++ b/docs/cli-guide.md @@ -134,41 +134,8 @@ bunx opencode-matrixx run [prompt] --- -## 6. `mcp oauth` - MCP OAuth Management -Manages OAuth 2.1 authentication for remote MCP servers. - -### Usage - -```bash -# Login to an OAuth-protected MCP server -bunx opencode-matrixx mcp oauth login --server-url https://api.example.com - -# Login with explicit client ID and scopes -bunx opencode-matrixx mcp oauth login my-api --server-url https://api.example.com --client-id my-client --scopes "read,write" - -# Remove stored OAuth tokens -bunx opencode-matrixx mcp oauth logout - -# Check OAuth token status -bunx opencode-matrixx mcp oauth status [server-name] -``` - -### Options - -| Option | Description | -|--------|-------------| -| `--server-url ` | MCP server URL (required for login) | -| `--client-id ` | OAuth client ID (optional if server supports Dynamic Client Registration) | -| `--scopes ` | Comma-separated OAuth scopes | - -### Token Storage - -Tokens are stored in `~/.config/opencode/mcp-oauth.json` with `0600` permissions (owner read/write only). Key format: `{serverHost}/{resource}`. - ---- - -## 7. `auth` - Authentication Management +## 6. `auth` - Authentication Management Manages Google Antigravity OAuth authentication. Required for using Gemini models. @@ -187,7 +154,7 @@ bunx opencode-matrixx auth status --- -## 8. In-Session Slash Commands +## 7. In-Session Slash Commands These are slash commands used within OpenCode sessions during active conversations. diff --git a/docs/features.md b/docs/features.md index 507fbb446dc..65d3dfa9c31 100644 --- a/docs/features.md +++ b/docs/features.md @@ -900,45 +900,6 @@ mcp: The `skill_mcp` tool invokes these operations with full schema discovery. -#### OAuth-Enabled MCPs - -### Lazy MCP Initialization (websearch) - -The `websearch` MCP uses deferred initialization. Its config depends on env vars (`EXA_API_KEY`, `TAVILY_API_KEY`) and user-configured provider, so the `createWebsearchConfig(config?.websearch)` call is wrapped in an `Object.defineProperty` getter on the returned `mcps` record. The other three built-in MCPs (context7, grep_app, document_reader) have static configs and initialize eagerly. - -**Why:** The Exa/Tavily env var read happens only when OpenCode first introspects the websearch server, not at plugin init — matches the lazy skill pattern. - -**Files:** `src/mcp/index.ts` (the `websearch` lazy block). - - -Skills can define OAuth-protected remote MCP servers. OAuth 2.1 with full RFC compliance (RFC 9728, 8414, 8707, 7591) is supported: - -```yaml ---- -description: My API skill -mcp: - my-api: - url: https://api.example.com/mcp - oauth: - clientId: ${CLIENT_ID} - scopes: ["read", "write"] ---- -``` - -When a skill MCP has `oauth` configured: -- **Auto-discovery**: Fetches `/.well-known/oauth-protected-resource` (RFC 9728), falls back to `/.well-known/oauth-authorization-server` (RFC 8414) -- **Dynamic Client Registration**: Auto-registers with servers supporting RFC 7591 (clientId becomes optional) -- **PKCE**: Mandatory for all flows -- **Resource Indicators**: Auto-generated from MCP URL per RFC 8707 -- **Token Storage**: Persisted in `~/.config/opencode/mcp-oauth.json` (chmod 0600) -- **Auto-refresh**: Tokens refresh on 401; step-up authorization on 403 with `WWW-Authenticate` -- **Dynamic Port**: OAuth callback server uses an auto-discovered available port - -Pre-authenticate via CLI: - -```bash -bunx opencode-matrixx mcp oauth login --server-url https://api.example.com -``` --- diff --git a/package.json b/package.json index 5e77f2a0b42..b0dc389704b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "lint": "biome check src/", "lint:fix": "biome check --write src/", "ci": "bash script/run-ci.sh", - "test": "bun test" + "test": "bash script/run-ci.sh" }, "keywords": [ "opencode", diff --git a/script/run-ci.sh b/script/run-ci.sh index 9aa95c2ac85..2dd3531a797 100755 --- a/script/run-ci.sh +++ b/script/run-ci.sh @@ -60,13 +60,12 @@ for test in \ tests/features/opencode-skill-loader/loader.test.ts \ tests/features/opencode-skill-loader/agents-skills-global.test.ts \ tests/tools/session-manager/storage.test.ts \ - tests/hooks/prometheus-md-only/index.test.ts \ + tests/hooks/oracle-md-only/index.test.ts \ tests/hooks/architect/index.test.ts \ tests/hooks/matrix-loop/index.test.ts \ tests/hooks/start-work/index.test.ts \ tests/hooks/auto-update-checker/hook/background-update-check.test.ts \ tests/hooks/auto-update-checker/hook.test.ts \ - tests/features/skill-mcp-manager/manager.test.ts \ tests/features/background-agent/manager.test.ts \ tests/hooks/comment-checker/cli.test.ts \ tests/hooks/comment-checker/hook.apply-patch.test.ts \ @@ -76,12 +75,14 @@ for test in \ tests/hooks/compaction-todo-preserver/index.test.ts \ tests/hooks/preemptive-compaction.test.ts \ tests/tools/lsp/client.test.ts \ + tests/tools/lsp/lsp-process.test.ts \ tests/tools/skill/tools.test.ts \ tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts \ tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts \ tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts \ tests/agents/utils.test.ts \ - tests/hooks/task-notepad/hook.test.ts + tests/hooks/task-notepad/hook.test.ts \ + tests/tools/bdd-parse-gherkin/tools.test.ts do label="$(basename "$(dirname "$test")")/$(basename "$test")" bun test "$test" && pass "$label" || fail "$label" @@ -101,13 +102,12 @@ find tests script -name '*.test.ts' -type f \ -e 'tests/features/opencode-skill-loader/loader.test.ts' \ -e 'tests/features/opencode-skill-loader/agents-skills-global.test.ts' \ -e 'tests/tools/session-manager/storage.test.ts' \ - -e 'tests/hooks/prometheus-md-only/index.test.ts' \ + -e 'tests/hooks/oracle-md-only/index.test.ts' \ -e 'tests/hooks/architect/index.test.ts' \ -e 'tests/hooks/matrix-loop/index.test.ts' \ -e 'tests/hooks/start-work/index.test.ts' \ -e 'tests/hooks/auto-update-checker/hook/background-update-check.test.ts' \ -e 'tests/hooks/auto-update-checker/hook.test.ts' \ - -e 'tests/features/skill-mcp-manager/manager.test.ts' \ -e 'tests/features/background-agent/manager.test.ts' \ -e 'tests/hooks/comment-checker/cli.test.ts' \ -e 'tests/hooks/comment-checker/hook.apply-patch.test.ts' \ @@ -117,12 +117,14 @@ find tests script -name '*.test.ts' -type f \ -e 'tests/hooks/compaction-todo-preserver/index.test.ts' \ -e 'tests/hooks/preemptive-compaction.test.ts' \ -e 'tests/tools/lsp/client.test.ts' \ + -e 'tests/tools/lsp/lsp-process.test.ts' \ -e 'tests/tools/skill/tools.test.ts' \ -e 'tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts' \ -e 'tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts' \ -e 'tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts' \ -e 'tests/agents/utils.test.ts' \ -e 'tests/hooks/task-notepad/hook.test.ts' \ + -e 'tests/tools/bdd-parse-gherkin/tools.test.ts' \ | xargs bun test && pass "Remaining tests" || fail "Remaining tests" # ------------------------------------------------------------------ diff --git a/src/AGENTS.md b/src/AGENTS.md index 18b567c262e..f122a970810 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -37,7 +37,6 @@ src/ 6. `createManagers(...)` → 4 managers: - `TmuxSessionManager` — Multi-pane tmux sessions - `BackgroundManager` — Parallel subagent execution - - `SkillMcpManager` — MCP server lifecycle - `ConfigHandler` — Plugin config API to OpenCode 7. `createTools(...)` → `createSkillContext()` + `createAvailableCategories()` + `createToolRegistry()` 8. `createHooks(...)` → `createCoreHooks()` + `createContinuationHooks()` + `createSkillHooks()` diff --git a/src/agents/agent-builder.ts b/src/agents/agent-builder.ts index fe801a9fccd..32ce63cbe31 100644 --- a/src/agents/agent-builder.ts +++ b/src/agents/agent-builder.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { BrowserAutomationProvider, CategoriesConfig, CategoryConfig } from "../config/schema" -import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" +import { createBuiltinSkills } from "../features/builtin-skills" import { mergeCategories } from "../shared/merge-categories" import type { AgentFactory } from "./types" @@ -37,9 +37,10 @@ export function buildAgent( } if (agentWithCategory.skills?.length) { - const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { browserProvider, disabledSkills }) - if (resolved.size > 0) { - const skillContent = Array.from(resolved.values()).join("\n\n") + const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }) + const resolved = builtinSkills.filter(s => agentWithCategory.skills!.includes(s.name)) + if (resolved.length > 0) { + const skillContent = resolved.map(s => s.template).join("\n\n") base.prompt = skillContent + (base.prompt ? `\n\n${base.prompt}` : "") } } diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index 0cff5215e36..fec66c402d7 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { BrowserAutomationProvider, CategoriesConfig } from "../config/schema" -import type { LoadedSkill } from "../features/opencode-skill-loader/types" +import type { BuiltinSkill } from "../features/builtin-skills" import { fetchAvailableModels, readConnectedProvidersCache, @@ -16,18 +16,18 @@ import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents" import { maybeCreateKeymakerConfig } from "./builtin-agents/keymaker-agent" import { maybeCreateMorpheusConfig } from "./builtin-agents/morpheus-agent" import { CIPHER_PROMPT_METADATA, createCipherAgent } from "./cipher" -import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./construct" +import { CONSTRUCT_PROMPT_METADATA, createConstructAgent } from "./construct" import { buildCustomAgentMetadata, parseRegisteredAgentSummaries } from "./custom-agent-summaries" import type { AvailableCategory } from "./dynamic-agent-prompt-builder" import { createKeymakerAgent } from "./keymaker" import { createMerovingianAgent, ORACLE_PLAN_BUILDER_METADATA, ORACLE_PROMPT_METADATA } from "./merovingian" import { createMorpheusAgent } from "./morpheus" -import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./operator" +import { createOperatorAgent, OPERATOR_PROMPT_METADATA } from "./operator" import { createSatiAgent } from "./sati" import { createSentinelAgent, SENTINEL_PROMPT_METADATA } from "./sentinel" import { createSeraphAgent, seraphPromptMetadata } from "./seraph" import { createSmithAgent, smithPromptMetadata } from "./smith" -import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./trinity" +import { createTrinityAgent, TRINITY_PROMPT_METADATA } from "./trinity" import type { AgentFactory, AgentOverrides, AgentPromptMetadata, BuiltinAgentName } from "./types" type AgentSource = AgentFactory | AgentConfig @@ -37,9 +37,9 @@ const agentSources: Partial> = { morpheus: createMorpheusAgent, keymaker: createKeymakerAgent, merovingian: createMerovingianAgent, - operator: createLibrarianAgent, - trinity: createExploreAgent, - construct: createMultimodalLookerAgent, + operator: createOperatorAgent, + trinity: createTrinityAgent, + construct: createConstructAgent, seraph: createSeraphAgent, smith: createSmithAgent, architect: createArchitectAgent as AgentFactory, @@ -56,9 +56,9 @@ const agentSources: Partial> = { const agentMetadata: Partial> = { merovingian: ORACLE_PROMPT_METADATA, oracle: ORACLE_PLAN_BUILDER_METADATA, - operator: LIBRARIAN_PROMPT_METADATA, - trinity: EXPLORE_PROMPT_METADATA, - construct: MULTIMODAL_LOOKER_PROMPT_METADATA, + operator: OPERATOR_PROMPT_METADATA, + trinity: TRINITY_PROMPT_METADATA, + construct: CONSTRUCT_PROMPT_METADATA, seraph: seraphPromptMetadata, smith: smithPromptMetadata, architect: architectPromptMetadata, @@ -73,7 +73,7 @@ export async function createBuiltinAgents( directory?: string, systemDefaultModel?: string, categories?: CategoriesConfig, - discoveredSkills: LoadedSkill[] = [], + discoveredSkills: BuiltinSkill[] = [], customAgentSummaries?: unknown, browserProvider?: BrowserAutomationProvider, uiSelectedModel?: string, diff --git a/src/agents/builtin-agents/available-skills.ts b/src/agents/builtin-agents/available-skills.ts index 1468d569379..0780c026c46 100644 --- a/src/agents/builtin-agents/available-skills.ts +++ b/src/agents/builtin-agents/available-skills.ts @@ -1,16 +1,9 @@ import type { BrowserAutomationProvider } from "../../config/schema" -import { createBuiltinSkills } from "../../features/builtin-skills" -import type { LoadedSkill, SkillScope } from "../../features/opencode-skill-loader/types" +import { type BuiltinSkill, createBuiltinSkills } from "../../features/builtin-skills" import type { AvailableSkill } from "../dynamic-agent-prompt-builder" -function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { - if (scope === "user" || scope === "opencode") return "user" - if (scope === "project" || scope === "opencode-project") return "project" - return "plugin" -} - export function buildAvailableSkills( - discoveredSkills: LoadedSkill[], + discoveredSkills: BuiltinSkill[], browserProvider?: BrowserAutomationProvider, disabledSkills?: Set, currentAgent?: string @@ -32,15 +25,15 @@ export function buildAvailableSkills( const discoveredAvailable: AvailableSkill[] = discoveredSkills .filter((s) => { if (!builtinSkillNames.has(s.name) && !disabledSkills?.has(s.name)) { - if (s.definition.agent && currentAgent && s.definition.agent !== currentAgent) return false + if (s.agent && currentAgent && s.agent !== currentAgent) return false return true } return false }) .map((skill) => ({ name: skill.name, - description: skill.definition.description ?? "", - location: mapScopeToLocation(skill.scope), + description: skill.description || "", + location: "plugin" as const, })) return [...builtinAvailable, ...discoveredAvailable] diff --git a/src/agents/builtin-agents/keymaker-agent.ts b/src/agents/builtin-agents/keymaker-agent.ts index 34069b1b631..9870b564c9a 100644 --- a/src/agents/builtin-agents/keymaker-agent.ts +++ b/src/agents/builtin-agents/keymaker-agent.ts @@ -40,34 +40,34 @@ export function maybeCreateKeymakerConfig(input: { if (disabledAgents.includes("keymaker")) return undefined const keymakerOverride = agentOverrides.keymaker - const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS.keymaker + const keymakerRequirement = AGENT_MODEL_REQUIREMENTS.keymaker const hasKeymakerExplicitConfig = keymakerOverride !== undefined const hasRequiredProvider = - !hephaestusRequirement?.requiresProvider || + !keymakerRequirement?.requiresProvider || hasKeymakerExplicitConfig || isFirstRunNoCache || - isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels) + isAnyProviderConnected(keymakerRequirement.requiresProvider, availableModels) if (!hasRequiredProvider) return undefined - let hephaestusResolution = applyModelResolution({ + let keymakerResolution = applyModelResolution({ globalOverrideModel, userModel: keymakerOverride?.model, - requirement: hephaestusRequirement, + requirement: keymakerRequirement, availableModels, systemDefaultModel, }) if (isFirstRunNoCache && !keymakerOverride?.model) { - hephaestusResolution = getFirstFallbackModel(hephaestusRequirement) + keymakerResolution = getFirstFallbackModel(keymakerRequirement) } - if (!hephaestusResolution) return undefined - const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution + if (!keymakerResolution) return undefined + const { model: keymakerModel, variant: keymakerResolvedVariant } = keymakerResolution let keymakerConfig = createKeymakerAgent( - hephaestusModel, + keymakerModel, availableAgents, availableToolNames, availableSkills, @@ -75,11 +75,11 @@ export function maybeCreateKeymakerConfig(input: { useTaskSystem ) - keymakerConfig = { ...keymakerConfig, variant: hephaestusResolvedVariant ?? "medium" } + keymakerConfig = { ...keymakerConfig, variant: keymakerResolvedVariant ?? "medium" } - const hepOverrideCategory = (keymakerOverride as Record | undefined)?.category as string | undefined - if (hepOverrideCategory) { - keymakerConfig = applyCategoryOverride(keymakerConfig, hepOverrideCategory, mergedCategories) + const keymakerOverrideCategory = (keymakerOverride as Record | undefined)?.category as string | undefined + if (keymakerOverrideCategory) { + keymakerConfig = applyCategoryOverride(keymakerConfig, keymakerOverrideCategory, mergedCategories) } diff --git a/src/agents/construct.ts b/src/agents/construct.ts index 3fd8a6a2f92..dc3febf305c 100644 --- a/src/agents/construct.ts +++ b/src/agents/construct.ts @@ -4,14 +4,14 @@ import type { AgentMode, AgentPromptMetadata } from "./types" const MODE: AgentMode = "subagent" -export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = { +export const CONSTRUCT_PROMPT_METADATA: AgentPromptMetadata = { category: "utility", cost: "CHEAP", promptAlias: "Multimodal Looker", triggers: [], } -export function createMultimodalLookerAgent(model: string): AgentConfig { +export function createConstructAgent(model: string): AgentConfig { const restrictions = createAgentToolAllowlist(["read"]) return { @@ -55,4 +55,4 @@ Response rules: Your output goes straight to the main agent for continued work.`, } } -createMultimodalLookerAgent.mode = MODE +createConstructAgent.mode = MODE diff --git a/src/agents/index.ts b/src/agents/index.ts index e57a4301e3f..3e2ba1648ba 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,10 +1,10 @@ export { architectPromptMetadata, createArchitectAgent } from "./architect" export { createBuiltinAgents } from "./builtin-agents" -export { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./construct" +export { CONSTRUCT_PROMPT_METADATA, createConstructAgent } from "./construct" export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" export { createMerovingianAgent, ORACLE_PROMPT_METADATA } from "./merovingian" export { createMorpheusAgent } from "./morpheus" -export { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./operator" +export { createOperatorAgent, OPERATOR_PROMPT_METADATA } from "./operator" export { ORACLE_BEHAVIORAL_SUMMARY, ORACLE_HIGH_ACCURACY_MODE, @@ -17,5 +17,5 @@ export { } from "./oracle" export { createSeraphAgent, SERAPH_SYSTEM_PROMPT, seraphPromptMetadata } from "./seraph" export { createSmithAgent, SMITH_SYSTEM_PROMPT, smithPromptMetadata } from "./smith" -export { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./trinity" +export { createTrinityAgent, TRINITY_PROMPT_METADATA } from "./trinity" export * from "./types" diff --git a/src/agents/operator.ts b/src/agents/operator.ts index fbdd331b461..e785166f8e8 100644 --- a/src/agents/operator.ts +++ b/src/agents/operator.ts @@ -4,7 +4,7 @@ import type { AgentMode, AgentPromptMetadata } from "./types" const MODE: AgentMode = "subagent" -export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = { +export const OPERATOR_PROMPT_METADATA: AgentPromptMetadata = { category: "exploration", cost: "CHEAP", promptAlias: "Librarian", @@ -21,7 +21,7 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = { ], } -export function createLibrarianAgent(model: string): AgentConfig { +export function createOperatorAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([ "write", "edit", @@ -322,4 +322,4 @@ grep_app_searchGitHub(query: "useQuery") `, } } -createLibrarianAgent.mode = MODE +createOperatorAgent.mode = MODE diff --git a/src/agents/oracle/identity-constraints.ts b/src/agents/oracle/identity-constraints.ts index e2273919072..efb9120bc1a 100644 --- a/src/agents/oracle/identity-constraints.ts +++ b/src/agents/oracle/identity-constraints.ts @@ -108,7 +108,7 @@ CLEARANCE CHECKLIST (ALL must be YES to auto-transition): ### 3. MARKDOWN-ONLY FILE ACCESS You may ONLY create/edit markdown (.md) files. All other file types are FORBIDDEN. -This constraint is enforced by the prometheus-md-only hook. Non-.md writes will be blocked. +This constraint is enforced by the oracle-md-only hook. Non-.md writes will be blocked. ### 4. PLAN OUTPUT LOCATION (STRICT PATH ENFORCEMENT) diff --git a/src/agents/oracle/system-prompt.ts b/src/agents/oracle/system-prompt.ts index 6a1799ddc49..bbfb7431535 100644 --- a/src/agents/oracle/system-prompt.ts +++ b/src/agents/oracle/system-prompt.ts @@ -18,7 +18,7 @@ ${ORACLE_BEHAVIORAL_SUMMARY}` /** * Oracle planner permission configuration. - * Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook). + * Allows write/edit for plan files (.md only, enforced by oracle-md-only hook). * Question permission allows agent to ask user questions via OpenCode's QuestionTool. */ export const ORACLE_PERMISSION = { diff --git a/src/agents/trinity.ts b/src/agents/trinity.ts index f9f1eb560c0..9bdc130461f 100644 --- a/src/agents/trinity.ts +++ b/src/agents/trinity.ts @@ -4,7 +4,7 @@ import type { AgentMode, AgentPromptMetadata } from "./types" const MODE: AgentMode = "subagent" -export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = { +export const TRINITY_PROMPT_METADATA: AgentPromptMetadata = { category: "exploration", cost: "FREE", promptAlias: "Explore", @@ -24,7 +24,7 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = { ], } -export function createExploreAgent(model: string): AgentConfig { +export function createTrinityAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([ "write", "edit", @@ -121,4 +121,4 @@ Use the right tool for the job: Flood with parallel calls. Cross-validate findings across multiple tools.`, } } -createExploreAgent.mode = MODE +createTrinityAgent.mode = MODE diff --git a/src/config/schema/dcp.ts b/src/config/schema/dcp.ts index 7f7835e0664..c4e6107fb15 100644 --- a/src/config/schema/dcp.ts +++ b/src/config/schema/dcp.ts @@ -8,15 +8,252 @@ import { z } from "zod" * * DCP must be installed as a plugin: `~/.config/opencode/node_modules/@tarquinen/opencode-dcp` */ + +// ─── Sub-schemas ────────────────────────────────────────────────────────── + +export const DcpCompressOverrideSchema = z.object({ + maxContextLimit: z.union([z.number(), z.string().regex(/^\d+%$/)]).optional(), + minContextLimit: z.union([z.number(), z.string().regex(/^\d+%$/)]).optional(), + nudgeFrequency: z.number().int().min(1).optional(), + iterationNudgeThreshold: z.number().int().min(1).optional(), + nudgeForce: z.enum(["strong", "soft"]).optional(), + protectTags: z.boolean().optional(), + protectedTools: z.array(z.string()).optional(), + protectUserMessages: z.boolean().optional(), +}) +export type DcpCompressOverride = z.infer + +export const DcpStrategiesOverrideSchema = z.object({ + purgeErrors: z + .object({ + turns: z.number().int().min(1).optional(), + }) + .optional(), +}) +export type DcpStrategiesOverride = z.infer + +export const DcpTurnProtectionSchema = z.object({ + enabled: z.boolean().optional(), + turns: z.number().int().min(1).optional(), +}) +export type DcpTurnProtection = z.infer + +export const DcpExperimentalSchema = z.object({ + allowSubAgents: z.boolean().optional(), +}) +export type DcpExperimental = z.infer + +export const DcpProfileDefinitionSchema = z.object({ + pruneNotification: z.enum(["off", "minimal", "detailed"]).optional(), + compress: DcpCompressOverrideSchema.optional(), + turnProtection: DcpTurnProtectionSchema.optional(), + experimental: DcpExperimentalSchema.optional(), + strategies: DcpStrategiesOverrideSchema.optional(), +}) +export type DcpProfileDefinition = z.infer + +// ─── Built-in profiles ─────────────────────────────────────────────────── + +export const BUILTIN_DCP_PROFILES = { + economy: { + pruneNotification: "off" as const, + compress: { + maxContextLimit: "30%", + minContextLimit: "20%", + nudgeFrequency: 2, + nudgeForce: "strong" as const, + iterationNudgeThreshold: 7, + }, + turnProtection: { enabled: false }, + experimental: { allowSubAgents: false }, + strategies: { purgeErrors: { turns: 1 } }, + }, + balanced: { + pruneNotification: "minimal" as const, + compress: { + maxContextLimit: "60%", + minContextLimit: "30%", + nudgeFrequency: 3, + nudgeForce: "strong" as const, + iterationNudgeThreshold: 10, + }, + turnProtection: { enabled: true, turns: 2 }, + experimental: { allowSubAgents: true }, + strategies: { purgeErrors: { turns: 2 } }, + }, + performance: { + pruneNotification: "minimal" as const, + compress: { + maxContextLimit: "80%", + minContextLimit: "35%", + nudgeFrequency: 4, + nudgeForce: "strong" as const, + iterationNudgeThreshold: 12, + }, + turnProtection: { enabled: true, turns: 3 }, + experimental: { allowSubAgents: true }, + strategies: { purgeErrors: { turns: 2 } }, + }, + ultimate: { + pruneNotification: "detailed" as const, + compress: { + maxContextLimit: "85%", + minContextLimit: "40%", + nudgeFrequency: 5, + nudgeForce: "strong" as const, + iterationNudgeThreshold: 15, + protectTags: true, + }, + turnProtection: { enabled: true, turns: 5 }, + experimental: { allowSubAgents: true }, + strategies: { purgeErrors: { turns: 4 } }, + }, +} as const satisfies Record + +// ─── Root DCP config schema ───────────────────────────────────────────── + export const DcpConfigSchema = z.object({ /** Enable the DCP profile switcher. Default: true */ enabled: z.boolean().default(true), - /** Absolute path to the profile-switching shell script. Default: ~/.myopencode/dcp/switch-profile.sh */ - switch_script: z.string().optional(), - /** Profile names available for switching. Default: ["economy", "balanced", "performance", "ultimate"] */ - profiles: z.array(z.string()).optional(), + + /** Profile definitions keyed by name. Defaults to the four built-in profiles. */ + profiles: z.record(z.string(), DcpProfileDefinitionSchema).default(BUILTIN_DCP_PROFILES), + /** Default profile to activate when the command is invoked without arguments. Default: "balanced" */ default_profile: z.string().optional(), + + /** Base/shared configuration that applies across all profiles */ + base: z + .object({ + /** Automatically update DCP when a new version is available (default: false) */ + autoUpdate: z.boolean().default(false), + + /** Enable debug logging for DCP operations (default: false) */ + debug: z.boolean().default(false), + + /** How to deliver prune notifications: via chat message or toast popup (default: "chat") */ + pruneNotificationType: z.enum(["chat", "toast"]).default("chat"), + + /** Context compression configuration */ + compress: z + .object({ + /** Compression mode: "range" compresses a range of messages, "message" compresses individual messages (default: "range") */ + mode: z.enum(["range", "message"]).default("range"), + + /** Permission model: "ask" prompts before compressing, "allow" auto-compresses, "deny" disables (default: "allow") */ + permission: z.enum(["ask", "allow", "deny"]).default("allow"), + + /** Show compression summary in chat after each compression (default: true) */ + showCompression: z.boolean().default(true), + + /** Keep a summary buffer for compressed content to preserve context continuity (default: true) */ + summaryBuffer: z.boolean().default(true), + + /** Force of compression nudges: "strong" is more aggressive, "soft" is gentler (default: "strong") */ + nudgeForce: z.enum(["strong", "soft"]).default("strong"), + + /** Number of consecutive iteration-based messages before a nudge is triggered (default: 5) */ + iterationNudgeThreshold: z.number().int().min(1).default(5), + + /** Tools whose outputs are protected from compression (default: []) */ + protectedTools: z.array(z.string()).default([]), + + /** Protect user messages from being compressed (default: false) */ + protectUserMessages: z.boolean().default(false), + }) + .default({ + mode: "range", + permission: "allow", + showCompression: true, + summaryBuffer: true, + nudgeForce: "strong", + iterationNudgeThreshold: 5, + protectedTools: [], + protectUserMessages: false, + }), + + /** Strategy configurations for context management */ + strategies: z + .object({ + deduplication: z + .object({ + /** Enable deduplication of repeated content (default: true) */ + enabled: z.boolean().default(true), + /** Tools excluded from deduplication (default: []) */ + protectedTools: z.array(z.string()).default([]), + }) + .default({ + enabled: true, + protectedTools: [], + }), + purgeErrors: z + .object({ + /** Enable purging of error content from context (default: true) */ + enabled: z.boolean().default(true), + /** Tools excluded from error purging (default: []) */ + protectedTools: z.array(z.string()).default([]), + }) + .default({ + enabled: true, + protectedTools: [], + }), + }) + .default({ + deduplication: { enabled: true, protectedTools: [] }, + purgeErrors: { enabled: true, protectedTools: [] }, + }), + + /** Slash command configuration */ + commands: z + .object({ + /** Enable DCP-related slash commands (default: true) */ + enabled: z.boolean().default(true), + /** Tools excluded from command interception (default: []) */ + protectedTools: z.array(z.string()).default([]), + }) + .default({ + enabled: true, + protectedTools: [], + }), + + /** Manual mode configuration for user-initiated pruning */ + manualMode: z + .object({ + /** Enable manual mode where user explicitly triggers pruning (default: false) */ + enabled: z.boolean().default(false), + /** Run automatic strategies (deduplication, error purging) even in manual mode (default: true) */ + automaticStrategies: z.boolean().default(true), + }) + .default({ + enabled: false, + automaticStrategies: true, + }), + + /** Glob patterns for files that should be protected from compression (default: []) */ + protectedFilePatterns: z.array(z.string()).default([]), + }) + .default({ + autoUpdate: false, + debug: false, + pruneNotificationType: "chat", + compress: { + mode: "range", + permission: "allow", + showCompression: true, + summaryBuffer: true, + nudgeForce: "strong", + iterationNudgeThreshold: 5, + protectedTools: [], + protectUserMessages: false, + }, + strategies: { + deduplication: { enabled: true, protectedTools: [] }, + purgeErrors: { enabled: true, protectedTools: [] }, + }, + commands: { enabled: true, protectedTools: [] }, + manualMode: { enabled: false, automaticStrategies: true }, + protectedFilePatterns: [], + }), }) export type DcpConfig = z.infer diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index bca3be1c82d..f23f8be981c 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -33,7 +33,7 @@ export const HookNameSchema = z.enum([ "auto-slash-command", "edit-error-recovery", "delegate-task-retry", - "prometheus-md-only", + "oracle-md-only", "plan-persister", "mouse-notepad", "mouse-notepad", diff --git a/src/create-hooks.ts b/src/create-hooks.ts index 1edc6b1d31d..a3333af8b68 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -1,7 +1,7 @@ import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder" import type { HookName, MatrixxConfig } from "./config" import type { BackgroundManager } from "./features/background-agent" -import type { LoadedSkill } from "./features/opencode-skill-loader/types" +import type { BuiltinSkill } from "./features/builtin-skills" import { createContinuationHooks } from "./plugin/hooks/create-continuation-hooks" import { createCoreHooks } from "./plugin/hooks/create-core-hooks" @@ -16,7 +16,7 @@ export function createHooks(args: { backgroundManager: BackgroundManager isHookEnabled: (hookName: HookName) => boolean safeHookEnabled: boolean - mergedSkills: LoadedSkill[] + builtinSkills: BuiltinSkill[] availableSkills: AvailableSkill[] }) { const { @@ -25,7 +25,7 @@ export function createHooks(args: { backgroundManager, isHookEnabled, safeHookEnabled, - mergedSkills, + builtinSkills, availableSkills, } = args @@ -49,7 +49,7 @@ export function createHooks(args: { ctx, isHookEnabled, safeHookEnabled, - mergedSkills, + builtinSkills, availableSkills, }) diff --git a/src/create-managers.ts b/src/create-managers.ts index 646e19dc6d6..d2abd98d188 100644 --- a/src/create-managers.ts +++ b/src/create-managers.ts @@ -1,7 +1,6 @@ import type { MatrixxConfig } from "./config" import type { SubagentSessionCreatedEvent } from "./features/background-agent" import { BackgroundManager } from "./features/background-agent" -import { SkillMcpManager } from "./features/skill-mcp-manager" import { initTaskToastManager } from "./features/task-toast-manager" import { TmuxSessionManager } from "./features/tmux-subagent" import type { PluginContext, TmuxConfig } from "./plugin/types" @@ -12,7 +11,6 @@ import { log } from "./shared" export type Managers = { tmuxSessionManager: TmuxSessionManager backgroundManager: BackgroundManager - skillMcpManager: SkillMcpManager configHandler: ReturnType } @@ -63,8 +61,6 @@ export function createManagers(args: { initTaskToastManager(ctx.client) - const skillMcpManager = new SkillMcpManager() - const configHandler = createConfigHandler({ ctx: { directory: ctx.directory, client: ctx.client }, pluginConfig, @@ -74,7 +70,6 @@ export function createManagers(args: { return { tmuxSessionManager, backgroundManager, - skillMcpManager, configHandler, } } diff --git a/src/create-tools.ts b/src/create-tools.ts index 60e97411344..a5d8c70b97d 100644 --- a/src/create-tools.ts +++ b/src/create-tools.ts @@ -2,7 +2,7 @@ import type { AvailableCategory, AvailableSkill } from "./agents/dynamic-agent-p import type { MatrixxConfig } from "./config" import type { BrowserAutomationProvider } from "./config/schema/browser-automation" import type { Managers } from "./create-managers" -import type { LoadedSkill } from "./features/opencode-skill-loader/types" +import type { BuiltinSkill } from "./features/builtin-skills" import { createAvailableCategories } from "./plugin/available-categories" import { createSkillContext } from "./plugin/skill-context" import { createToolRegistry } from "./plugin/tool-registry" @@ -10,7 +10,7 @@ import type { PluginContext, ToolsRecord } from "./plugin/types" type CreateToolsResult = { filteredTools: ToolsRecord - mergedSkills: LoadedSkill[] + builtinSkills: BuiltinSkill[] availableSkills: AvailableSkill[] availableCategories: AvailableCategory[] browserProvider: BrowserAutomationProvider @@ -21,7 +21,7 @@ type CreateToolsResult = { export async function createTools(args: { ctx: PluginContext pluginConfig: MatrixxConfig - managers: Pick + managers: Pick }): Promise { const { ctx, pluginConfig, managers } = args @@ -42,7 +42,7 @@ export async function createTools(args: { return { filteredTools, - mergedSkills: skillContext.mergedSkills, + builtinSkills: skillContext.builtinSkills, availableSkills: skillContext.availableSkills, availableCategories, browserProvider: skillContext.browserProvider, diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 314aad792db..205ac724c47 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -20 feature modules extending plugin capabilities: agent orchestration, skill loading, Claude Code compatibility, MCP management, task storage, handoff, and tmux integration. +15 feature modules extending plugin capabilities: agent orchestration, skill management, MCP infrastructure, task storage, handoff, and tmux integration. ## STRUCTURE ``` @@ -12,17 +12,6 @@ features/ │ └── concurrency.ts # Parallel execution limits per provider/model ├── tmux-subagent/ # Tmux integration (25 files, ~3000 LOC) │ └── manager.ts # Pane management, grid planning (350 lines) -├── opencode-skill-loader/ # YAML frontmatter skill loading (26 files, ~2850 LOC) -│ ├── loader.ts # Skill discovery (4 scopes) -│ ├── skill-directory-loader.ts # Recursive directory scanning -│ ├── skill-discovery.ts # getAllSkills() with caching -│ └── merger/ # Skill merging with scope priority -├── mcp-oauth/ # OAuth 2.0 flow for MCP (17 files, ~2050 LOC) -│ ├── provider.ts # McpOAuthProvider class -│ ├── oauth-authorization-flow.ts # PKCE, callback handling -│ └── dcr.ts # Dynamic Client Registration (RFC 7591) -├── skill-mcp-manager/ # MCP client lifecycle per session (12 files, 1769 LOC) -│ └── manager.ts # SkillMcpManager class (150 lines) ├── builtin-skills/ # Built-in skills (8 files, ~1700 LOC) │ └── skills/ # dev-browser, frontend-ui-ux, git-master (1111), matrixx-self-config, playwright ├── builtin-commands/ # 6 command templates (11 files, 1511 LOC) @@ -30,27 +19,17 @@ features/ ├── task-storage/ # Task schema + storage (7 files, 1165 LOC) ├── context-injector/ # AGENTS.md, README.md, rules injection (6 files, 809 LOC) ├── handoff/ # Multi-action handoff: create, read, list, archive -├── claude-code-plugin-loader/ # Plugin discovery from .opencode/plugins/ (10 files) -├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion (6 files) -├── claude-code-command-loader/ # Command loading from .opencode/commands/ (3 files) -├── claude-code-agent-loader/ # Agent loading from .opencode/agents/ (3 files) -├── session-state/ # Subagent session state tracking (3 files) +├── session-state/ # Subagent session state tracking (3 files) ├── hook-message-injector/ # System message injection (4 files) ├── task-toast-manager/ # Task progress notifications (4 files) ├── mission-state/ # Persistent state for multi-step ops (9 files) -└── tool-metadata-store/ # Tool execution metadata caching (3 files) -``` +└── tool-metadata-store/ # Tool execution metadata caching (3 files)``` ## KEY PATTERNS **Background Agent Lifecycle:** Task creation → Queue → Concurrency check → Execute → Monitor/Poll → Notification → Cleanup - -**Skill Loading Pipeline (4-scope priority):** -opencode-project (`.opencode/skills/`) > opencode (`~/.config/opencode/skills/`) > project (`.claude/skills/`) > user (`~/.claude/skills/`) - -**Claude Code Compatibility Layer:** -5 loaders: agent-loader, command-loader, mcp-loader, plugin-loader, session-state +**Skill Management:** All skills are loaded from `src/features/builtin-skills/` via `createBuiltinSkills()`. No external skill directory loading. Skills are configured via `disabled_skills` in `matrixx.jsonc`. **SKILL.md Format:** ```yaml diff --git a/src/features/agent-loader/index.ts b/src/features/agent-loader/index.ts deleted file mode 100644 index a076ecef86b..00000000000 --- a/src/features/agent-loader/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./loader" -export * from "./types" diff --git a/src/features/agent-loader/loader.ts b/src/features/agent-loader/loader.ts deleted file mode 100644 index aa173be5470..00000000000 --- a/src/features/agent-loader/loader.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs" -import { basename, join } from "node:path" -import type { AgentConfig } from "@opencode-ai/sdk" -import { getOpenCodeConfigDir } from "../../shared" -import { isMarkdownFile } from "../../shared/file-utils" -import { parseFrontmatter } from "../../shared/frontmatter" -import type { AgentFrontmatter, AgentScope, LoadedAgent } from "./types" - -function parseToolsConfig(toolsStr?: string): Record | undefined { - if (!toolsStr) return undefined - - const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} - -function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] { - if (!existsSync(agentsDir)) { - return [] - } - - const entries = readdirSync(agentsDir, { withFileTypes: true }) - const agents: LoadedAgent[] = [] - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const agentPath = join(agentsDir, entry.name) - const agentName = basename(entry.name, ".md") - - try { - const content = readFileSync(agentPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const name = data.name || agentName - const originalDescription = data.description || "" - - const formattedDescription = `(${scope}) ${originalDescription}` - - const config: AgentConfig = { - description: formattedDescription, - mode: "subagent", - prompt: body.trim(), - } - - const toolsConfig = parseToolsConfig(data.tools) - if (toolsConfig) { - config.tools = toolsConfig - } - - agents.push({ - name, - path: agentPath, - config, - scope, - }) - } catch { - } - } - - return agents -} - -export function loadUserAgents(): Record { - const userAgentsDir = join(getOpenCodeConfigDir({ binary: "opencode" }), "agents") - const agents = loadAgentsFromDir(userAgentsDir, "user") - - const result: Record = {} - for (const agent of agents) { - result[agent.name] = agent.config - } - return result -} - -export function loadProjectAgents(directory?: string): Record { - const projectAgentsDir = join(directory ?? process.cwd(), ".opencode", "agents") - const agents = loadAgentsFromDir(projectAgentsDir, "project") - - const result: Record = {} - for (const agent of agents) { - result[agent.name] = agent.config - } - return result -} diff --git a/src/features/agent-loader/types.ts b/src/features/agent-loader/types.ts deleted file mode 100644 index 4ffd9de401c..00000000000 --- a/src/features/agent-loader/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { AgentConfig } from "@opencode-ai/sdk" - -export type AgentScope = "user" | "project" - -export interface AgentFrontmatter { - name?: string - description?: string - model?: string - tools?: string -} - -export interface LoadedAgent { - name: string - path: string - config: AgentConfig - scope: AgentScope -} diff --git a/src/features/builtin-commands/templates/dcp-profile.ts b/src/features/builtin-commands/templates/dcp-profile.ts index 780e3e620e0..56450482962 100644 --- a/src/features/builtin-commands/templates/dcp-profile.ts +++ b/src/features/builtin-commands/templates/dcp-profile.ts @@ -1,6 +1,6 @@ export const DCP_PROFILE_TEMPLATE = `You are switching the active DCP (Dynamic Context Pruning) profile tier. -This command is provided by the Matrixx plugin to call a user-managed shell script that swaps the active DCP configuration. The script lives in the user's home directory and is not part of the Matrixx codebase. +This command is provided by the Matrixx plugin. It uses the built-in \`dcp_switch_profile\` tool to apply DCP profile configurations — no external scripts needed. ## Step 1: Verify DCP is installed @@ -16,42 +16,30 @@ fi If the directory does not exist, stop immediately and report the error to the user. Do not proceed. -## Step 2: Resolve the switch script path - -The switch script location is configurable via the \`dcp.switch_script\` key in the user's \`matrixx.jsonc\` (read from \`~/.config/opencode/matrixx.jsonc\` or the project-level file). If that key is absent, use the default: - -\`\`\` -~/.myopencode/dcp/switch-profile.sh -\`\`\` - -If the resolved script does not exist or is not executable, stop and report the path to the user. - -## Step 3: Determine the target profile +## Step 2: Determine the target profile Parse the arguments passed to this command. The user invoked \`/dcp-profile \` where \`\` is the first positional argument. - If the argument is a known profile name (one of: economy, balanced, performance, ultimate), use it directly. -- If the argument is empty or missing, read \`dcp.default_profile\` from the user's config; if absent, default to \`balanced\`. -- If the argument is not a recognized profile name, list the available profiles and stop. Do NOT guess or pass invalid names to the script. +- If the argument is empty or missing, read \`dcp.default_profile\` from the user's \`matrixx.jsonc\` config; if absent, default to \`balanced\`. +- If the argument is not a recognized profile name, list the available profiles and stop. Do NOT guess or pass invalid names. -## Step 4: Execute the switch script +## Step 3: Call the built-in \`dcp_switch_profile\` tool -Run the script via the bash tool. Capture both stdout and stderr. If the script exits with a non-zero status, show the captured stderr output to the user verbatim and stop. Do not swallow errors. +Use the \`dcp_switch_profile\` tool with the resolved profile name. This tool reads profile parameters from the Matrixx plugin configuration and writes the full inline DCP config to \`~/.config/opencode/dcp.jsonc\`. -\`\`\`bash -"" "" -\`\`\` +The tool will handle all file operations — you do NOT need to run any external scripts or edit DCP config files directly. -## Step 5: Confirm and instruct +## Step 4: Confirm and instruct After a successful switch: -1. Report the script's stdout to the user. +1. Report the tool's output to the user. 2. Tell the user that the new DCP configuration will take effect after they restart their OpenCode session (the active session has already loaded the previous config into memory). 3. Do not attempt to reload DCP in-place; a session restart is required. ## Important constraints -- This command is a thin wrapper. All real logic lives in the user's switch script. Never bypass the script by writing to \`dcp.jsonc\` directly. +- Use the built-in \`dcp_switch_profile\` tool. Do NOT bypass it by editing DCP config files directly. - Do not install, upgrade, or modify the DCP plugin from this command. If the user needs to install or upgrade DCP, instruct them to run \`opencode plugin @tarquinen/opencode-dcp@\` (or use \`npm install --prefix ~/.config/opencode\` for cached installs). -- Do not edit any DCP config files (\`dcp-base.jsonc\`, \`dcp-.jsonc\`, \`dcp-generated-*.jsonc\`) as part of switching.` +` diff --git a/src/features/builtin-skills/AGENTS.md b/src/features/builtin-skills/AGENTS.md index afa8d6b93cb..6374dc93c2f 100644 --- a/src/features/builtin-skills/AGENTS.md +++ b/src/features/builtin-skills/AGENTS.md @@ -96,9 +96,9 @@ Returns `BuiltinSkill[]`. Disabled skills are filtered AFTER creation. The `disa } ``` -## SKILL LOADING PIPELINE (PLUGIN-WIDE) +## SKILL LOADING -Built-in skills (this dir) are merged with project / user / Claude-Code skills in `src/features/opencode-skill-loader/`. Priority (highest to lowest): opencode-project > opencode-user > builtin > project > user > claude-code-* compat. +All skills are loaded from `src/features/builtin-skills/` via `createBuiltinSkills()`. No external skill directory loading. ## HOW TO ADD A NEW BUILT-IN SKILL diff --git a/src/features/builtin-skills/types.ts b/src/features/builtin-skills/types.ts index 7adc0f94944..5d329016822 100644 --- a/src/features/builtin-skills/types.ts +++ b/src/features/builtin-skills/types.ts @@ -1,4 +1,15 @@ -import type { SkillMcpConfig } from "../skill-mcp-manager/types" +export interface McpServerDefinition { + type?: "http" | "sse" | "stdio" + url?: string + command?: string + args?: string[] + env?: Record + headers?: Record + disabled?: boolean +} + +export type SkillMcpConfig = Record + export interface BuiltinSkill { name: string diff --git a/src/features/command-loader/index.ts b/src/features/command-loader/index.ts index a076ecef86b..8f6869405a5 100644 --- a/src/features/command-loader/index.ts +++ b/src/features/command-loader/index.ts @@ -1,2 +1,2 @@ -export * from "./loader" +export * from "./types" export * from "./types" diff --git a/src/features/command-loader/loader.ts b/src/features/command-loader/loader.ts deleted file mode 100644 index 6b9b52fd460..00000000000 --- a/src/features/command-loader/loader.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { type Dirent, promises as fs } from "node:fs" -import { basename, join } from "node:path" -import { getOpenCodeConfigDir } from "../../shared" -import { isMarkdownFile } from "../../shared/file-utils" -import { parseFrontmatter } from "../../shared/frontmatter" -import { log } from "../../shared/logger" -import { sanitizeModelField } from "../../shared/model-sanitizer" -import type { CommandDefinition, CommandFrontmatter, CommandScope, LoadedCommand } from "./types" - -async function loadCommandsFromDir( - commandsDir: string, - scope: CommandScope, - visited: Set = new Set(), - prefix: string = "" -): Promise { - try { - await fs.access(commandsDir) - } catch { - return [] - } - - let realPath: string - try { - realPath = await fs.realpath(commandsDir) - } catch (error) { - log(`Failed to resolve command directory: ${commandsDir}`, error) - return [] - } - - if (visited.has(realPath)) { - return [] - } - visited.add(realPath) - - let entries: Dirent[] - try { - entries = await fs.readdir(commandsDir, { withFileTypes: true }) - } catch (error) { - log(`Failed to read command directory: ${commandsDir}`, error) - return [] - } - - const commands: LoadedCommand[] = [] - - for (const entry of entries) { - if (entry.isDirectory()) { - if (entry.name.startsWith(".")) continue - const subDirPath = join(commandsDir, entry.name) - const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name - const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix) - commands.push(...subCommands) - continue - } - - if (!isMarkdownFile(entry)) continue - - const commandPath = join(commandsDir, entry.name) - const baseCommandName = basename(entry.name, ".md") - const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName - - try { - const content = await fs.readFile(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const wrappedTemplate = ` -${body.trim()} - - - -$ARGUMENTS -` - - const formattedDescription = `(${scope}) ${data.description || ""}` - - const isOpencodeSource = scope === "opencode" || scope === "opencode-project" - const definition: CommandDefinition = { - name: commandName, - description: formattedDescription, - template: wrappedTemplate, - agent: data.agent, - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - subtask: data.subtask, - argumentHint: data["argument-hint"], - handoffs: data.handoffs, - } - - commands.push({ - name: commandName, - path: commandPath, - definition, - scope, - }) - } catch (error) { - log(`Failed to parse command: ${commandPath}`, error) - } - } - - return commands -} - -function commandsToRecord(commands: LoadedCommand[]): Record { - const result: Record = {} - for (const cmd of commands) { - const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition - result[cmd.name] = openCodeCompatible as CommandDefinition - } - return result -} - -export async function loadOpencodeGlobalCommands(): Promise> { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const opencodeCommandsDir = join(configDir, "command") - const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode") - return commandsToRecord(commands) -} - -export async function loadOpencodeProjectCommands(directory?: string): Promise> { - const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command") - const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project") - return commandsToRecord(commands) -} - -async function _loadAllCommands(directory?: string): Promise> { - const [global, projectOpencode] = await Promise.all([ - loadOpencodeGlobalCommands(), - loadOpencodeProjectCommands(directory), - ]) - return { ...projectOpencode, ...global } -} diff --git a/src/features/mcp-oauth/callback-server.ts b/src/features/mcp-oauth/callback-server.ts deleted file mode 100644 index 3f201202255..00000000000 --- a/src/features/mcp-oauth/callback-server.ts +++ /dev/null @@ -1,124 +0,0 @@ -const DEFAULT_PORT = 19877 -const MAX_PORT_ATTEMPTS = 20 -const TIMEOUT_MS = 5 * 60 * 1000 - -export type OAuthCallbackResult = { - code: string - state: string -} - -export type CallbackServer = { - port: number - waitForCallback: () => Promise - close: () => void -} - -const SUCCESS_HTML = ` - - - - OAuth Authorized - - - -
-

Authorization successful

-

You can close this window and return to your terminal.

-
- -` - -async function isPortAvailable(port: number): Promise { - try { - const server = Bun.serve({ - port, - hostname: "127.0.0.1", - fetch: () => new Response(), - }) - server.stop(true) - return true - } catch { - return false - } -} - -export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promise { - for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) { - const port = startPort + attempt - if (await isPortAvailable(port)) { - return port - } - } - throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) -} - -export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise { - const port = await findAvailablePort(startPort) - - let resolveCallback: ((result: OAuthCallbackResult) => void) | null = null - let rejectCallback: ((error: Error) => void) | null = null - - const callbackPromise = new Promise((resolve, reject) => { - resolveCallback = resolve - rejectCallback = reject - }) - - const timeoutId = setTimeout(() => { - rejectCallback?.(new Error("OAuth callback timed out after 5 minutes")) - server.stop(true) - }, TIMEOUT_MS) - - const server = Bun.serve({ - port, - hostname: "127.0.0.1", - fetch(request: Request): Response { - const url = new URL(request.url) - - if (url.pathname !== "/oauth/callback") { - return new Response("Not Found", { status: 404 }) - } - - const oauthError = url.searchParams.get("error") - if (oauthError) { - const description = url.searchParams.get("error_description") ?? oauthError - clearTimeout(timeoutId) - rejectCallback?.(new Error(`OAuth authorization failed: ${description}`)) - setTimeout(() => server.stop(true), 100) - return new Response(`Authorization failed: ${description}`, { status: 400 }) - } - - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - - if (!code || !state) { - clearTimeout(timeoutId) - rejectCallback?.(new Error("OAuth callback missing code or state parameter")) - setTimeout(() => server.stop(true), 100) - return new Response("Missing code or state parameter", { status: 400 }) - } - - resolveCallback?.({ code, state }) - clearTimeout(timeoutId) - - setTimeout(() => server.stop(true), 100) - - return new Response(SUCCESS_HTML, { - headers: { "content-type": "text/html; charset=utf-8" }, - }) - }, - }) - - return { - port, - waitForCallback: () => callbackPromise, - close: () => { - clearTimeout(timeoutId) - server.stop(true) - }, - } -} diff --git a/src/features/mcp-oauth/dcr.ts b/src/features/mcp-oauth/dcr.ts deleted file mode 100644 index 91d40679c0f..00000000000 --- a/src/features/mcp-oauth/dcr.ts +++ /dev/null @@ -1,98 +0,0 @@ -type ClientRegistrationRequest = { - redirect_uris: string[] - client_name: string - grant_types: ["authorization_code", "refresh_token"] - response_types: ["code"] - token_endpoint_auth_method: "none" | "client_secret_post" -} - -export type ClientCredentials = { - clientId: string - clientSecret?: string -} - -export type ClientRegistrationStorage = { - getClientRegistration: (serverIdentifier: string) => ClientCredentials | null - setClientRegistration: ( - serverIdentifier: string, - credentials: ClientCredentials - ) => void -} - -type DynamicClientRegistrationOptions = { - registrationEndpoint?: string | null - serverIdentifier?: string - clientName: string - redirectUris: string[] - tokenEndpointAuthMethod: "none" | "client_secret_post" - clientId?: string | null - storage: ClientRegistrationStorage - fetch?: DcrFetch -} - -type DcrFetch = ( - input: string, - init?: { method?: string; headers?: Record; body?: string } -) => Promise<{ ok: boolean; json: () => Promise }> - -export async function getOrRegisterClient( - options: DynamicClientRegistrationOptions -): Promise { - const serverIdentifier = - options.serverIdentifier ?? options.registrationEndpoint ?? "default" - const existing = options.storage.getClientRegistration(serverIdentifier) - if (existing) return existing - - if (!options.registrationEndpoint) { - return options.clientId ? { clientId: options.clientId } : null - } - - const fetchImpl = options.fetch ?? globalThis.fetch - const request: ClientRegistrationRequest = { - redirect_uris: options.redirectUris, - client_name: options.clientName, - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: options.tokenEndpointAuthMethod, - } - - try { - const response = await fetchImpl(options.registrationEndpoint, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(request), - }) - - if (!response.ok) { - return options.clientId ? { clientId: options.clientId } : null - } - - const data: unknown = await response.json() - const parsed = parseRegistrationResponse(data) - if (!parsed) { - return options.clientId ? { clientId: options.clientId } : null - } - - options.storage.setClientRegistration(serverIdentifier, parsed) - return parsed - } catch { - return options.clientId ? { clientId: options.clientId } : null - } -} - -function parseRegistrationResponse(data: unknown): ClientCredentials | null { - if (!isRecord(data)) return null - const clientId = data.client_id - if (typeof clientId !== "string" || clientId.length === 0) return null - - const clientSecret = data.client_secret - if (typeof clientSecret === "string" && clientSecret.length > 0) { - return { clientId, clientSecret } - } - - return { clientId } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} diff --git a/src/features/mcp-oauth/discovery.ts b/src/features/mcp-oauth/discovery.ts deleted file mode 100644 index 619520d42e7..00000000000 --- a/src/features/mcp-oauth/discovery.ts +++ /dev/null @@ -1,123 +0,0 @@ -export interface OAuthServerMetadata { - authorizationEndpoint: string - tokenEndpoint: string - registrationEndpoint?: string - resource: string -} - -const discoveryCache = new Map() -const pendingDiscovery = new Map>() - -function parseHttpsUrl(value: string, label: string): URL { - const parsed = new URL(value) - if (parsed.protocol !== "https:") { - throw new Error(`${label} must use https`) - } - return parsed -} - -function readStringField(source: Record, field: string): string { - const value = source[field] - if (typeof value !== "string" || value.length === 0) { - throw new Error(`OAuth metadata missing ${field}`) - } - return value -} - -async function fetchMetadata(url: string): Promise<{ ok: true; json: Record } | { ok: false; status: number }> { - const response = await fetch(url, { headers: { accept: "application/json" } }) - if (!response.ok) { - return { ok: false, status: response.status } - } - const json = (await response.json().catch(() => null)) as Record | null - if (!json || typeof json !== "object") { - throw new Error("OAuth metadata response is not valid JSON") - } - return { ok: true, json } -} - -async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise { - const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL") - const issuerPath = issuerUrl.pathname.replace(/\/+$/, "") - const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString() - const metadata = await fetchMetadata(metadataUrl) - - if (!metadata.ok) { - if (metadata.status === 404) { - throw new Error("OAuth authorization server metadata not found") - } - throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`) - } - - const authorizationEndpoint = parseHttpsUrl( - readStringField(metadata.json, "authorization_endpoint"), - "authorization_endpoint" - ).toString() - const tokenEndpoint = parseHttpsUrl( - readStringField(metadata.json, "token_endpoint"), - "token_endpoint" - ).toString() - const registrationEndpointValue = metadata.json.registration_endpoint - const registrationEndpoint = - typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0 - ? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString() - : undefined - - return { - authorizationEndpoint, - tokenEndpoint, - registrationEndpoint, - resource, - } -} - -function parseAuthorizationServers(metadata: Record): string[] { - const servers = metadata.authorization_servers - if (!Array.isArray(servers)) return [] - return servers.filter((server): server is string => typeof server === "string" && server.length > 0) -} - -export async function discoverOAuthServerMetadata(resource: string): Promise { - const resourceUrl = parseHttpsUrl(resource, "Resource server URL") - const resourceKey = resourceUrl.toString() - - const cached = discoveryCache.get(resourceKey) - if (cached) return cached - - const pending = pendingDiscovery.get(resourceKey) - if (pending) return pending - - const discoveryPromise = (async () => { - const prmUrl = new URL("/.well-known/oauth-protected-resource", resourceUrl).toString() - const prmResponse = await fetchMetadata(prmUrl) - - if (prmResponse.ok) { - const authServers = parseAuthorizationServers(prmResponse.json) - if (authServers.length === 0) { - throw new Error("OAuth protected resource metadata missing authorization_servers") - } - return fetchAuthorizationServerMetadata(authServers[0], resource) - } - - if (prmResponse.status !== 404) { - throw new Error(`OAuth protected resource metadata fetch failed (${prmResponse.status})`) - } - - return fetchAuthorizationServerMetadata(resourceKey, resource) - })() - - pendingDiscovery.set(resourceKey, discoveryPromise) - - try { - const result = await discoveryPromise - discoveryCache.set(resourceKey, result) - return result - } finally { - pendingDiscovery.delete(resourceKey) - } -} - -export function resetDiscoveryCache(): void { - discoveryCache.clear() - pendingDiscovery.clear() -} diff --git a/src/features/mcp-oauth/oauth-authorization-flow.ts b/src/features/mcp-oauth/oauth-authorization-flow.ts deleted file mode 100644 index 26f7d31ac0d..00000000000 --- a/src/features/mcp-oauth/oauth-authorization-flow.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { spawn } from "node:child_process" -import { createHash, randomBytes } from "node:crypto" -import { createServer } from "node:http" - -export type OAuthCallbackResult = { - code: string - state: string -} - -export function generateCodeVerifier(): string { - return randomBytes(32).toString("base64url") -} - -export function generateCodeChallenge(verifier: string): string { - return createHash("sha256").update(verifier).digest("base64url") -} - -export function buildAuthorizationUrl( - authorizationEndpoint: string, - options: { - clientId: string - redirectUri: string - codeChallenge: string - state: string - scopes?: string[] - resource?: string - } -): string { - const url = new URL(authorizationEndpoint) - url.searchParams.set("response_type", "code") - url.searchParams.set("client_id", options.clientId) - url.searchParams.set("redirect_uri", options.redirectUri) - url.searchParams.set("code_challenge", options.codeChallenge) - url.searchParams.set("code_challenge_method", "S256") - url.searchParams.set("state", options.state) - if (options.scopes && options.scopes.length > 0) { - url.searchParams.set("scope", options.scopes.join(" ")) - } - if (options.resource) { - url.searchParams.set("resource", options.resource) - } - return url.toString() -} - -const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 - -export function startCallbackServer(port: number): Promise { - return new Promise((resolve, reject) => { - let timeoutId: ReturnType - - const server = createServer((request, response) => { - clearTimeout(timeoutId) - - const requestUrl = new URL(request.url ?? "/", `http://localhost:${port}`) - const code = requestUrl.searchParams.get("code") - const state = requestUrl.searchParams.get("state") - const error = requestUrl.searchParams.get("error") - - if (error) { - const errorDescription = requestUrl.searchParams.get("error_description") ?? error - response.writeHead(400, { "content-type": "text/html" }) - response.end("

Authorization failed

") - server.close() - reject(new Error(`OAuth authorization error: ${errorDescription}`)) - return - } - - if (!code || !state) { - response.writeHead(400, { "content-type": "text/html" }) - response.end("

Missing code or state

") - server.close() - reject(new Error("OAuth callback missing code or state parameter")) - return - } - - response.writeHead(200, { "content-type": "text/html" }) - response.end("

Authorization successful. You can close this tab.

") - server.close() - resolve({ code, state }) - }) - - timeoutId = setTimeout(() => { - server.close() - reject(new Error("OAuth callback timed out after 5 minutes")) - }, CALLBACK_TIMEOUT_MS) - - server.listen(port, "127.0.0.1") - server.on("error", (err) => { - clearTimeout(timeoutId) - reject(err) - }) - }) -} - -function openBrowser(url: string): void { - const platform = process.platform - let command: string - let args: string[] - - if (platform === "darwin") { - command = "open" - args = [url] - } else if (platform === "win32") { - command = "explorer" - args = [url] - } else { - command = "xdg-open" - args = [url] - } - - try { - const child = spawn(command, args, { stdio: "ignore", detached: true }) - child.on("error", () => {}) - child.unref() - } catch { - // Browser open failed — user must navigate manually - } -} - -export async function runAuthorizationCodeRedirect(options: { - authorizationEndpoint: string - callbackPort: number - clientId: string - redirectUri: string - scopes?: string[] - resource?: string -}): Promise<{ code: string; verifier: string }> { - const verifier = generateCodeVerifier() - const challenge = generateCodeChallenge(verifier) - const state = randomBytes(16).toString("hex") - - const authorizationUrl = buildAuthorizationUrl(options.authorizationEndpoint, { - clientId: options.clientId, - redirectUri: options.redirectUri, - codeChallenge: challenge, - state, - scopes: options.scopes, - resource: options.resource, - }) - - const callbackPromise = startCallbackServer(options.callbackPort) - openBrowser(authorizationUrl) - - const result = await callbackPromise - if (result.state !== state) { - throw new Error("OAuth state mismatch") - } - - return { code: result.code, verifier } -} diff --git a/src/features/mcp-oauth/provider.ts b/src/features/mcp-oauth/provider.ts deleted file mode 100644 index 4b546c4ff35..00000000000 --- a/src/features/mcp-oauth/provider.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { findAvailablePort } from "./callback-server" -import type { ClientCredentials, ClientRegistrationStorage } from "./dcr" -import { getOrRegisterClient } from "./dcr" -import type { OAuthServerMetadata } from "./discovery" -import { discoverOAuthServerMetadata } from "./discovery" -import { - buildAuthorizationUrl, - generateCodeChallenge, - generateCodeVerifier, - runAuthorizationCodeRedirect, - startCallbackServer, -} from "./oauth-authorization-flow" -import type { OAuthTokenData } from "./storage" -import { loadToken, saveToken } from "./storage" - -type McpOAuthProviderOptions = { - serverUrl: string - clientId?: string - scopes?: string[] -} - -export class McpOAuthProvider { - private readonly serverUrl: string - private readonly configClientId: string | undefined - private readonly scopes: string[] - private storedCodeVerifier: string | null = null - private storedClientInfo: ClientCredentials | null = null - private callbackPort: number | null = null - - constructor(options: McpOAuthProviderOptions) { - this.serverUrl = options.serverUrl - this.configClientId = options.clientId - this.scopes = options.scopes ?? [] - } - - tokens(): OAuthTokenData | null { - return loadToken(this.serverUrl, this.serverUrl) - } - - saveTokens(tokenData: OAuthTokenData): boolean { - return saveToken(this.serverUrl, this.serverUrl, tokenData) - } - - clientInformation(): ClientCredentials | null { - if (this.storedClientInfo) return this.storedClientInfo - const tokenData = this.tokens() - if (tokenData?.clientInfo) { - this.storedClientInfo = tokenData.clientInfo - return this.storedClientInfo - } - return null - } - - redirectUrl(): string { - return `http://127.0.0.1:${this.callbackPort ?? 19877}/callback` - } - - saveCodeVerifier(verifier: string): void { - this.storedCodeVerifier = verifier - } - - codeVerifier(): string | null { - return this.storedCodeVerifier - } - - async redirectToAuthorization(metadata: OAuthServerMetadata): Promise<{ code: string }> { - const clientInfo = this.clientInformation() - if (!clientInfo) { - throw new Error("No client information available. Run login() or register a client first.") - } - - if (this.callbackPort === null) { - this.callbackPort = await findAvailablePort() - } - - const result = await runAuthorizationCodeRedirect({ - authorizationEndpoint: metadata.authorizationEndpoint, - callbackPort: this.callbackPort, - clientId: clientInfo.clientId, - redirectUri: this.redirectUrl(), - scopes: this.scopes, - resource: metadata.resource, - }) - - this.saveCodeVerifier(result.verifier) - return { code: result.code } - } - - async login(): Promise { - const metadata = await discoverOAuthServerMetadata(this.serverUrl) - - const clientRegistrationStorage: ClientRegistrationStorage = { - getClientRegistration: () => this.storedClientInfo, - setClientRegistration: (_serverIdentifier: string, credentials: ClientCredentials) => { - this.storedClientInfo = credentials - }, - } - - const clientInfo = await getOrRegisterClient({ - registrationEndpoint: metadata.registrationEndpoint, - serverIdentifier: this.serverUrl, - clientName: "matrixx", - redirectUris: [this.redirectUrl()], - tokenEndpointAuthMethod: "none", - clientId: this.configClientId, - storage: clientRegistrationStorage, - }) - - if (!clientInfo) { - throw new Error("Failed to obtain client credentials. Provide a clientId or ensure the server supports DCR.") - } - - this.storedClientInfo = clientInfo - - const { code } = await this.redirectToAuthorization(metadata) - const verifier = this.codeVerifier() - if (!verifier) { - throw new Error("Code verifier not found") - } - - const tokenResponse = await fetch(metadata.tokenEndpoint, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: this.redirectUrl(), - client_id: clientInfo.clientId, - code_verifier: verifier, - ...(metadata.resource ? { resource: metadata.resource } : {}), - }).toString(), - }) - - if (!tokenResponse.ok) { - let errorDetail = `${tokenResponse.status}` - try { - const body = (await tokenResponse.json()) as Record - if (body.error) { - errorDetail = `${tokenResponse.status} ${body.error}` - if (body.error_description) { - errorDetail += `: ${body.error_description}` - } - } - } catch { - // Response body not JSON - } - throw new Error(`Token exchange failed: ${errorDetail}`) - } - - const tokenData = (await tokenResponse.json()) as Record - const accessToken = tokenData.access_token - if (typeof accessToken !== "string") { - throw new Error("Token response missing access_token") - } - - const oauthTokenData: OAuthTokenData = { - accessToken, - refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : undefined, - expiresAt: - typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined, - clientInfo: { - clientId: clientInfo.clientId, - clientSecret: clientInfo.clientSecret, - }, - } - - this.saveTokens(oauthTokenData) - return oauthTokenData - } -} - -export { buildAuthorizationUrl, generateCodeChallenge, generateCodeVerifier, startCallbackServer } diff --git a/src/features/mcp-oauth/step-up.ts b/src/features/mcp-oauth/step-up.ts deleted file mode 100644 index 39856bc1592..00000000000 --- a/src/features/mcp-oauth/step-up.ts +++ /dev/null @@ -1,79 +0,0 @@ -interface StepUpInfo { - requiredScopes: string[] - error?: string - errorDescription?: string -} - -export function parseWwwAuthenticate(header: string): StepUpInfo | null { - const trimmed = header.trim() - const lowerHeader = trimmed.toLowerCase() - const bearerIndex = lowerHeader.indexOf("bearer") - if (bearerIndex === -1) { - return null - } - - const params = trimmed.slice(bearerIndex + "bearer".length).trim() - if (params.length === 0) { - return null - } - - const scope = extractParam(params, "scope") - if (scope === null) { - return null - } - - const requiredScopes = scope - .split(/\s+/) - .filter((s) => s.length > 0) - - if (requiredScopes.length === 0) { - return null - } - - const info: StepUpInfo = { requiredScopes } - - const error = extractParam(params, "error") - if (error !== null) { - info.error = error - } - - const errorDescription = extractParam(params, "error_description") - if (errorDescription !== null) { - info.errorDescription = errorDescription - } - - return info -} - -function extractParam(params: string, name: string): string | null { - const quotedPattern = new RegExp(`${name}="([^"]*)"`) - const quotedMatch = quotedPattern.exec(params) - if (quotedMatch) { - return quotedMatch[1] - } - - const unquotedPattern = new RegExp(`${name}=([^\\s,]+)`) - const unquotedMatch = unquotedPattern.exec(params) - return unquotedMatch?.[1] ?? null -} - -export function mergeScopes(existing: string[], required: string[]): string[] { - const set = new Set(existing) - for (const scope of required) { - set.add(scope) - } - return [...set] -} - -export function isStepUpRequired(statusCode: number, headers: Record): StepUpInfo | null { - if (statusCode !== 403) { - return null - } - - const wwwAuth = headers["www-authenticate"] ?? headers["WWW-Authenticate"] - if (!wwwAuth) { - return null - } - - return parseWwwAuthenticate(wwwAuth) -} diff --git a/src/features/mcp-oauth/storage.ts b/src/features/mcp-oauth/storage.ts deleted file mode 100644 index d041bdfd12e..00000000000 --- a/src/features/mcp-oauth/storage.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" -import { dirname, join } from "node:path" -import { getOpenCodeConfigDir } from "../../shared" - -export interface OAuthTokenData { - accessToken: string - refreshToken?: string - expiresAt?: number - clientInfo?: { - clientId: string - clientSecret?: string - } -} - -type TokenStore = Record - -const STORAGE_FILE_NAME = "mcp-oauth.json" - -export function getMcpOauthStoragePath(): string { - return join(getOpenCodeConfigDir({ binary: "opencode" }), STORAGE_FILE_NAME) -} - -function normalizeHost(serverHost: string): string { - let host = serverHost.trim() - if (!host) return host - - if (host.includes("://")) { - try { - host = new URL(host).hostname - } catch { - host = host.split("/")[0] - } - } else { - host = host.split("/")[0] - } - - if (host.startsWith("[")) { - const closing = host.indexOf("]") - if (closing !== -1) { - host = host.slice(0, closing + 1) - } - return host - } - - if (host.includes(":")) { - host = host.split(":")[0] - } - - return host -} - -function normalizeResource(resource: string): string { - return resource.replace(/^\/+/, "") -} - -function buildKey(serverHost: string, resource: string): string { - const host = normalizeHost(serverHost) - const normalizedResource = normalizeResource(resource) - return `${host}/${normalizedResource}` -} - -function readStore(): TokenStore | null { - const filePath = getMcpOauthStoragePath() - if (!existsSync(filePath)) { - return null - } - - try { - const content = readFileSync(filePath, "utf-8") - return JSON.parse(content) as TokenStore - } catch { - return null - } -} - -function writeStore(store: TokenStore): boolean { - const filePath = getMcpOauthStoragePath() - - try { - const dir = dirname(filePath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 }) - chmodSync(filePath, 0o600) - return true - } catch { - return false - } -} - -export function loadToken(serverHost: string, resource: string): OAuthTokenData | null { - const store = readStore() - if (!store) return null - - const key = buildKey(serverHost, resource) - return store[key] ?? null -} - -export function saveToken(serverHost: string, resource: string, token: OAuthTokenData): boolean { - const store = readStore() ?? {} - const key = buildKey(serverHost, resource) - store[key] = token - return writeStore(store) -} - -export function deleteToken(serverHost: string, resource: string): boolean { - const store = readStore() - if (!store) return true - - const key = buildKey(serverHost, resource) - if (!(key in store)) { - return true - } - - delete store[key] - - if (Object.keys(store).length === 0) { - try { - const filePath = getMcpOauthStoragePath() - if (existsSync(filePath)) { - unlinkSync(filePath) - } - return true - } catch { - return false - } - } - - return writeStore(store) -} - -export function listTokensByHost(serverHost: string): TokenStore { - const store = readStore() - if (!store) return {} - - const host = normalizeHost(serverHost) - const prefix = `${host}/` - const result: TokenStore = {} - - for (const [key, value] of Object.entries(store)) { - if (key.startsWith(prefix)) { - result[key] = value - } - } - - return result -} - -export function listAllTokens(): TokenStore { - return readStore() ?? {} -} diff --git a/src/features/opencode-skill-loader/allowed-tools-parser.ts b/src/features/opencode-skill-loader/allowed-tools-parser.ts deleted file mode 100644 index 0bf1354d01b..00000000000 --- a/src/features/opencode-skill-loader/allowed-tools-parser.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined { - if (!allowedTools) return undefined - - if (Array.isArray(allowedTools)) { - return allowedTools.map((tool) => tool.trim()).filter(Boolean) - } - - return allowedTools.split(/\s+/).filter(Boolean) -} diff --git a/src/features/opencode-skill-loader/config-source-discovery.ts b/src/features/opencode-skill-loader/config-source-discovery.ts deleted file mode 100644 index 3dc16008e04..00000000000 --- a/src/features/opencode-skill-loader/config-source-discovery.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { promises as fs } from "node:fs" -import { dirname, extname, isAbsolute, join, relative } from "node:path" -import picomatch from "picomatch" -import type { SkillsConfig } from "../../config/schema" -import { inferSkillNameFromFileName, loadSkillFromPath } from "./loaded-skill-from-path" -import { normalizeSkillsConfig } from "./merger/skills-config-normalizer" -import { deduplicateSkillsByName } from "./skill-deduplication" -import { loadSkillsFromDir } from "./skill-directory-loader" -import type { LoadedSkill } from "./types" - -const MAX_RECURSIVE_DEPTH = 10 - -function isHttpUrl(path: string): boolean { - return path.startsWith("http://") || path.startsWith("https://") -} - -function toAbsolutePath(path: string, configDir: string): string { - if (isAbsolute(path)) { - return path - } - return join(configDir, path) -} - -function isMarkdownPath(path: string): boolean { - return extname(path).toLowerCase() === ".md" -} - -export function normalizePathForGlob(path: string): string { - return path.split("\\").join("/") -} - -function filterByGlob(skills: LoadedSkill[], sourceBaseDir: string, globPattern?: string): LoadedSkill[] { - if (!globPattern) return skills - - return skills.filter((skill) => { - if (!skill.path) return false - const rel = normalizePathForGlob(relative(sourceBaseDir, skill.path)) - return picomatch.isMatch(rel, globPattern, { dot: true, bash: true }) - }) -} - -async function loadSourcePath(options: { - sourcePath: string - recursive: boolean - globPattern?: string - configDir: string -}): Promise { - if (isHttpUrl(options.sourcePath)) { - return [] - } - - const absolutePath = toAbsolutePath(options.sourcePath, options.configDir) - const stat = await fs.stat(absolutePath).catch(() => null) - if (!stat) return [] - - if (stat.isFile()) { - if (!isMarkdownPath(absolutePath)) return [] - const loaded = await loadSkillFromPath({ - skillPath: absolutePath, - resolvedPath: dirname(absolutePath), - defaultName: inferSkillNameFromFileName(absolutePath), - scope: "config", - }) - if (!loaded) return [] - return filterByGlob([loaded], dirname(absolutePath), options.globPattern) - } - - if (!stat.isDirectory()) return [] - - const directorySkills = await loadSkillsFromDir({ - skillsDir: absolutePath, - scope: "config", - maxDepth: options.recursive ? MAX_RECURSIVE_DEPTH : 0, - }) - return filterByGlob(directorySkills, absolutePath, options.globPattern) -} - -export async function discoverConfigSourceSkills(options: { - config: SkillsConfig | undefined - configDir: string -}): Promise { - const normalized = normalizeSkillsConfig(options.config) - if (normalized.sources.length === 0) return [] - - const loadedBySource = await Promise.all( - normalized.sources.map((source) => { - if (typeof source === "string") { - return loadSourcePath({ - sourcePath: source, - recursive: false, - configDir: options.configDir, - }) - } - - return loadSourcePath({ - sourcePath: source.path, - recursive: source.recursive ?? false, - globPattern: source.glob, - configDir: options.configDir, - }) - }), - ) - - return deduplicateSkillsByName(loadedBySource.flat()) -} diff --git a/src/features/opencode-skill-loader/index.ts b/src/features/opencode-skill-loader/index.ts deleted file mode 100644 index 0b74a650da4..00000000000 --- a/src/features/opencode-skill-loader/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export * from "./config-source-discovery" -export * from "./loaded-skill-from-path" -export * from "./loaded-skill-template-extractor" -export * from "./loader" -export * from "./merger" -export * from "./skill-content" -export * from "./skill-deduplication" -export * from "./skill-definition-record" -export * from "./skill-directory-loader" - -export * from "./skill-discovery" -export * from "./skill-mcp-config" -export * from "./skill-resolution-options" -export * from "./skill-template-resolver" -export * from "./types" diff --git a/src/features/opencode-skill-loader/loaded-skill-from-path.ts b/src/features/opencode-skill-loader/loaded-skill-from-path.ts deleted file mode 100644 index 4178d931cb1..00000000000 --- a/src/features/opencode-skill-loader/loaded-skill-from-path.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { promises as fs } from "node:fs" -import { basename } from "node:path" -import { parseFrontmatter } from "../../shared/frontmatter" -import { sanitizeModelField } from "../../shared/model-sanitizer" -import { resolveSkillPathReferences } from "../../shared/skill-path-resolver" -import type { CommandDefinition } from "../command-loader/types" -import { parseAllowedTools } from "./allowed-tools-parser" -import { loadMcpJsonFromDir, parseSkillMcpConfigFromFrontmatter } from "./skill-mcp-config" -import type { LazyContentLoader, LoadedSkill, SkillMetadata, SkillScope } from "./types" - -export async function loadSkillFromPath(options: { - skillPath: string - resolvedPath: string - defaultName: string - scope: SkillScope - namePrefix?: string -}): Promise { - const namePrefix = options.namePrefix ?? "" - - try { - const content = await fs.readFile(options.skillPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) - const mcpJsonMcp = await loadMcpJsonFromDir(options.resolvedPath) - const mcpConfig = mcpJsonMcp || frontmatterMcp - - const baseName = data.name || options.defaultName - const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName - const originalDescription = data.description || "" - const isOpencodeSource = options.scope === "opencode" || options.scope === "opencode-project" - const formattedDescription = `(${options.scope} - Skill) ${originalDescription}` - - const resolvedBody = resolveSkillPathReferences(body.trim(), options.resolvedPath) - const templateContent = `\nBase directory for this skill: ${options.resolvedPath}/\nFile references (@path) in this skill are relative to this directory.\n\n${resolvedBody}\n\n\n\n$ARGUMENTS\n` - - const eagerLoader: LazyContentLoader = { - loaded: true, - content: templateContent, - load: async () => templateContent, - } - - const definition: CommandDefinition = { - name: skillName, - description: formattedDescription, - template: templateContent, - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - agent: data.agent, - subtask: data.subtask, - argumentHint: data["argument-hint"], - } - - return { - name: skillName, - path: options.skillPath, - resolvedPath: options.resolvedPath, - definition, - scope: options.scope, - license: data.license, - compatibility: data.compatibility, - metadata: data.metadata, - allowedTools: parseAllowedTools(data["allowed-tools"]), - mcpConfig, - lazyContent: eagerLoader, - } - } catch { - return null - } -} - -export function inferSkillNameFromFileName(filePath: string): string { - return basename(filePath, ".md") -} diff --git a/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts b/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts deleted file mode 100644 index ba20552e52a..00000000000 --- a/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readFileSync } from "node:fs" -import { parseFrontmatter } from "../../shared/frontmatter" -import type { LoadedSkill } from "./types" - -export function extractSkillTemplate(skill: LoadedSkill): string { - if (skill.path) { - const content = readFileSync(skill.path, "utf-8") - const { body } = parseFrontmatter(content) - return body.trim() - } - return skill.definition.template || "" -} diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts deleted file mode 100644 index d61a6d3b902..00000000000 --- a/src/features/opencode-skill-loader/loader.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { homedir } from "node:os" -import { join } from "node:path" -import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" -import type { CommandDefinition } from "../command-loader/types" -import { deduplicateSkillsByName } from "./skill-deduplication" -import { skillsToCommandDefinitionRecord } from "./skill-definition-record" -import { loadSkillsFromDir } from "./skill-directory-loader" -import type { LoadedSkill } from "./types" - -export async function loadOpencodeGlobalSkills(): Promise> { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const opencodeSkillsDir = join(configDir, "skills") - const skills = await loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" }) - return skillsToCommandDefinitionRecord(skills) -} - -export async function loadOpencodeProjectSkills(directory?: string): Promise> { - const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills") - const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) - return skillsToCommandDefinitionRecord(skills) -} - -interface DiscoverSkillsOptions { - includeClaudeCodePaths?: boolean - directory?: string -} - -export async function discoverAllSkills(directory?: string): Promise { - const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = - await Promise.all([ - discoverOpencodeProjectSkills(directory), - discoverOpencodeGlobalSkills(), - discoverProjectClaudeSkills(directory), - discoverUserClaudeSkills(), - discoverProjectAgentsSkills(directory), - discoverGlobalAgentsSkills(), - ]) - - // Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents) - return deduplicateSkillsByName([ - ...opencodeProjectSkills, - ...opencodeGlobalSkills, - ...projectSkills, - ...agentsProjectSkills, - ...userSkills, - ...agentsGlobalSkills, - ]) -} - -export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise { - const { includeClaudeCodePaths = true, directory } = options - - const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([ - discoverOpencodeProjectSkills(directory), - discoverOpencodeGlobalSkills(), - ]) - - if (!includeClaudeCodePaths) { - // Priority: opencode-project > opencode - return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills]) - } - - const [projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = await Promise.all([ - discoverProjectClaudeSkills(directory), - discoverUserClaudeSkills(), - discoverProjectAgentsSkills(directory), - discoverGlobalAgentsSkills(), - ]) - - // Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents) - return deduplicateSkillsByName([ - ...opencodeProjectSkills, - ...opencodeGlobalSkills, - ...projectSkills, - ...agentsProjectSkills, - ...userSkills, - ...agentsGlobalSkills, - ]) -} - -export async function discoverUserClaudeSkills(): Promise { - const userSkillsDir = join(getOpenCodeConfigDir({ binary: "opencode" }), "skills") - return loadSkillsFromDir({ skillsDir: userSkillsDir, scope: "user" }) -} - -export async function discoverProjectClaudeSkills(directory?: string): Promise { - const projectSkillsDir = join(directory ?? process.cwd(), ".opencode", "skills") - return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) -} - -export async function discoverOpencodeGlobalSkills(): Promise { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const opencodeSkillsDir = join(configDir, "skills") - return loadSkillsFromDir({ skillsDir: opencodeSkillsDir, scope: "opencode" }) -} - -export async function discoverOpencodeProjectSkills(directory?: string): Promise { - const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills") - return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) -} - -export async function discoverProjectAgentsSkills(directory?: string): Promise { - const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills") - return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" }) -} - -export async function discoverGlobalAgentsSkills(): Promise { - const agentsGlobalDir = join(homedir(), ".agents", "skills") - return loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" }) -} diff --git a/src/features/opencode-skill-loader/merger.ts b/src/features/opencode-skill-loader/merger.ts deleted file mode 100644 index e0ec7ccdd68..00000000000 --- a/src/features/opencode-skill-loader/merger.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { SkillsConfig } from "../../config/schema" -import type { BuiltinSkill } from "../builtin-skills/types" -import { builtinToLoadedSkill } from "./merger/builtin-skill-converter" -import { configEntryToLoadedSkill } from "./merger/config-skill-entry-loader" -import { SCOPE_PRIORITY } from "./merger/scope-priority" -import { mergeSkillDefinitions } from "./merger/skill-definition-merger" -import { normalizeSkillsConfig } from "./merger/skills-config-normalizer" -import type { LoadedSkill } from "./types" - -interface MergeSkillsOptions { - configDir?: string -} - -export function mergeSkills( - builtinSkills: BuiltinSkill[], - config: SkillsConfig | undefined, - configSourceSkills: LoadedSkill[], - userClaudeSkills: LoadedSkill[], - userOpencodeSkills: LoadedSkill[], - projectClaudeSkills: LoadedSkill[], - projectOpencodeSkills: LoadedSkill[], - options: MergeSkillsOptions = {} -): LoadedSkill[] { - const skillMap = new Map() - - for (const builtin of builtinSkills) { - const loaded = builtinToLoadedSkill(builtin) - skillMap.set(loaded.name, loaded) - } - - const normalizedConfig = normalizeSkillsConfig(config) - - for (const [name, entry] of Object.entries(normalizedConfig.entries)) { - if (entry === false) continue - if (entry === true) continue - - if (entry.disable) continue - - const loaded = configEntryToLoadedSkill(name, entry, options.configDir) - if (loaded) { - const existing = skillMap.get(name) - if (existing && !entry.template && !entry.from) { - skillMap.set(name, mergeSkillDefinitions(existing, entry)) - } else { - skillMap.set(name, loaded) - } - } - } - - const fileSystemSkills = [ - ...configSourceSkills, - ...userClaudeSkills, - ...userOpencodeSkills, - ...projectClaudeSkills, - ...projectOpencodeSkills, - ] - - for (const skill of fileSystemSkills) { - const existing = skillMap.get(skill.name) - if (!existing || SCOPE_PRIORITY[skill.scope] > SCOPE_PRIORITY[existing.scope]) { - skillMap.set(skill.name, skill) - } - } - - for (const [name, entry] of Object.entries(normalizedConfig.entries)) { - if (entry === true) continue - if (entry === false) { - skillMap.delete(name) - continue - } - if (entry.disable) { - skillMap.delete(name) - continue - } - - const existing = skillMap.get(name) - if (existing && !entry.template && !entry.from) { - skillMap.set(name, mergeSkillDefinitions(existing, entry)) - } - } - - for (const name of normalizedConfig.disable) { - skillMap.delete(name) - } - - if (normalizedConfig.enable.length > 0) { - const enableSet = new Set(normalizedConfig.enable) - for (const name of skillMap.keys()) { - if (!enableSet.has(name)) { - skillMap.delete(name) - } - } - } - - return Array.from(skillMap.values()) -} diff --git a/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts b/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts deleted file mode 100644 index 1f92b5c076b..00000000000 --- a/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { BuiltinSkill } from "../../builtin-skills/types" -import type { CommandDefinition } from "../../command-loader/types" -import type { LoadedSkill } from "../types" - -export function builtinToLoadedSkill(builtin: BuiltinSkill): LoadedSkill { - const definition: CommandDefinition = { - name: builtin.name, - description: `(opencode - Skill) ${builtin.description}`, - template: builtin.template, - model: builtin.model, - agent: builtin.agent, - subtask: builtin.subtask, - argumentHint: builtin.argumentHint, - } - - return { - name: builtin.name, - definition, - scope: "builtin", - license: builtin.license, - compatibility: builtin.compatibility, - metadata: builtin.metadata as Record | undefined, - allowedTools: builtin.allowedTools, - mcpConfig: builtin.mcpConfig, - } -} diff --git a/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts b/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts deleted file mode 100644 index e5671de2fab..00000000000 --- a/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { dirname, isAbsolute, resolve } from "node:path" -import type { SkillDefinition } from "../../../config/schema" -import { parseFrontmatter } from "../../../shared/frontmatter" -import { sanitizeModelField } from "../../../shared/model-sanitizer" -import { resolveSkillPathReferences } from "../../../shared/skill-path-resolver" -import type { CommandDefinition } from "../../command-loader/types" -import { parseAllowedTools } from "../allowed-tools-parser" -import type { LoadedSkill, SkillMetadata } from "../types" - -function resolveFilePath(from: string, configDir?: string): string { - let filePath = from - - if (filePath.startsWith("{file:") && filePath.endsWith("}")) { - filePath = filePath.slice(6, -1) - } - - if (filePath.startsWith("~/")) { - return resolve(homedir(), filePath.slice(2)) - } - - if (isAbsolute(filePath)) { - return filePath - } - - const baseDir = configDir || process.cwd() - return resolve(baseDir, filePath) -} - -function loadSkillFromFile(filePath: string): { template: string; metadata: SkillMetadata } | null { - try { - if (!existsSync(filePath)) return null - const content = readFileSync(filePath, "utf-8") - const { data, body } = parseFrontmatter(content) - return { template: body, metadata: data } - } catch { - return null - } -} - -export function configEntryToLoadedSkill( - name: string, - entry: SkillDefinition, - configDir?: string -): LoadedSkill | null { - let template = entry.template || "" - let fileMetadata: SkillMetadata = {} - - if (entry.from) { - const filePath = resolveFilePath(entry.from, configDir) - const loaded = loadSkillFromFile(filePath) - if (loaded) { - template = loaded.template - fileMetadata = loaded.metadata - } else { - return null - } - } - - if (!template && !entry.from) { - return null - } - - const description = entry.description || fileMetadata.description || "" - const resolvedPath = entry.from - ? dirname(resolveFilePath(entry.from, configDir)) - : configDir || process.cwd() - - const resolvedTemplate = resolveSkillPathReferences(template.trim(), resolvedPath) - const wrappedTemplate = ` -Base directory for this skill: ${resolvedPath}/ -File references (@path) in this skill are relative to this directory. - -${resolvedTemplate} - - - -$ARGUMENTS -` - - const definition: CommandDefinition = { - name, - description: `(config - Skill) ${description}`, - template: wrappedTemplate, - model: sanitizeModelField(entry.model || fileMetadata.model, "opencode"), - agent: entry.agent || fileMetadata.agent, - subtask: entry.subtask ?? fileMetadata.subtask, - argumentHint: entry["argument-hint"] || fileMetadata["argument-hint"], - } - - const allowedTools = entry["allowed-tools"] || parseAllowedTools(fileMetadata["allowed-tools"]) - - return { - name, - path: entry.from ? resolveFilePath(entry.from, configDir) : undefined, - resolvedPath, - definition, - scope: "config", - license: entry.license || fileMetadata.license, - compatibility: entry.compatibility || fileMetadata.compatibility, - metadata: (entry.metadata as Record | undefined) || fileMetadata.metadata, - allowedTools, - } -} diff --git a/src/features/opencode-skill-loader/merger/scope-priority.ts b/src/features/opencode-skill-loader/merger/scope-priority.ts deleted file mode 100644 index c665d700c81..00000000000 --- a/src/features/opencode-skill-loader/merger/scope-priority.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SkillScope } from "../types" - -export const SCOPE_PRIORITY: Record = { - builtin: 1, - config: 2, - user: 3, - opencode: 4, - project: 5, - "opencode-project": 6, -} diff --git a/src/features/opencode-skill-loader/merger/skill-definition-merger.ts b/src/features/opencode-skill-loader/merger/skill-definition-merger.ts deleted file mode 100644 index 09020f366fa..00000000000 --- a/src/features/opencode-skill-loader/merger/skill-definition-merger.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { SkillDefinition } from "../../../config/schema" -import { deepMerge } from "../../../shared/deep-merge" -import type { LoadedSkill } from "../types" - -export function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): LoadedSkill { - const mergedMetadata = base.metadata || patch.metadata - ? deepMerge(base.metadata || {}, (patch.metadata as Record) || {}) - : undefined - - const mergedTools = base.allowedTools || patch["allowed-tools"] - ? [...(base.allowedTools || []), ...(patch["allowed-tools"] || [])] - : undefined - - const description = patch.description || base.definition.description?.replace(/^\([^)]+\) /, "") - - return { - ...base, - definition: { - ...base.definition, - description: `(${base.scope} - Skill) ${description}`, - model: patch.model || base.definition.model, - agent: patch.agent || base.definition.agent, - subtask: patch.subtask ?? base.definition.subtask, - argumentHint: patch["argument-hint"] || base.definition.argumentHint, - }, - license: patch.license || base.license, - compatibility: patch.compatibility || base.compatibility, - metadata: mergedMetadata as Record | undefined, - allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined, - } -} diff --git a/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts b/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts deleted file mode 100644 index 8bcb4c46af6..00000000000 --- a/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { SkillDefinition, SkillsConfig } from "../../../config/schema" - -export function normalizeSkillsConfig(config: SkillsConfig | undefined): { - sources: Array - enable: string[] - disable: string[] - entries: Record -} { - if (!config) { - return { sources: [], enable: [], disable: [], entries: {} } - } - - if (Array.isArray(config)) { - return { sources: [], enable: config, disable: [], entries: {} } - } - - const { sources = [], enable = [], disable = [], ...entries } = config - return { sources, enable, disable, entries } -} diff --git a/src/features/opencode-skill-loader/skill-content.ts b/src/features/opencode-skill-loader/skill-content.ts deleted file mode 100644 index 1e06cda35c4..00000000000 --- a/src/features/opencode-skill-loader/skill-content.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { extractSkillTemplate } from "./loaded-skill-template-extractor" - -export { clearSkillCache, getAllSkills } from "./skill-discovery" -export type { SkillResolutionOptions } from "./skill-resolution-options" -export { - resolveMultipleSkills, - resolveMultipleSkillsAsync, - resolveSkillContent, - resolveSkillContentAsync, -} from "./skill-template-resolver" diff --git a/src/features/opencode-skill-loader/skill-deduplication.ts b/src/features/opencode-skill-loader/skill-deduplication.ts deleted file mode 100644 index 1c3c6a40598..00000000000 --- a/src/features/opencode-skill-loader/skill-deduplication.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { LoadedSkill } from "./types" - -export function deduplicateSkillsByName(skills: LoadedSkill[]): LoadedSkill[] { - const seen = new Set() - const result: LoadedSkill[] = [] - for (const skill of skills) { - if (!seen.has(skill.name)) { - seen.add(skill.name) - result.push(skill) - } - } - return result -} diff --git a/src/features/opencode-skill-loader/skill-definition-record.ts b/src/features/opencode-skill-loader/skill-definition-record.ts deleted file mode 100644 index 4534a9a6c16..00000000000 --- a/src/features/opencode-skill-loader/skill-definition-record.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CommandDefinition } from "../command-loader/types" -import type { LoadedSkill } from "./types" - -export function skillsToCommandDefinitionRecord(skills: LoadedSkill[]): Record { - const result: Record = {} - for (const skill of skills) { - const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = skill.definition - result[skill.name] = openCodeCompatible as CommandDefinition - } - return result -} diff --git a/src/features/opencode-skill-loader/skill-directory-loader.ts b/src/features/opencode-skill-loader/skill-directory-loader.ts deleted file mode 100644 index f3488c94945..00000000000 --- a/src/features/opencode-skill-loader/skill-directory-loader.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { promises as fs } from "node:fs" -import { join } from "node:path" -import { isMarkdownFile, resolveSymlinkAsync } from "../../shared/file-utils" -import { inferSkillNameFromFileName, loadSkillFromPath } from "./loaded-skill-from-path" -import type { LoadedSkill, SkillScope } from "./types" - -export async function loadSkillsFromDir(options: { - skillsDir: string - scope: SkillScope - namePrefix?: string - depth?: number - maxDepth?: number -}): Promise { - const namePrefix = options.namePrefix ?? "" - const depth = options.depth ?? 0 - const maxDepth = options.maxDepth ?? 2 - - const entries = await fs.readdir(options.skillsDir, { withFileTypes: true }).catch(() => []) - const skillMap = new Map() - - const directories = entries.filter( - (entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()) - ) - const files = entries.filter( - (entry) => - !entry.name.startsWith(".") && - !entry.isDirectory() && - !entry.isSymbolicLink() && - isMarkdownFile(entry) - ) - - for (const entry of directories) { - const entryPath = join(options.skillsDir, entry.name) - const resolvedPath = await resolveSymlinkAsync(entryPath) - const dirName = entry.name - - const skillMdPath = join(resolvedPath, "SKILL.md") - try { - await fs.access(skillMdPath) - const skill = await loadSkillFromPath({ - skillPath: skillMdPath, - resolvedPath, - defaultName: dirName, - scope: options.scope, - namePrefix, - }) - if (skill && !skillMap.has(skill.name)) { - skillMap.set(skill.name, skill) - } - continue - } catch { - // no SKILL.md - } - - const namedSkillMdPath = join(resolvedPath, `${dirName}.md`) - try { - await fs.access(namedSkillMdPath) - const skill = await loadSkillFromPath({ - skillPath: namedSkillMdPath, - resolvedPath, - defaultName: dirName, - scope: options.scope, - namePrefix, - }) - if (skill && !skillMap.has(skill.name)) { - skillMap.set(skill.name, skill) - } - continue - } catch { - // no named md - } - - if (depth < maxDepth) { - const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName - const nestedSkills = await loadSkillsFromDir({ - skillsDir: resolvedPath, - scope: options.scope, - namePrefix: newPrefix, - depth: depth + 1, - maxDepth, - }) - for (const nestedSkill of nestedSkills) { - if (!skillMap.has(nestedSkill.name)) { - skillMap.set(nestedSkill.name, nestedSkill) - } - } - } - } - - for (const entry of files) { - const entryPath = join(options.skillsDir, entry.name) - const baseName = inferSkillNameFromFileName(entryPath) - const skill = await loadSkillFromPath({ - skillPath: entryPath, - resolvedPath: options.skillsDir, - defaultName: baseName, - scope: options.scope, - namePrefix, - }) - if (skill && !skillMap.has(skill.name)) { - skillMap.set(skill.name, skill) - } - } - - return Array.from(skillMap.values()) -} diff --git a/src/features/opencode-skill-loader/skill-discovery.ts b/src/features/opencode-skill-loader/skill-discovery.ts deleted file mode 100644 index 94a19f49f6b..00000000000 --- a/src/features/opencode-skill-loader/skill-discovery.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createBuiltinSkills } from "../builtin-skills/skills" -import { discoverSkills } from "./loader" -import type { SkillResolutionOptions } from "./skill-resolution-options" -import type { LoadedSkill } from "./types" - -const cachedSkillsByProvider = new Map() - -export function clearSkillCache(): void { - cachedSkillsByProvider.clear() -} - -export async function getAllSkills(options?: SkillResolutionOptions): Promise { - const cacheKey = options?.browserProvider ?? "playwright" - const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0 - - // Skip cache if disabledSkills is provided (varies between calls) - if (!hasDisabledSkills) { - const cached = cachedSkillsByProvider.get(cacheKey) - if (cached) return cached - } - - const [discoveredSkills, builtinSkillDefinitions] = await Promise.all([ - discoverSkills({ includeClaudeCodePaths: true, directory: options?.directory }), - Promise.resolve( - createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - ), - ]) - - const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefinitions.map((skill) => ({ - name: skill.name, - definition: { - name: skill.name, - description: skill.description, - template: skill.template, - model: skill.model, - agent: skill.agent, - subtask: skill.subtask, - }, - scope: "builtin" as const, - license: skill.license, - compatibility: skill.compatibility, - metadata: skill.metadata as Record | undefined, - allowedTools: skill.allowedTools, - mcpConfig: skill.mcpConfig, - })) - - // Provider-gated skill names that should be filtered based on browserProvider - const providerGatedSkillNames = new Set(["agent-browser", "playwright"]) - const browserProvider = options?.browserProvider ?? "playwright" - - // Filter discovered skills to exclude provider-gated names that don't match the selected provider - const filteredDiscoveredSkills = discoveredSkills.filter((skill) => { - if (!providerGatedSkillNames.has(skill.name)) { - return true - } - // For provider-gated skills, only include if it matches the selected provider - return skill.name === browserProvider - }) - - const discoveredNames = new Set(filteredDiscoveredSkills.map((skill) => skill.name)) - const uniqueBuiltins = builtinSkillsAsLoaded.filter((skill) => !discoveredNames.has(skill.name)) - - let allSkills = [...filteredDiscoveredSkills, ...uniqueBuiltins] - - // Filter discovered skills by disabledSkills (builtin skills are already filtered by createBuiltinSkills) - if (hasDisabledSkills) { - allSkills = allSkills.filter((skill) => !options?.disabledSkills?.has(skill.name)) - } else { - cachedSkillsByProvider.set(cacheKey, allSkills) - } - - return allSkills -} diff --git a/src/features/opencode-skill-loader/skill-mcp-config.ts b/src/features/opencode-skill-loader/skill-mcp-config.ts deleted file mode 100644 index 21cffa50ee4..00000000000 --- a/src/features/opencode-skill-loader/skill-mcp-config.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { promises as fs } from "node:fs" -import { join } from "node:path" -import yaml from "js-yaml" -import type { SkillMcpConfig } from "../skill-mcp-manager/types" - -export function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined { - const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!frontmatterMatch) return undefined - - try { - const parsed = yaml.load(frontmatterMatch[1]) as Record - if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) { - return parsed.mcp as SkillMcpConfig - } - } catch { - return undefined - } - return undefined -} - -export async function loadMcpJsonFromDir(skillDir: string): Promise { - const mcpJsonPath = join(skillDir, "mcp.json") - - try { - const content = await fs.readFile(mcpJsonPath, "utf-8") - const parsed = JSON.parse(content) as Record - - if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) { - return parsed.mcpServers as SkillMcpConfig - } - - if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) { - const hasCommandField = Object.values(parsed).some( - (value) => value && typeof value === "object" && "command" in (value as Record) - ) - if (hasCommandField) { - return parsed as SkillMcpConfig - } - } - } catch { - return undefined - } - - return undefined -} diff --git a/src/features/opencode-skill-loader/skill-resolution-options.ts b/src/features/opencode-skill-loader/skill-resolution-options.ts deleted file mode 100644 index 16dc5972850..00000000000 --- a/src/features/opencode-skill-loader/skill-resolution-options.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { BrowserAutomationProvider } from "../../config/schema" - -export interface SkillResolutionOptions { - browserProvider?: BrowserAutomationProvider - disabledSkills?: Set - /** Project directory to discover project-level skills from. Falls back to process.cwd() if not provided. */ - directory?: string -} diff --git a/src/features/opencode-skill-loader/skill-template-resolver.ts b/src/features/opencode-skill-loader/skill-template-resolver.ts deleted file mode 100644 index d16f560db99..00000000000 --- a/src/features/opencode-skill-loader/skill-template-resolver.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { createBuiltinSkills } from "../builtin-skills/skills" -import { extractSkillTemplate } from "./loaded-skill-template-extractor" -import { getAllSkills } from "./skill-discovery" -import type { SkillResolutionOptions } from "./skill-resolution-options" -import type { LoadedSkill } from "./types" - -export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null { - const skills = createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - const skill = skills.find((builtinSkill) => builtinSkill.name === skillName) - if (!skill) return null - - return skill.template -} - -export function resolveMultipleSkills( - skillNames: string[], - options?: SkillResolutionOptions -): { resolved: Map; notFound: string[] } { - const skills = createBuiltinSkills({ - browserProvider: options?.browserProvider, - disabledSkills: options?.disabledSkills, - }) - const skillMap = new Map(skills.map((skill) => [skill.name, skill.template])) - - const resolved = new Map() - const notFound: string[] = [] - - for (const name of skillNames) { - const template = skillMap.get(name) - if (template) { - resolved.set(name, template) - } else { - notFound.push(name) - } - } - - return { resolved, notFound } -} - -export async function resolveSkillContentAsync( - skillName: string, - options?: SkillResolutionOptions -): Promise { - const allSkills = await getAllSkills(options) - const skill = allSkills.find((loadedSkill) => loadedSkill.name === skillName) - if (!skill) return null - - const template = await extractSkillTemplate(skill) - - return template -} - -export async function resolveMultipleSkillsAsync( - skillNames: string[], - options?: SkillResolutionOptions -): Promise<{ resolved: Map; notFound: string[] }> { - const allSkills = await getAllSkills(options) - const skillMap = new Map() - for (const skill of allSkills) { - skillMap.set(skill.name, skill) - } - - const resolved = new Map() - const notFound: string[] = [] - - for (const name of skillNames) { - const skill = skillMap.get(name) - if (skill) { - const template = await extractSkillTemplate(skill) - resolved.set(name, template) - } else { - notFound.push(name) - } - } - - return { resolved, notFound } -} diff --git a/src/features/opencode-skill-loader/types.ts b/src/features/opencode-skill-loader/types.ts deleted file mode 100644 index 9de3dd446c1..00000000000 --- a/src/features/opencode-skill-loader/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CommandDefinition } from "../command-loader/types" -import type { SkillMcpConfig } from "../skill-mcp-manager/types" - -export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" - -export interface SkillMetadata { - name?: string - description?: string - model?: string - "argument-hint"?: string - agent?: string - subtask?: boolean - license?: string - compatibility?: string - metadata?: Record - "allowed-tools"?: string | string[] - mcp?: SkillMcpConfig -} - -export interface LazyContentLoader { - loaded: boolean - content?: string - load: () => Promise -} - -export interface LoadedSkill { - name: string - path?: string - resolvedPath?: string - definition: CommandDefinition - scope: SkillScope - license?: string - compatibility?: string - metadata?: Record - allowedTools?: string[] - mcpConfig?: SkillMcpConfig - lazyContent?: LazyContentLoader -} diff --git a/src/features/skill-mcp-manager/cleanup.ts b/src/features/skill-mcp-manager/cleanup.ts deleted file mode 100644 index 56b814416c2..00000000000 --- a/src/features/skill-mcp-manager/cleanup.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { log } from "../../shared/logger" -import type { ManagedClient, SkillMcpManagerState } from "./types" - -async function closeManagedClient(managed: ManagedClient): Promise { - try { - await managed.client.close() - } catch { - // Ignore close errors - process may already be terminated - } - - try { - await managed.transport.close() - } catch { - // Transport may already be terminated - } -} - -export function registerProcessCleanup(state: SkillMcpManagerState): void { - if (state.cleanupRegistered) return - state.cleanupRegistered = true - - const cleanup = async (): Promise => { - for (const managed of state.clients.values()) { - await closeManagedClient(managed) - } - state.clients.clear() - state.pendingConnections.clear() - } - - // Note: Node's 'exit' event is synchronous-only, so we rely on signal handlers for async cleanup. - // Signal handlers invoke the async cleanup function and ignore errors so they don't block or throw. - // Don't call process.exit() here - let the background-agent manager handle the final process exit. - // Use void + catch to trigger async cleanup without awaiting it in the signal handler. - - const register = (signal: NodeJS.Signals) => { - const listener = () => void cleanup().catch((err) => { log("[skill-mcp] Signal handler cleanup failed:", err) }) - state.cleanupHandlers.push({ signal, listener }) - process.on(signal, listener) - } - - register("SIGINT") - register("SIGTERM") - if (process.platform === "win32") { - register("SIGBREAK") - } -} - -export function unregisterProcessCleanup(state: SkillMcpManagerState): void { - if (!state.cleanupRegistered) return - for (const { signal, listener } of state.cleanupHandlers) { - process.off(signal, listener) - } - state.cleanupHandlers = [] - state.cleanupRegistered = false -} - -export function startCleanupTimer(state: SkillMcpManagerState): void { - if (state.cleanupInterval) return - - state.cleanupInterval = setInterval(() => { - void cleanupIdleClients(state).catch((err) => { log("[skill-mcp] Idle client cleanup failed:", err) }) - }, 60_000) - - state.cleanupInterval.unref() -} - -function stopCleanupTimer(state: SkillMcpManagerState): void { - if (!state.cleanupInterval) return - clearInterval(state.cleanupInterval) - state.cleanupInterval = null -} - -async function cleanupIdleClients(state: SkillMcpManagerState): Promise { - const now = Date.now() - - for (const [key, managed] of state.clients) { - if (now - managed.lastUsedAt > state.idleTimeoutMs) { - state.clients.delete(key) - await closeManagedClient(managed) - } - } - - if (state.clients.size === 0) { - stopCleanupTimer(state) - } -} - -export async function disconnectSession(state: SkillMcpManagerState, sessionID: string): Promise { - const keysToRemove: string[] = [] - - for (const [key, managed] of state.clients.entries()) { - if (key.startsWith(`${sessionID}:`)) { - keysToRemove.push(key) - // Delete from map first to prevent re-entrancy during async close - state.clients.delete(key) - await closeManagedClient(managed) - } - } - - for (const key of keysToRemove) { - state.pendingConnections.delete(key) - } - - if (state.clients.size === 0) { - stopCleanupTimer(state) - } -} - -export async function disconnectAll(state: SkillMcpManagerState): Promise { - stopCleanupTimer(state) - unregisterProcessCleanup(state) - - const clients = Array.from(state.clients.values()) - state.clients.clear() - state.pendingConnections.clear() - state.authProviders.clear() - - for (const managed of clients) { - await closeManagedClient(managed) - } -} - -export async function forceReconnect(state: SkillMcpManagerState, clientKey: string): Promise { - const existing = state.clients.get(clientKey) - if (!existing) return false - - state.clients.delete(clientKey) - await closeManagedClient(existing) - return true -} diff --git a/src/features/skill-mcp-manager/connection-type.ts b/src/features/skill-mcp-manager/connection-type.ts deleted file mode 100644 index eedc86a60a2..00000000000 --- a/src/features/skill-mcp-manager/connection-type.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ConnectionType, McpServerDefinition } from "./types" - -/** - * Determines connection type from MCP server configuration. - * Priority: explicit type field > url presence > command presence - */ -export function getConnectionType(config: McpServerDefinition): ConnectionType | null { - // Explicit type takes priority - if (config.type === "http" || config.type === "sse") { - return "http" - } - if (config.type === "stdio") { - return "stdio" - } - - // Infer from available fields - if (config.url) { - return "http" - } - if (config.command) { - return "stdio" - } - - return null -} diff --git a/src/features/skill-mcp-manager/connection.ts b/src/features/skill-mcp-manager/connection.ts deleted file mode 100644 index fe77e7f104f..00000000000 --- a/src/features/skill-mcp-manager/connection.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { forceReconnect } from "./cleanup" -import { getConnectionType } from "./connection-type" -import { expandEnvVarsInObject } from "./env-expander" -import { createHttpClient } from "./http-client" -import { createStdioClient } from "./stdio-client" -import type { McpServerDefinition, SkillMcpClientConnectionParams, SkillMcpClientInfo, SkillMcpManagerState } from "./types" - -export async function getOrCreateClient(params: { - state: SkillMcpManagerState - clientKey: string - info: SkillMcpClientInfo - config: McpServerDefinition -}): Promise { - const { state, clientKey, info, config } = params - - const existing = state.clients.get(clientKey) - if (existing) { - existing.lastUsedAt = Date.now() - return existing.client - } - - // Prevent race condition: if a connection is already in progress, wait for it - const pending = state.pendingConnections.get(clientKey) - if (pending) { - return pending - } - - const expandedConfig = expandEnvVarsInObject(config) - const connectionPromise = createClient({ state, clientKey, info, config: expandedConfig }) - state.pendingConnections.set(clientKey, connectionPromise) - - try { - const client = await connectionPromise - return client - } finally { - state.pendingConnections.delete(clientKey) - } -} - -export async function getOrCreateClientWithRetryImpl(params: { - state: SkillMcpManagerState - clientKey: string - info: SkillMcpClientInfo - config: McpServerDefinition -}): Promise { - const { state, clientKey } = params - - try { - return await getOrCreateClient(params) - } catch (error) { - const didReconnect = await forceReconnect(state, clientKey) - if (!didReconnect) { - throw error - } - return await getOrCreateClient(params) - } -} - -async function createClient(params: { - state: SkillMcpManagerState - clientKey: string - info: SkillMcpClientInfo - config: McpServerDefinition -}): Promise { - const { info, config } = params - const connectionType = getConnectionType(config) - - if (!connectionType) { - throw new Error( - `MCP server "${info.serverName}" has no valid connection configuration.\n\n` + - `The MCP configuration in skill "${info.skillName}" must specify either:\n` + - ` - A URL for HTTP connection (remote MCP server)\n` + - ` - A command for stdio connection (local MCP process)\n\n` + - `Examples:\n` + - ` HTTP:\n` + - ` mcp:\n` + - ` ${info.serverName}:\n` + - ` url: https://mcp.example.com/mcp\n` + - ` headers:\n` + - // biome-ignore lint/suspicious/noTemplateCurlyInString: string in generated doc output, not a template - " Authorization: Bearer ${API_KEY}\n\n" + - ` Stdio:\n` + - ` mcp:\n` + - ` ${info.serverName}:\n` + - ` command: npx\n` + - ` args: [-y, @some/mcp-server]` - ) - } - - if (connectionType === "http") { - return await createHttpClient(params satisfies SkillMcpClientConnectionParams) - } - return await createStdioClient(params satisfies SkillMcpClientConnectionParams) -} diff --git a/src/features/skill-mcp-manager/env-cleaner.ts b/src/features/skill-mcp-manager/env-cleaner.ts deleted file mode 100644 index 9a3faba7988..00000000000 --- a/src/features/skill-mcp-manager/env-cleaner.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456) -export const EXCLUDED_ENV_PATTERNS: RegExp[] = [ - /^NPM_CONFIG_/i, - /^npm_config_/, - /^YARN_/, - /^PNPM_/, - /^NO_UPDATE_NOTIFIER$/, -] - -export function createCleanMcpEnvironment( - customEnv: Record = {} -): Record { - const cleanEnv: Record = {} - - for (const [key, value] of Object.entries(process.env)) { - if (value === undefined) continue - - const shouldExclude = EXCLUDED_ENV_PATTERNS.some((pattern) => pattern.test(key)) - if (!shouldExclude) { - cleanEnv[key] = value - } - } - - Object.assign(cleanEnv, customEnv) - - return cleanEnv -} diff --git a/src/features/skill-mcp-manager/env-expander.ts b/src/features/skill-mcp-manager/env-expander.ts deleted file mode 100644 index 2cec8d1ea05..00000000000 --- a/src/features/skill-mcp-manager/env-expander.ts +++ /dev/null @@ -1,27 +0,0 @@ -function expandEnvVars(value: string): string { - return value.replace( - /\$\{([^}:]+)(?::-([^}]*))?\}/g, - (_, varName: string, defaultValue?: string) => { - const envValue = process.env[varName] - if (envValue !== undefined) return envValue - if (defaultValue !== undefined) return defaultValue - return "" - } - ) -} - -export function expandEnvVarsInObject(obj: T): T { - if (obj === null || obj === undefined) return obj - if (typeof obj === "string") return expandEnvVars(obj) as T - if (Array.isArray(obj)) { - return obj.map((item) => expandEnvVarsInObject(item)) as T - } - if (typeof obj === "object") { - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - result[key] = expandEnvVarsInObject(value) - } - return result as T - } - return obj -} diff --git a/src/features/skill-mcp-manager/http-client.ts b/src/features/skill-mcp-manager/http-client.ts deleted file mode 100644 index dc47c8199e9..00000000000 --- a/src/features/skill-mcp-manager/http-client.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import { registerProcessCleanup, startCleanupTimer } from "./cleanup" -import { buildHttpRequestInit } from "./oauth-handler" -import type { ManagedClient, SkillMcpClientConnectionParams } from "./types" - -function redactUrl(urlStr: string): string { - try { - const u = new URL(urlStr) - for (const key of u.searchParams.keys()) { - if ( - key.toLowerCase().includes("key") || - key.toLowerCase().includes("token") || - key.toLowerCase().includes("secret") - ) { - u.searchParams.set(key, "***REDACTED***") - } - } - return u.toString() - } catch { - return urlStr - } -} - -export async function createHttpClient(params: SkillMcpClientConnectionParams): Promise { - const { state, clientKey, info, config } = params - - if (!config.url) { - throw new Error(`MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.`) - } - - let url: URL - try { - url = new URL(config.url) - } catch { - throw new Error( - `MCP server "${info.serverName}" has invalid URL: ${redactUrl(config.url)}\n\n` + - `Expected a valid URL like: https://mcp.example.com/mcp` - ) - } - - registerProcessCleanup(state) - - const requestInit = await buildHttpRequestInit(config, state.authProviders) - const transport = new StreamableHTTPClientTransport(url, { - requestInit, - }) - - const client = new Client( - { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, - { capabilities: {} } - ) - - try { - await client.connect(transport) - } catch (error) { - try { - await transport.close() - } catch { - // Transport may already be closed - } - - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Failed to connect to MCP server "${info.serverName}".\n\n` + - `URL: ${redactUrl(config.url)}\n` + - `Reason: ${errorMessage}\n\n` + - `Hints:\n` + - ` - Verify the URL is correct and the server is running\n` + - ` - Check if authentication headers are required\n` + - ` - Ensure the server supports MCP over HTTP` - ) - } - - const managedClient = { - client, - transport, - skillName: info.skillName, - lastUsedAt: Date.now(), - connectionType: "http", - } satisfies ManagedClient - - state.clients.set(clientKey, managedClient) - startCleanupTimer(state) - return client -} diff --git a/src/features/skill-mcp-manager/index.ts b/src/features/skill-mcp-manager/index.ts deleted file mode 100644 index 7503af9bc42..00000000000 --- a/src/features/skill-mcp-manager/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { SkillMcpManager } from "./manager" -export * from "./types" diff --git a/src/features/skill-mcp-manager/manager.ts b/src/features/skill-mcp-manager/manager.ts deleted file mode 100644 index f1f51787134..00000000000 --- a/src/features/skill-mcp-manager/manager.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" -import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js" -import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup" -import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection" -import { handleStepUpIfNeeded } from "./oauth-handler" -import type { McpServerDefinition, SkillMcpClientInfo, SkillMcpManagerState, SkillMcpServerContext } from "./types" - -export class SkillMcpManager { - private readonly state: SkillMcpManagerState = { - clients: new Map(), - pendingConnections: new Map(), - authProviders: new Map(), - cleanupRegistered: false, - cleanupInterval: null, - cleanupHandlers: [], - idleTimeoutMs: 5 * 60 * 1000, - } - - private getClientKey(info: SkillMcpClientInfo): string { - return `${info.sessionID}:${info.skillName}:${info.serverName}` - } - - async getOrCreateClient(info: SkillMcpClientInfo, config: McpServerDefinition): Promise { - const clientKey = this.getClientKey(info) - return await getOrCreateClient({ - state: this.state, - clientKey, - info, - config, - }) - } - - async disconnectSession(sessionID: string): Promise { - await disconnectSession(this.state, sessionID) - } - - async disconnectAll(): Promise { - await disconnectAll(this.state) - } - - async listTools(info: SkillMcpClientInfo, context: SkillMcpServerContext): Promise { - const client = await this.getOrCreateClientWithRetry(info, context.config) - const result = await client.listTools() - return result.tools - } - - async listResources(info: SkillMcpClientInfo, context: SkillMcpServerContext): Promise { - const client = await this.getOrCreateClientWithRetry(info, context.config) - const result = await client.listResources() - return result.resources - } - - async listPrompts(info: SkillMcpClientInfo, context: SkillMcpServerContext): Promise { - const client = await this.getOrCreateClientWithRetry(info, context.config) - const result = await client.listPrompts() - return result.prompts - } - - async callTool( - info: SkillMcpClientInfo, - context: SkillMcpServerContext, - name: string, - args: Record - ): Promise { - return await this.withOperationRetry(info, context.config, async (client) => { - const result = await client.callTool({ name, arguments: args }) - return result.content - }) - } - - async readResource(info: SkillMcpClientInfo, context: SkillMcpServerContext, uri: string): Promise { - return await this.withOperationRetry(info, context.config, async (client) => { - const result = await client.readResource({ uri }) - return result.contents - }) - } - - async getPrompt( - info: SkillMcpClientInfo, - context: SkillMcpServerContext, - name: string, - args: Record - ): Promise { - return await this.withOperationRetry(info, context.config, async (client) => { - const result = await client.getPrompt({ name, arguments: args }) - return result.messages - }) - } - - private async withOperationRetry( - info: SkillMcpClientInfo, - config: McpServerDefinition, - operation: (client: Client) => Promise - ): Promise { - const maxRetries = 3 - let lastError: Error | null = null - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const client = await this.getOrCreateClientWithRetry(info, config) - return await operation(client) - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)) - const errorMessage = lastError.message.toLowerCase() - - const stepUpHandled = await handleStepUpIfNeeded({ - error: lastError, - config, - authProviders: this.state.authProviders, - }) - if (stepUpHandled) { - await forceReconnect(this.state, this.getClientKey(info)) - continue - } - - if (!errorMessage.includes("not connected")) { - throw lastError - } - - if (attempt === maxRetries) { - throw new Error(`Failed after ${maxRetries} reconnection attempts: ${lastError.message}`) - } - - await forceReconnect(this.state, this.getClientKey(info)) - } - } - - throw lastError ?? new Error("Operation failed with unknown error") - } - - // NOTE: tests spy on this exact method name via `spyOn(manager as any, 'getOrCreateClientWithRetry')`. - private async getOrCreateClientWithRetry(info: SkillMcpClientInfo, config: McpServerDefinition): Promise { - const clientKey = this.getClientKey(info) - return await getOrCreateClientWithRetryImpl({ - state: this.state, - clientKey, - info, - config, - }) - } - - getConnectedServers(): string[] { - return Array.from(this.state.clients.keys()) - } - - isConnected(info: SkillMcpClientInfo): boolean { - return this.state.clients.has(this.getClientKey(info)) - } -} diff --git a/src/features/skill-mcp-manager/oauth-handler.ts b/src/features/skill-mcp-manager/oauth-handler.ts deleted file mode 100644 index 51ff0b8719f..00000000000 --- a/src/features/skill-mcp-manager/oauth-handler.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { McpOAuthProvider } from "../mcp-oauth/provider" -import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up" -import type { OAuthTokenData } from "../mcp-oauth/storage" -import type { McpServerDefinition } from "./types" - -function getOrCreateAuthProvider( - authProviders: Map, - serverUrl: string, - oauth: NonNullable -): McpOAuthProvider { - const existing = authProviders.get(serverUrl) - if (existing) return existing - - const provider = new McpOAuthProvider({ - serverUrl, - clientId: oauth.clientId, - scopes: oauth.scopes, - }) - authProviders.set(serverUrl, provider) - return provider -} - -function isTokenExpired(tokenData: OAuthTokenData): boolean { - if (tokenData.expiresAt == null) return false - return tokenData.expiresAt < Math.floor(Date.now() / 1000) -} - -export async function buildHttpRequestInit( - config: McpServerDefinition, - authProviders: Map -): Promise { - const headers: Record = {} - - if (config.headers) { - for (const [key, value] of Object.entries(config.headers)) { - headers[key] = value - } - } - - if (config.oauth && config.url) { - const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth) - let tokenData = provider.tokens() - - if (!tokenData || isTokenExpired(tokenData)) { - try { - tokenData = await provider.login() - } catch { - tokenData = null - } - } - - if (tokenData) { - headers.Authorization = `Bearer ${tokenData.accessToken}` - } - } - - return Object.keys(headers).length > 0 ? { headers } : undefined -} - -export async function handleStepUpIfNeeded(params: { - error: Error - config: McpServerDefinition - authProviders: Map -}): Promise { - const { error, config, authProviders } = params - - if (!config.oauth || !config.url) { - return false - } - - const statusMatch = /\b403\b/.exec(error.message) - if (!statusMatch) { - return false - } - - const headers: Record = {} - const wwwAuthMatch = /WWW-Authenticate:\s*(.+)/i.exec(error.message) - if (wwwAuthMatch?.[1]) { - headers["www-authenticate"] = wwwAuthMatch[1] - } - - const stepUp = isStepUpRequired(403, headers) - if (!stepUp) { - return false - } - - const currentScopes = config.oauth.scopes ?? [] - const mergedScopes = mergeScopes(currentScopes, stepUp.requiredScopes) - config.oauth.scopes = mergedScopes - - authProviders.delete(config.url) - const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth) - - try { - await provider.login() - return true - } catch { - return false - } -} diff --git a/src/features/skill-mcp-manager/stdio-client.ts b/src/features/skill-mcp-manager/stdio-client.ts deleted file mode 100644 index 12199c1cabb..00000000000 --- a/src/features/skill-mcp-manager/stdio-client.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { registerProcessCleanup, startCleanupTimer } from "./cleanup" -import { createCleanMcpEnvironment } from "./env-cleaner" -import type { ManagedClient, McpServerDefinition, SkillMcpClientConnectionParams } from "./types" - -function getStdioCommand(config: McpServerDefinition, serverName: string): string { - if (!config.command) { - throw new Error(`MCP server "${serverName}" is configured for stdio but missing 'command' field.`) - } - return config.command -} - -export async function createStdioClient(params: SkillMcpClientConnectionParams): Promise { - const { state, clientKey, info, config } = params - - const command = getStdioCommand(config, info.serverName) - const args = config.args ?? [] - const mergedEnv = createCleanMcpEnvironment(config.env) - - registerProcessCleanup(state) - - const transport = new StdioClientTransport({ - command, - args, - env: mergedEnv, - stderr: "ignore", - }) - - const client = new Client( - { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, - { capabilities: {} } - ) - - try { - await client.connect(transport) - } catch (error) { - // Close transport to prevent orphaned MCP process on connection failure - try { - await transport.close() - } catch { - // Process may already be terminated - } - - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Failed to connect to MCP server "${info.serverName}".\n\n` + - `Command: ${command} ${args.join(" ")}\n` + - `Reason: ${errorMessage}\n\n` + - `Hints:\n` + - ` - Ensure the command is installed and available in PATH\n` + - ` - Check if the MCP server package exists\n` + - ` - Verify the args are correct for this server` - ) - } - - const managedClient = { - client, - transport, - skillName: info.skillName, - lastUsedAt: Date.now(), - connectionType: "stdio", - } satisfies ManagedClient - - state.clients.set(clientKey, managedClient) - startCleanupTimer(state) - return client -} diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts deleted file mode 100644 index 7107ee822ae..00000000000 --- a/src/features/skill-mcp-manager/types.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" -import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import type { McpOAuthProvider } from "../mcp-oauth/provider" - -export interface McpServerDefinition { - type?: "http" | "sse" | "stdio" - url?: string - command?: string - args?: string[] - env?: Record - headers?: Record - oauth?: { - clientId?: string - scopes?: string[] - } - disabled?: boolean -} - -export type SkillMcpConfig = Record - -export interface SkillMcpClientInfo { - serverName: string - skillName: string - sessionID: string -} - -export interface SkillMcpServerContext { - config: McpServerDefinition - skillName: string -} - -/** - * Connection type for a managed MCP client. - * - "stdio": Local process via stdin/stdout - * - "http": Remote server via HTTP (Streamable HTTP transport) - */ -export type ConnectionType = "stdio" | "http" - -interface ManagedClientBase { - client: Client - skillName: string - lastUsedAt: number - connectionType: ConnectionType -} - -interface ManagedStdioClient extends ManagedClientBase { - connectionType: "stdio" - transport: StdioClientTransport -} - -interface ManagedHttpClient extends ManagedClientBase { - connectionType: "http" - transport: StreamableHTTPClientTransport -} - -export type ManagedClient = ManagedStdioClient | ManagedHttpClient - -interface ProcessCleanupHandler { - signal: NodeJS.Signals - listener: () => void -} - -export interface SkillMcpManagerState { - clients: Map - pendingConnections: Map> - authProviders: Map - cleanupRegistered: boolean - cleanupInterval: ReturnType | null - cleanupHandlers: ProcessCleanupHandler[] - idleTimeoutMs: number -} - -export interface SkillMcpClientConnectionParams { - state: SkillMcpManagerState - clientKey: string - info: SkillMcpClientInfo - config: McpServerDefinition -} diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index 8a753b39b16..ad55b0a846f 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -1,10 +1,12 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { basename, dirname, join } from "node:path" +import type { createOpencodeClient } from "@opencode-ai/sdk" import { loadBuiltinCommands } from "../../features/builtin-commands" +import type { BuiltinSkill } from "../../features/builtin-skills" import type { CommandFrontmatter } from "../../features/command-loader/types" -import { discoverAllSkills, type LazyContentLoader, type LoadedSkill } from "../../features/opencode-skill-loader" import { getOpenCodeConfigDir, + log, parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, @@ -14,7 +16,7 @@ import { isMarkdownFile } from "../../shared/file-utils" import type { ParsedSlashCommand } from "./types" interface CommandScope { - type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" + type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin" } interface CommandMetadata { @@ -32,7 +34,6 @@ interface CommandInfo { metadata: CommandMetadata content?: string scope: CommandScope["type"] - lazyContentLoader?: LazyContentLoader } function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] { @@ -77,26 +78,51 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type" return commands } -function skillToCommandInfo(skill: LoadedSkill): CommandInfo { +function skillToCommandInfo(skill: BuiltinSkill): CommandInfo { return { name: skill.name, - path: skill.path, + path: undefined, metadata: { name: skill.name, - description: skill.definition.description || "", - argumentHint: skill.definition.argumentHint, - model: skill.definition.model, - agent: skill.definition.agent, - subtask: skill.definition.subtask, + description: skill.description || "", + argumentHint: skill.argumentHint, + model: skill.model, + agent: skill.agent, + subtask: skill.subtask, }, - content: skill.definition.template, + content: skill.template, scope: "skill", - lazyContentLoader: skill.lazyContent, } } export interface ExecutorOptions { - skills?: LoadedSkill[] + skills?: BuiltinSkill[] + /** OpenCode SDK client for discovering plugin-registered commands */ + client?: ReturnType +} + +async function discoverPluginCommands(client?: ReturnType): Promise { + if (!client) return [] + + try { + const result = await client.command.list() + const commands = result.data ?? [] + return commands.map(cmd => ({ + name: cmd.name, + metadata: { + name: cmd.name, + description: cmd.description || "", + model: typeof cmd.model === "string" ? cmd.model : undefined, + agent: cmd.agent, + subtask: cmd.subtask, + }, + content: typeof cmd.template === "string" ? cmd.template : undefined, + scope: "plugin" as const, + })) + } catch (err) { + log(`[auto-slash-command] Failed to discover plugin commands:`, err) + return [] + } } async function discoverAllCommands(options?: ExecutorOptions): Promise { @@ -120,14 +146,17 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise() const sessionProcessedCommandExecutions = new Set() export interface AutoSlashCommandHookOptions { - skills?: LoadedSkill[] + skills?: BuiltinSkill[] + /** OpenCode SDK client for discovering plugin-registered commands */ + client?: ReturnType } export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) { const executorOptions: ExecutorOptions = { skills: options?.skills, + client: options?.client, } return { diff --git a/src/hooks/auto-update-checker/hook/spinner-toast.ts b/src/hooks/auto-update-checker/hook/spinner-toast.ts index d5fcd115a05..8ae949b814d 100644 --- a/src/hooks/auto-update-checker/hook/spinner-toast.ts +++ b/src/hooks/auto-update-checker/hook/spinner-toast.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../../shared/logger" -const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "] +const MORPHEUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "] export async function showSpinnerToast(ctx: PluginInput, version: string, message: string): Promise { const totalDuration = 5000 @@ -9,7 +9,7 @@ export async function showSpinnerToast(ctx: PluginInput, version: string, messag const totalFrames = Math.floor(totalDuration / frameInterval) for (let i = 0; i < totalFrames; i++) { - const spinner = SISYPHUS_SPINNER[i % SISYPHUS_SPINNER.length] + const spinner = MORPHEUS_SPINNER[i % MORPHEUS_SPINNER.length] await ctx.client.tui .showToast({ body: { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 0d4b465923a..e2a3c571834 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -26,9 +26,9 @@ export { createKeywordDetectorHook } from "./keyword-detector"; export { createMatrixLoopHook, type MatrixLoopHook } from "./matrix-loop"; export { createMouseNotepadHook } from "./mouse-notepad"; export { createNonInteractiveEnvHook } from "./non-interactive-env"; +export { createOracleMdOnlyHook } from "./oracle-md-only"; export { createPlanPersister } from "./plan-persister" export { createPreemptiveCompactionHook } from "./preemptive-compaction"; -export { createOracleMdOnlyHook } from "./prometheus-md-only"; export { createQualityGateHook } from "./quality-gate/hook" export { createQuestionLabelTruncatorHook } from "./question-label-truncator"; export { createReadImageResizerHook } from "./read-image-resizer" diff --git a/src/hooks/keyword-detector/ultrawork/index.ts b/src/hooks/keyword-detector/ultrawork/index.ts index cd88188402b..eb9f12eb469 100644 --- a/src/hooks/keyword-detector/ultrawork/index.ts +++ b/src/hooks/keyword-detector/ultrawork/index.ts @@ -2,7 +2,7 @@ * Ultrawork message module - routes to appropriate message based on agent/model. * * Routing: - * 1. Planner agents (prometheus, plan) → planner.ts + * 1. Planner agents (oracle, plan) → planner.ts * 2. GPT 5.2 models → gpt5.2.ts * 3. Default (Claude, etc.) → default.ts (optimized for Claude series) */ diff --git a/src/hooks/keyword-detector/ultrawork/source-detector.ts b/src/hooks/keyword-detector/ultrawork/source-detector.ts index ba1095c6892..a24d37a8a38 100644 --- a/src/hooks/keyword-detector/ultrawork/source-detector.ts +++ b/src/hooks/keyword-detector/ultrawork/source-detector.ts @@ -2,7 +2,7 @@ * Agent/model detection utilities for ultrawork message routing. * * Routing logic: - * 1. Planner agents (prometheus, plan) → planner.ts + * 1. Planner agents (oracle, plan) → planner.ts * 2. GPT 5.2 models → gpt5.2.ts * 3. Everything else (Claude, etc.) → default.ts */ diff --git a/src/hooks/prometheus-md-only/agent-matcher.ts b/src/hooks/oracle-md-only/agent-matcher.ts similarity index 100% rename from src/hooks/prometheus-md-only/agent-matcher.ts rename to src/hooks/oracle-md-only/agent-matcher.ts diff --git a/src/hooks/prometheus-md-only/agent-resolution.ts b/src/hooks/oracle-md-only/agent-resolution.ts similarity index 100% rename from src/hooks/prometheus-md-only/agent-resolution.ts rename to src/hooks/oracle-md-only/agent-resolution.ts diff --git a/src/hooks/prometheus-md-only/constants.ts b/src/hooks/oracle-md-only/constants.ts similarity index 88% rename from src/hooks/prometheus-md-only/constants.ts rename to src/hooks/oracle-md-only/constants.ts index 594aeefdd8a..34c5fd35689 100644 --- a/src/hooks/prometheus-md-only/constants.ts +++ b/src/hooks/oracle-md-only/constants.ts @@ -1,7 +1,7 @@ import { getAgentDisplayName } from "../../shared/agent-display-names" import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" -export const HOOK_NAME = "prometheus-md-only" +export const HOOK_NAME = "oracle-md-only" export const ORACLE_AGENT = "oracle" @@ -13,7 +13,7 @@ export const PLANNING_CONSULT_WARNING = ` --- -${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)} +${createSystemDirective(SystemDirectiveTypes.ORACLE_READ_ONLY)} You are being invoked by ${getAgentDisplayName("oracle")}, a READ-ONLY planning agent. @@ -30,32 +30,32 @@ Return your findings and recommendations. The actual implementation will be hand ` -export const PROMETHEUS_WORKFLOW_REMINDER = ` +export const ORACLE_WORKFLOW_REMINDER = ` --- -${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)} +${createSystemDirective(SystemDirectiveTypes.ORACLE_READ_ONLY)} -## PROMETHEUS MANDATORY WORKFLOW REMINDER +## ORACLE MANDATORY WORKFLOW REMINDER **You are writing a work plan. STOP AND VERIFY you completed ALL steps:** ┌─────────────────────────────────────────────────────────────────────┐ -│ PROMETHEUS WORKFLOW │ +│ ORACLE WORKFLOW │ ├──────┬──────────────────────────────────────────────────────────────┤ │ 1 │ INTERVIEW: Full consultation with user │ │ │ - Gather ALL requirements │ │ │ - Clarify ambiguities │ │ │ - Record decisions to .matrixx/drafts/ │ ├──────┼──────────────────────────────────────────────────────────────┤ -│ 2 │ METIS CONSULTATION: Pre-generation gap analysis │ +│ 2 │ SERAPH CONSULTATION: Pre-generation gap analysis │ │ │ - task(agent="Seraph (Plan Consultant)", ...) │ │ │ - Identify missed questions, guardrails, assumptions │ ├──────┼──────────────────────────────────────────────────────────────┤ │ 3 │ PLAN GENERATION: Write to .matrixx/plans/*.md │ │ │ <- YOU ARE HERE │ ├──────┼──────────────────────────────────────────────────────────────┤ -│ 4 │ MOMUS REVIEW (if high accuracy requested) │ +│ 4 │ SMITH REVIEW (if high accuracy requested) │ │ │ - task(agent="Smith (Plan Reviewer)", ...) │ │ │ - Loop until OKAY verdict │ ├──────┼──────────────────────────────────────────────────────────────┤ diff --git a/src/hooks/prometheus-md-only/hook.ts b/src/hooks/oracle-md-only/hook.ts similarity index 93% rename from src/hooks/prometheus-md-only/hook.ts rename to src/hooks/oracle-md-only/hook.ts index 5e9be6c5821..0fe6193ff15 100644 --- a/src/hooks/prometheus-md-only/hook.ts +++ b/src/hooks/oracle-md-only/hook.ts @@ -4,7 +4,7 @@ import { log } from "../../shared/logger" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { isOracleAgent } from "./agent-matcher" import { getAgentFromSession } from "./agent-resolution" -import { BLOCKED_TOOLS, HOOK_NAME, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants" +import { BLOCKED_TOOLS, HOOK_NAME, ORACLE_WORKFLOW_REMINDER, PLANNING_CONSULT_WARNING } from "./constants" import { isAllowedFile } from "./path-policy" const TASK_TOOLS = ["task", "delegate_agent"] @@ -69,7 +69,7 @@ export function createOracleMdOnlyHook(ctx: PluginInput) { filePath, agent: agentName, }) - output.message = (output.message || "") + PROMETHEUS_WORKFLOW_REMINDER + output.message = (output.message || "") + ORACLE_WORKFLOW_REMINDER } log(`[${HOOK_NAME}] Allowed: .matrixx/*.md write permitted`, { diff --git a/src/hooks/prometheus-md-only/index.ts b/src/hooks/oracle-md-only/index.ts similarity index 100% rename from src/hooks/prometheus-md-only/index.ts rename to src/hooks/oracle-md-only/index.ts diff --git a/src/hooks/prometheus-md-only/path-policy.ts b/src/hooks/oracle-md-only/path-policy.ts similarity index 100% rename from src/hooks/prometheus-md-only/path-policy.ts rename to src/hooks/oracle-md-only/path-policy.ts diff --git a/src/hooks/tool-output-truncator.ts b/src/hooks/tool-output-truncator.ts index 8f8c300dae8..fb80fdb6bac 100644 --- a/src/hooks/tool-output-truncator.ts +++ b/src/hooks/tool-output-truncator.ts @@ -16,7 +16,6 @@ const TRUNCATABLE_TOOLS = [ "ast_grep_search", "interactive_bash", "Interactive_bash", - "skill_mcp", "webfetch", "WebFetch", ] diff --git a/src/index.ts b/src/index.ts index b174aafac25..effe7ca3130 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,7 +63,7 @@ const MatrixxPlugin: Plugin = async (ctx) => { backgroundManager: managers.backgroundManager, isHookEnabled, safeHookEnabled, - mergedSkills: toolsResult.mergedSkills, + builtinSkills: toolsResult.builtinSkills, availableSkills: toolsResult.availableSkills, }) diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index ef3fdc53d33..9e98b756514 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -2,12 +2,11 @@ ## OVERVIEW -Tier 1 of three-tier MCP system: 4 built-in MCPs (3 remote HTTP + 1 local stdio). +Tier 1 of two-tier MCP system: 4 built-in MCPs (3 remote HTTP + 1 local stdio). -**Three-Tier System**: +**Two-Tier System**: 1. **Built-in** (this directory): websearch, context7, grep_app, document_reader -2. **Claude Code compat** (`features/claude-code-mcp-loader/`): .mcp.json with `${VAR}` expansion -3. **Skill-embedded** (`features/opencode-skill-loader/`): YAML frontmatter in SKILL.md +2. **Plugin-config / user-configured**: MCPs defined in plugin configuration ## STRUCTURE ``` diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index e3e73184209..14a4260a9c8 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -1,18 +1,12 @@ import { createBuiltinAgents } from "../agents"; import { createMouseAgentWithOverrides } from "../agents/mouse"; import type { MatrixxConfig } from "../config"; -import { loadProjectAgents, loadUserAgents } from "../features/agent-loader"; -import { - discoverConfigSourceSkills, - discoverOpencodeGlobalSkills, - discoverOpencodeProjectSkills, -} from "../features/opencode-skill-loader"; import { log, migrateAgentConfig } from "../shared"; import { AGENT_NAME_MAP } from "../shared/migration"; import { reorderAgentsByPriority } from "./agent-priority-order"; +import { buildOracleAgentConfig } from "./oracle-agent-config-builder"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; import type { PluginComponents } from "./plugin-components-loader"; -import { buildOracleAgentConfig } from "./prometheus-agent-config-builder"; // Module-level tool names cache, set once at startup by index.ts let _availableToolNames: string[] = [] @@ -44,30 +38,7 @@ export async function applyAgentConfig(params: { }, ) as typeof params.pluginConfig.disabled_agents; - const [ - discoveredConfigSourceSkills, - discoveredUserSkills, - discoveredProjectSkills, - discoveredOpencodeGlobalSkills, - discoveredOpencodeProjectSkills, - ] = await Promise.all([ - discoverConfigSourceSkills({ - config: params.pluginConfig.skills, - configDir: params.ctx.directory, - }), - Promise.resolve([]), - Promise.resolve([]), - discoverOpencodeGlobalSkills(), - discoverOpencodeProjectSkills(params.ctx.directory), - ]); - - const allDiscoveredSkills = [ - ...discoveredConfigSourceSkills, - ...discoveredOpencodeProjectSkills, - ...discoveredProjectSkills, - ...discoveredOpencodeGlobalSkills, - ...discoveredUserSkills, - ]; + const allDiscoveredSkills: never[] = []; const browserProvider = params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; @@ -94,8 +65,6 @@ export async function applyAgentConfig(params: { params.pluginConfig.global_model, availableToolNames, ); - const userAgents = loadUserAgents(); - const projectAgents = loadProjectAgents(params.ctx.directory); const rawPluginAgents = params.pluginComponents.agents; const pluginAgents = Object.fromEntries( @@ -189,8 +158,6 @@ export async function applyAgentConfig(params: { ...Object.fromEntries( Object.entries(builtinAgents).filter(([key]) => key !== "morpheus"), ), - ...userAgents, - ...projectAgents, ...pluginAgents, ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, @@ -199,8 +166,6 @@ export async function applyAgentConfig(params: { } else { params.config.agent = { ...builtinAgents, - ...userAgents, - ...projectAgents, ...pluginAgents, ...configAgent, }; diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 42fdde4052b..96fdbae5339 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,15 +1,5 @@ import type { MatrixxConfig } from "../config"; import { loadBuiltinCommands } from "../features/builtin-commands"; -import { - loadOpencodeGlobalCommands, - loadOpencodeProjectCommands, -} from "../features/command-loader"; -import { - discoverConfigSourceSkills, - loadOpencodeGlobalSkills, - loadOpencodeProjectSkills, - skillsToCommandDefinitionRecord, -} from "../features/opencode-skill-loader"; import type { PluginComponents } from "./plugin-components-loader"; export async function applyCommandConfig(params: { @@ -21,33 +11,9 @@ export async function applyCommandConfig(params: { const builtinCommands = loadBuiltinCommands(params.pluginConfig.disabled_commands); const systemCommands = (params.config.command as Record) ?? {}; - // claude_code flags removed during CC compat removal - - const [ - configSourceSkills, - opencodeGlobalCommands, - opencodeProjectCommands, - opencodeGlobalSkills, - opencodeProjectSkills, - ] = await Promise.all([ - discoverConfigSourceSkills({ - config: params.pluginConfig.skills, - configDir: params.ctx.directory, - }), - loadOpencodeGlobalCommands(), - loadOpencodeProjectCommands(params.ctx.directory), - loadOpencodeGlobalSkills(), - loadOpencodeProjectSkills(params.ctx.directory), - ]); - params.config.command = { ...builtinCommands, - ...skillsToCommandDefinitionRecord(configSourceSkills), - ...opencodeGlobalCommands, - ...opencodeGlobalSkills, ...systemCommands, - ...opencodeProjectCommands, - ...opencodeProjectSkills, ...params.pluginComponents.commands, ...params.pluginComponents.skills, }; diff --git a/src/plugin-handlers/index.ts b/src/plugin-handlers/index.ts index 15fc59f810f..01c029c59f4 100644 --- a/src/plugin-handlers/index.ts +++ b/src/plugin-handlers/index.ts @@ -4,7 +4,7 @@ export * from "./category-config-resolver"; export * from "./command-config-handler"; export { type ConfigHandlerDeps, createConfigHandler } from "./config-handler"; export * from "./mcp-config-handler"; +export * from "./oracle-agent-config-builder"; export * from "./plugin-components-loader"; -export * from "./prometheus-agent-config-builder"; export * from "./provider-config-handler"; export * from "./tool-config-handler"; diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/oracle-agent-config-builder.ts similarity index 100% rename from src/plugin-handlers/prometheus-agent-config-builder.ts rename to src/plugin-handlers/oracle-agent-config-builder.ts diff --git a/src/plugin-handlers/plan-model-inheritance.ts b/src/plugin-handlers/plan-model-inheritance.ts index bb32483c533..382ab09c6af 100644 --- a/src/plugin-handlers/plan-model-inheritance.ts +++ b/src/plugin-handlers/plan-model-inheritance.ts @@ -11,13 +11,13 @@ const MODEL_SETTINGS_KEYS = [ ] as const export function buildPlanDemoteConfig( - prometheusConfig: Record | undefined, + oracleConfig: Record | undefined, planOverride: Record | undefined, ): Record { const modelSettings: Record = {} for (const key of MODEL_SETTINGS_KEYS) { - const value = planOverride?.[key] ?? prometheusConfig?.[key] + const value = planOverride?.[key] ?? oracleConfig?.[key] if (value !== undefined) { modelSettings[key] = value } diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 340cacda5b5..15e8be9bb01 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -123,7 +123,6 @@ export function createEventHandler(args: { clearSessionAgent(sessionInfo.id) resetMessageCursor(sessionInfo.id) firstMessageVariantGate.clear(sessionInfo.id) - await managers.skillMcpManager.disconnectSession(sessionInfo.id) await lspManager.cleanupTempDirectoryClients() await managers.tmuxSessionManager.onSessionDeleted({ sessionID: sessionInfo.id, diff --git a/src/plugin/hook-mutation-classification.md b/src/plugin/hook-mutation-classification.md index c6fe47164ea..efbd0415882 100644 --- a/src/plugin/hook-mutation-classification.md +++ b/src/plugin/hook-mutation-classification.md @@ -35,7 +35,7 @@ relevant for parallelization decisions; **Secondary** lists everything else. | 10 | directoryReadmeInjector | `READ_ONLY` | — | — | NO | None — same NO-OP factory as #9 | `src/hooks/directory-injector/factory.ts:62-68` | | 11 | rulesInjector | `READ_ONLY` | — | — | NO | None — `tool.execute.before` is a NO-OP (`void input; void output;`, hook.ts:59-60); real work is in `tool.execute.after` | `src/hooks/rules-injector/hook.ts:55-61` | | 12 | tasksTodowriteDisabler | `BLOCKING` | — | — | YES (line 29, `throw new Error(REPLACEMENT_MESSAGE)`) | None (pure array `.some()`) | `src/hooks/tasks-todowrite-disabler/hook.ts:15-31` | -| 13 | prometheusMdOnly | `BLOCKING` | `MUTATOR` + `NETWORK` | `output.args.prompt` (line 30); `output.message` (CONCAT, line 72) | YES (line 56, `throw new Error("[…] Oracle can only write/edit .md files…")`) | `getAgentFromSession()` → SDK HTTP call (`findNearestMessageWithFieldsFromSDK`) or filesystem fallback (`readFileSync`/`readdirSync` in `features/hook-message-injector/injector.ts:147,154,166,198,204`) | `src/hooks/prometheus-md-only/hook.ts:14-81` | +| 13 | oracleMdOnly | `BLOCKING` | `MUTATOR` + `NETWORK` | `output.args.prompt` (line 30); `output.message` (CONCAT, line 72) | YES (line 56, `throw new Error("[…] Oracle can only write/edit .md files…")`) | `getAgentFromSession()` → SDK HTTP call (`findNearestMessageWithFieldsFromSDK`) or filesystem fallback (`readFileSync`/`readdirSync` in `features/hook-message-injector/injector.ts:147,154,166,198,204`) | `src/hooks/oracle-md-only/hook.ts:14-81` | | 14 | mouseNotepad | `MUTATOR` | `NETWORK` | `output.args.prompt` (CONCAT prefix, line 36) | NO | `isCallerOrchestrator()` → SDK HTTP call (`findNearestMessageWithFieldsFromSDK`) or filesystem fallback (`session-utils.ts:13-24`) | `src/hooks/mouse-notepad/hook.ts:10-43` | | 15 | architectHook | `MUTATOR` | `NETWORK` | `output.message` (CONCAT, line 34); `output.args.prompt` (CONCAT prefix, line 48) | NO | Same `isCallerOrchestrator()` as #14; also writes to `pendingFilePaths` Map (line 31) for `tool.execute.after` | `src/hooks/architect/tool-execute-before.ts:19-54` | @@ -44,14 +44,14 @@ relevant for parallelization decisions; **Secondary** lists everything else. | Class | Count | Hooks | |-------|-------|-------| | `READ_ONLY` | 6 | qualityGate, commentChecker, directoryAgentsInjector, directoryReadmeInjector, rulesInjector (×3 of which are pure no-ops) | -| `MUTATOR` | 6 | bashFileReadGuard, questionLabelTruncator, nonInteractiveEnv, mouseNotepad, architectHook (+ prometheusMdOnly when not blocking) | -| `NETWORK` | 4 | prometheusMdOnly, mouseNotepad, architectHook (+ secretLeakGuard as subprocess) | -| `BLOCKING` | 5 | secretLeakGuard, envFileWriteGuard, writeExistingFileGuard, tasksTodowriteDisabler, prometheusMdOnly | +| `MUTATOR` | 6 | bashFileReadGuard, questionLabelTruncator, nonInteractiveEnv, mouseNotepad, architectHook (+ oracleMdOnly when not blocking) | +| `NETWORK` | 4 | oracleMdOnly, mouseNotepad, architectHook (+ secretLeakGuard as subprocess) | +| `BLOCKING` | 5 | secretLeakGuard, envFileWriteGuard, writeExistingFileGuard, tasksTodowriteDisabler, oracleMdOnly | > The plan's spec asked for "≥ 8 BLOCKING hooks" but the actual code only > contains 5 hard-blocking hooks. The plan's count of 8 appears to come from > the older `AGENTS.md` "BLOCKING HOOKS (8)" table which over-counts -> (e.g. `oracle-md-only` is the same hook as `prometheusMdOnly`, +> (e.g. `oracle-md-only` is the same hook as `oracleMdOnly`, > `mouseNotepad` is the same as `mouseNotepad`). > The task brief's relaxed threshold of "≥ 4" is the binding contract. @@ -100,7 +100,7 @@ await Promise.allSettled([ hooks.envFileWriteGuard?.["tool.execute.before"]?.(input, output), // write/edit + sensitive file hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output), // write + file exists hooks.tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output),// todowrite + task system - hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output), // write/edit + Oracle agent + non-.md + hooks.oracleMdOnly?.["tool.execute.before"]?.(input, output), // write/edit + Oracle agent + non-.md ]) ``` @@ -110,7 +110,7 @@ await Promise.allSettled([ | envFileWriteGuard | `tool ∈ {write, edit, multiedit}` AND path matches sensitive pattern | | writeExistingFileGuard | `tool === "write"` AND `existsSync(path)` | | tasksTodowriteDisabler | `tool ∈ BLOCKED_TOOLS` AND `experimental.task_system === true` | -| prometheusMdOnly | agent is Oracle AND `tool ∈ BLOCKED_TOOLS` AND path outside `.matrixx/*.md` | +| oracleMdOnly | agent is Oracle AND `tool ∈ BLOCKED_TOOLS` AND path outside `.matrixx/*.md` | > **Subprocess cost warning**: `secretLeakGuard` shells out to `gitleaks` (up > to 30 s timeout per `gitleaks-runner.ts:20`). If a real commit/push command @@ -129,7 +129,7 @@ mutation. Specifically: stomp on the first writer's directive because both read `prompt` from the same starting state. (Confirmed by reading lines 36, 48, 30 of `mouse-notepad/hook.ts`, `architect/tool-execute-before.ts`, and - `prometheus-md-only/hook.ts`.) + `oracle-md-only/hook.ts`.) - **Hooks 3/7/13/15 all mutate `output.message`**. Hooks 3 (`bashFileReadGuard`) and 7 (`nonInteractiveEnv`) **REPLACE**; hooks 13 and 15 **CONCAT**. Parallel execution of REPLACE + CONCAT can lose the REPLACE'd message if CONCAT runs @@ -148,8 +148,8 @@ await hooks.bashFileReadGuard?.["tool.execute.before"]?.(input, output) // 3c. questionLabelTruncator — only mutates output.args.questions await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) -// 3d. prometheusMdOnly — prepends Oracle warning to output.args.prompt -await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) +// 3d. oracleMdOnly — prepends Oracle warning to output.args.prompt +await hooks.oracleMdOnly?.["tool.execute.before"]?.(input, output) // 3e. mouseNotepad — prepends notepad directive to output.args.prompt await hooks.mouseNotepad?.["tool.execute.before"]?.(input, output) @@ -162,9 +162,9 @@ await hooks.architectHook?.["tool.execute.before"]?.(input, output) > **3d → 3e → 3f** is the critical ordering: they all prepend to > `output.args.prompt` and **must** execute last-to-first (or first-to-last > consistently) to avoid losing directives. The current source order is -> `prometheusMdOnly → mouseNotepad → architectHook`, which means +> `oracleMdOnly → mouseNotepad → architectHook`, which means > `architectHook`'s prepended `` is the *outermost* directive -> (closest to the model) and `prometheusMdOnly`'s prepended +> (closest to the model) and `oracleMdOnly`'s prepended > `PLANNING_CONSULT_WARNING` is the *innermost* (closest to user prompt). > Keep this order when re-serializing. @@ -205,7 +205,7 @@ await hooks.architectHook?.["tool.execute.before"]?.(input, output) before-handler chain entirely is a zero-risk optimization (just don't register a `tool.execute.before` handler, or guard with `if (!handler) return undefined` in `tool-execute-before.ts`). -5. **3 of the 15 hooks (prometheusMdOnly, mouseNotepad, architectHook) +5. **3 of the 15 hooks (oracleMdOnly, mouseNotepad, architectHook) all hit the OpenCode SDK** via `isCallerOrchestrator()` / `getAgentFromSession()`. When SQLite backend is active, this is `findNearestMessageWithFieldsFromSDK()` (local HTTP to the OpenCode @@ -213,7 +213,7 @@ await hooks.architectHook?.["tool.execute.before"]?.(input, output) message JSON. **Either path is I/O-bound** and benefits massively from parallelism. Today they are sequential (rows 13, 14, 15), which is the single biggest cumulative I/O cost in the chain. -6. **`prometheusMdOnly` is the only hook with TRIPLE classification** +6. **`oracleMdOnly` is the only hook with TRIPLE classification** (BLOCKING + MUTATOR + NETWORK). It is the only hook where these three concerns coexist. In Wave 2 (BLOCKING) it can run in parallel with the other 4 guards, but its MUTATOR side (`output.args.prompt`, line 30) @@ -226,10 +226,10 @@ await hooks.architectHook?.["tool.execute.before"]?.(input, output) | Pattern | Affected Hooks | Why Sequential | |---------|----------------|----------------| -| Multiple writers to `output.args.prompt` (prepend) | `prometheusMdOnly` (line 30), `mouseNotepad` (line 36), `architectHook` (line 48) | Second writer's `prompt = PREFIX + prompt` stomps first writer's prefix. | -| `REPLACE` writer + `CONCAT` writer on same field (`output.message`) | `bashFileReadGuard` (REPLACE, line 38), `nonInteractiveEnv` (REPLACE, line 39), `prometheusMdOnly` (CONCAT, line 72), `architectHook` (CONCAT, line 34) | REPLACE loses the concat target; CONCAT after REPLACE can re-introduce lost content. | +| Multiple writers to `output.args.prompt` (prepend) | `oracleMdOnly` (line 30), `mouseNotepad` (line 36), `architectHook` (line 48) | Second writer's `prompt = PREFIX + prompt` stomps first writer's prefix. | +| `REPLACE` writer + `CONCAT` writer on same field (`output.message`) | `bashFileReadGuard` (REPLACE, line 38), `nonInteractiveEnv` (REPLACE, line 39), `oracleMdOnly` (CONCAT, line 72), `architectHook` (CONCAT, line 34) | REPLACE loses the concat target; CONCAT after REPLACE can re-introduce lost content. | | `output.args.command` rewrite | `nonInteractiveEnv` (line 58) | Mutates the only field other hooks (e.g. `bashFileReadGuard` line 29) read. Re-ordering would change semantics. | -| Shared SDK / filesystem read (e.g. `isCallerOrchestrator`) | `prometheusMdOnly`, `mouseNotepad`, `architectHook` | Concurrent calls hit the same SDK or same message dir. Not a correctness issue (calls are independent and read-only), but a contention hot-spot — parallelizing them is **safe** but each call still costs I/O. | +| Shared SDK / filesystem read (e.g. `isCallerOrchestrator`) | `oracleMdOnly`, `mouseNotepad`, `architectHook` | Concurrent calls hit the same SDK or same message dir. Not a correctness issue (calls are independent and read-only), but a contention hot-spot — parallelizing them is **safe** but each call still costs I/O. | | `throw` short-circuit semantics | All 5 `BLOCKING` hooks | If two BLOCKING guards run in parallel and both throw, only the first-rejected throw is propagated. This is **intentional** (first error wins), and `Promise.allSettled` + manual re-throw is the safe pattern. | ## Safe Promise.all Bundles (for T1.1) @@ -252,7 +252,7 @@ const settled = await Promise.allSettled([ hooks.envFileWriteGuard?.["tool.execute.before"]?.(input, output), hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output), hooks.tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output), - hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output), + hooks.oracleMdOnly?.["tool.execute.before"]?.(input, output), ]) for (const r of settled) if (r.status === "rejected") throw r.reason @@ -260,12 +260,12 @@ for (const r of settled) if (r.status === "rejected") throw r.reason await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output) await hooks.bashFileReadGuard?.["tool.execute.before"]?.(input, output) await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) -await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) +await hooks.oracleMdOnly?.["tool.execute.before"]?.(input, output) await hooks.mouseNotepad?.["tool.execute.before"]?.(input, output) await hooks.architectHook?.["tool.execute.before"]?.(input, output) ``` -> **Note**: `prometheusMdOnly` and `architectHook` already ran in Bundle B +> **Note**: `oracleMdOnly` and `architectHook` already ran in Bundle B > (BLOCKING path). They must run a **second time** in Bundle C (MUTATOR > path) to apply their `output.args` and `output.message` mutations. > The BLOCKING path's `throw` short-circuits Bundle B on reject, so the diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index fe36ab4c020..d3c70483b0f 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -46,7 +46,7 @@ export type SessionHooks = { editErrorRecovery: ReturnType | null delegateTaskRetry: ReturnType | null startWork: ReturnType | null - prometheusMdOnly: ReturnType | null + oracleMdOnly: ReturnType | null mouseNotepad: ReturnType | null questionLabelTruncator: ReturnType taskResumeInfo: ReturnType @@ -141,8 +141,8 @@ export function createSessionHooks(args: { ? safeHook("start-work", () => createStartWorkHook(ctx)) : null - const prometheusMdOnly = isHookEnabled("prometheus-md-only") - ? safeHook("prometheus-md-only", () => createOracleMdOnlyHook(ctx)) + const oracleMdOnly = isHookEnabled("oracle-md-only") + ? safeHook("oracle-md-only", () => createOracleMdOnlyHook(ctx)) : null const mouseNotepad = isHookEnabled("mouse-notepad") @@ -183,7 +183,7 @@ export function createSessionHooks(args: { editErrorRecovery, delegateTaskRetry, startWork, - prometheusMdOnly, + oracleMdOnly, mouseNotepad, questionLabelTruncator, taskResumeInfo, diff --git a/src/plugin/hooks/create-skill-hooks.ts b/src/plugin/hooks/create-skill-hooks.ts index 472b6026592..bda1c6c8c8a 100644 --- a/src/plugin/hooks/create-skill-hooks.ts +++ b/src/plugin/hooks/create-skill-hooks.ts @@ -1,6 +1,6 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import type { HookName } from "../../config" -import type { LoadedSkill } from "../../features/opencode-skill-loader/types" +import type { BuiltinSkill } from "../../features/builtin-skills" import { createAutoSlashCommandHook, createCategorySkillReminderHook } from "../../hooks" import { safeCreateHook } from "../../shared/safe-create-hook" import type { PluginContext } from "../types" @@ -14,10 +14,10 @@ export function createSkillHooks(args: { ctx: PluginContext isHookEnabled: (hookName: HookName) => boolean safeHookEnabled: boolean - mergedSkills: LoadedSkill[] + builtinSkills: BuiltinSkill[] availableSkills: AvailableSkill[] }): SkillHooks { - const { ctx, isHookEnabled, safeHookEnabled, mergedSkills, availableSkills } = args + const { ctx, isHookEnabled, safeHookEnabled, builtinSkills, availableSkills } = args const safeHook = (hookName: HookName, factory: () => T): T | null => safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) @@ -29,7 +29,7 @@ export function createSkillHooks(args: { const autoSlashCommand = isHookEnabled("auto-slash-command") ? safeHook("auto-slash-command", () => - createAutoSlashCommandHook({ skills: mergedSkills })) + createAutoSlashCommandHook({ skills: builtinSkills, client: ctx.client })) : null return { categorySkillReminder, autoSlashCommand } diff --git a/src/plugin/skill-context.ts b/src/plugin/skill-context.ts index 58d9a925351..b20487fd771 100644 --- a/src/plugin/skill-context.ts +++ b/src/plugin/skill-context.ts @@ -1,38 +1,20 @@ import type { AvailableSkill } from "../agents/dynamic-agent-prompt-builder" import type { MatrixxConfig } from "../config" import type { BrowserAutomationProvider } from "../config/schema/browser-automation" -import { createBuiltinSkills } from "../features/builtin-skills" -import { - discoverConfigSourceSkills, - discoverGlobalAgentsSkills, - discoverOpencodeGlobalSkills, - discoverOpencodeProjectSkills, - discoverProjectAgentsSkills, - mergeSkills, -} from "../features/opencode-skill-loader" -import type { - LoadedSkill, - SkillScope, -} from "../features/opencode-skill-loader/types" +import { type BuiltinSkill, createBuiltinSkills } from "../features/builtin-skills" export type SkillContext = { - mergedSkills: LoadedSkill[] + builtinSkills: BuiltinSkill[] availableSkills: AvailableSkill[] browserProvider: BrowserAutomationProvider disabledSkills: Set } -function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] { - if (scope === "user" || scope === "opencode") return "user" - if (scope === "project" || scope === "opencode-project") return "project" - return "plugin" -} - export async function createSkillContext(args: { directory: string pluginConfig: MatrixxConfig }): Promise { - const { directory, pluginConfig } = args + const { pluginConfig } = args const browserProvider: BrowserAutomationProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright" @@ -47,39 +29,14 @@ export async function createSkillContext(args: { disabledSkills, }) - const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills, agentsProjectSkills, agentsGlobalSkills] = - await Promise.all([ - discoverConfigSourceSkills({ - config: pluginConfig.skills, - configDir: directory, - }), - Promise.resolve([]), - discoverOpencodeGlobalSkills(), - Promise.resolve([]), - discoverOpencodeProjectSkills(directory), - discoverProjectAgentsSkills(directory), - discoverGlobalAgentsSkills(), - ]) - - const mergedSkills = mergeSkills( - builtinSkills, - pluginConfig.skills, - configSourceSkills, - [...userSkills, ...agentsGlobalSkills], - globalSkills, - [...projectSkills, ...agentsProjectSkills], - opencodeProjectSkills, - { configDir: directory }, - ) - - const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({ + const availableSkills: AvailableSkill[] = builtinSkills.map((skill) => ({ name: skill.name, - description: skill.definition.description ?? "", - location: mapScopeToLocation(skill.scope), + description: skill.description, + location: "plugin", })) return { - mergedSkills, + builtinSkills, availableSkills, browserProvider, disabledSkills, diff --git a/src/plugin/tool-execute-before.bench.ts b/src/plugin/tool-execute-before.bench.ts index eb513433f33..bd83c6f5783 100644 --- a/src/plugin/tool-execute-before.bench.ts +++ b/src/plugin/tool-execute-before.bench.ts @@ -11,18 +11,18 @@ * envFileWriteGuard, * writeExistingFileGuard, * tasksTodowriteDisabler, - * prometheusMdOnly (BLOCKING) + * oracleMdOnly (BLOCKING) * Wave 3 (6 calls, sequential): nonInteractiveEnv, * bashFileReadGuard, * questionLabelTruncator, - * prometheusMdOnly (MUTATOR), + * oracleMdOnly (MUTATOR), * mouseNotepad, * architectHook * - * Total: 16 calls per iteration (prometheusMdOnly appears in both Wave 2 and - * Wave 3; the handler calls `hooks.prometheusMdOnly?.["tool.execute.before"]` + * Total: 16 calls per iteration (oracleMdOnly appears in both Wave 2 and + * Wave 3; the handler calls `hooks.oracleMdOnly?.["tool.execute.before"]` * twice — once in Wave 2 and once in Wave 3 — so the bench must build a - * SINGLE prometheusMdOnly hook that records both invocations). + * SINGLE oracleMdOnly hook that records both invocations). * * Assertions: * - All 16 hook invocations per iteration. @@ -37,7 +37,7 @@ * not assert that. * - Per-iteration global counter increments by exactly 16 with no gaps * or duplicates, across all ITERATIONS. - * - prometheusMdOnly is called exactly twice per iteration (one in Wave 2, + * - oracleMdOnly is called exactly twice per iteration (one in Wave 2, * one in Wave 3). * * Target: p99 < 0.0158ms (baseline 0.0226ms × 0.7, Task T1.1 spec). @@ -66,14 +66,14 @@ const WAVE_2: HookName[] = [ "envFileWriteGuard", "writeExistingFileGuard", "tasksTodowriteDisabler", - "prometheusMdOnly", + "oracleMdOnly", ] const WAVE_3: HookName[] = [ "nonInteractiveEnv", "bashFileReadGuard", "questionLabelTruncator", - "prometheusMdOnly", + "oracleMdOnly", "mouseNotepad", "architectHook", ] @@ -247,13 +247,13 @@ describe("tool.execute.before T1.1 (3-wave parallelized)", () => { } } - // prometheusMdOnly total = 2 * ITERATIONS - const prometheusIdx = NAME_TO_IDX.get("prometheusMdOnly") ?? 0 - let prometheusTotal = 0 + // oracleMdOnly total = 2 * ITERATIONS + const oracleIdx = NAME_TO_IDX.get("oracleMdOnly") ?? 0 + let oracleTotal = 0 for (let i = 0; i < TOTAL_CALLS; i++) { - if (storage.nameIdx[i] === prometheusIdx) prometheusTotal++ + if (storage.nameIdx[i] === oracleIdx) oracleTotal++ } - expect(prometheusTotal).toBe(2 * ITERATIONS) + expect(oracleTotal).toBe(2 * ITERATIONS) // Sanity expect(p99).toBeGreaterThanOrEqual(p50) diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index d92a3822edb..3e29a106c09 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -48,7 +48,7 @@ export function createToolExecuteBeforeHandler(args: { const envFileWriteGuardHook = hooks.envFileWriteGuard?.["tool.execute.before"] const writeExistingFileGuardHook = hooks.writeExistingFileGuard?.["tool.execute.before"] const tasksTodowriteDisablerHook = hooks.tasksTodowriteDisabler?.["tool.execute.before"] - const prometheusMdOnlyHook = hooks.prometheusMdOnly?.["tool.execute.before"] + const oracleMdOnlyHook = hooks.oracleMdOnly?.["tool.execute.before"] const nonInteractiveEnvHook = hooks.nonInteractiveEnv?.["tool.execute.before"] const bashFileReadGuardHook = hooks.bashFileReadGuard?.["tool.execute.before"] const questionLabelTruncatorHook = hooks.questionLabelTruncator?.["tool.execute.before"] @@ -87,7 +87,7 @@ const rtkBashRewriterHook = hooks.rtkBashRewriter?.["tool.execute.before"] // rejections. Switching from allSettled to all also drops the // per-call result-array allocation. // - // `prometheusMdOnly` is BOTH BLOCKING (Wave 2) AND MUTATOR (Wave 3). + // `oracleMdOnly` is BOTH BLOCKING (Wave 2) AND MUTATOR (Wave 3). // It runs here for the BLOCKING behavior, then again in Wave 3 for // the output.args.prompt prepend. The first invocation never mutates // the throw path (throws on BLOCKED_TOOLS usage). @@ -96,20 +96,20 @@ const rtkBashRewriterHook = hooks.rtkBashRewriter?.["tool.execute.before"] envFileWriteGuardHook?.(input, output), writeExistingFileGuardHook?.(input, output), tasksTodowriteDisablerHook?.(input, output), - prometheusMdOnlyHook?.(input, output), + oracleMdOnlyHook?.(input, output), ]) // Wave 3 (7 hooks): MUTATOR — must run sequentially to preserve // mutation order. Specifically: - // - 3 hooks CONCAT to output.args.prompt (prometheusMdOnly, + // - 3 hooks CONCAT to output.args.prompt (oracleMdOnly, // mouseNotepad, architectHook). Parallel writes would stomp // each other (both read prompt from the same starting state). // - nonInteractiveEnv REWRITES output.args.command (line 58) and // REPLACES output.message; running it before other mutators is // required so that downstream hooks see the rewritten command. - // - The order prometheusMdOnly -> mouseNotepad -> architectHook + // - The order oracleMdOnly -> mouseNotepad -> architectHook // is critical: architectHook's prepended must be - // the outermost (closest to the model), prometheusMdOnly's + // the outermost (closest to the model), oracleMdOnly's // prepended PLANNING_CONSULT_WARNING the innermost (closest to the // user prompt). Reversing the order would change semantics. // --------------------------------------------------------------------- @@ -137,7 +137,7 @@ const rtkBashRewriterHook = hooks.rtkBashRewriter?.["tool.execute.before"] await nonInteractiveEnvHook?.(input, output) await bashFileReadGuardHook?.(input, output) await questionLabelTruncatorHook?.(input, output) - await prometheusMdOnlyHook?.(input, output) + await oracleMdOnlyHook?.(input, output) await mouseNotepadHook?.(input, output) await architectHookHook?.(input, output) diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 9450a5982ec..4f762362902 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -5,7 +5,6 @@ import type { } from "../agents/dynamic-agent-prompt-builder" import type { MatrixxConfig } from "../config" import type { Managers } from "../create-managers" -import { getMainSessionID } from "../features/session-state" import { log } from "../shared" import { filterDisabledTools } from "../shared/disabled-tools" import { @@ -17,6 +16,7 @@ import { createBddParseGherkinTool, createBddPipelineTool, createBddValidateContractTool, + createDcpSwitchProfileTool, createDelegateAgent, createDelegateTask, createGlobTools, @@ -25,7 +25,6 @@ createDelegateAgent, createHashlineEditTool, createLookAt, createSessionManagerTools, - createSkillMcpTool, createSkillTool, createSlashcommandTool, createTaskCreateTool, @@ -46,7 +45,7 @@ export type ToolRegistryResult = { export function createToolRegistry(args: { ctx: PluginContext pluginConfig: MatrixxConfig - managers: Pick + managers: Pick skillContext: SkillContext availableCategories: AvailableCategory[] }): ToolRegistryResult { @@ -90,25 +89,17 @@ export function createToolRegistry(args: { }, }) - const getSessionIDForMcp = (): string => getMainSessionID() || "" const skillTool = createSkillTool({ - skills: skillContext.mergedSkills, - mcpManager: managers.skillMcpManager, - getSessionID: getSessionIDForMcp, + skills: skillContext.builtinSkills, disabledSkills: skillContext.disabledSkills, }) - const skillMcpTool = createSkillMcpTool({ - manager: managers.skillMcpManager, - getLoadedSkills: () => skillContext.mergedSkills, - getSessionID: getSessionIDForMcp, - }) - const commands = discoverCommandsSync(ctx.directory) const slashcommandTool = createSlashcommandTool({ commands, - skills: skillContext.mergedSkills, + skills: skillContext.builtinSkills, + client: ctx.client, }) const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false @@ -141,12 +132,12 @@ const assemblyTool = assemblyEnabled ...createAstGrepTools(ctx), ...createSessionManagerTools(ctx), ...createHandoffTools(ctx), + ...createDcpSwitchProfileTool({ pluginConfig }), ...backgroundTools, delegate_agent: delegateAgent, ...(lookAt ? { look_at: lookAt } : {}), task: delegateTask, skill: skillTool, - skill_mcp: skillMcpTool, slashcommand: slashcommandTool, interactive_bash, ...taskToolsRecord, diff --git a/src/shared/migration/hook-names.ts b/src/shared/migration/hook-names.ts index b22b5371fdb..2c65546b1a6 100644 --- a/src/shared/migration/hook-names.ts +++ b/src/shared/migration/hook-names.ts @@ -5,6 +5,7 @@ export const HOOK_NAME_MAP: Record = { "anthropic-auto-compact": "anthropic-context-window-limit-recovery", "sisyphus-orchestrator": "architect", "sisyphus-junior-notepad": "mouse-notepad", + "prometheus-md-only": "oracle-md-only", // Removed hooks (v3.0.0) - will be filtered out and user warned "empty-message-sanitizer": null, diff --git a/src/shared/system-directive.ts b/src/shared/system-directive.ts index 368fc63c563..cc59ba644e7 100644 --- a/src/shared/system-directive.ts +++ b/src/shared/system-directive.ts @@ -54,6 +54,6 @@ export const SystemDirectiveTypes = { SINGLE_TASK_ONLY: "SINGLE TASK ONLY", COMPACTION_CONTEXT: "COMPACTION CONTEXT", CONTEXT_WINDOW_MONITOR: "CONTEXT WINDOW MONITOR", - PROMETHEUS_READ_ONLY: "PROMETHEUS READ-ONLY", + ORACLE_READ_ONLY: "ORACLE READ-ONLY", } as const diff --git a/src/tools/dcp-switch-profile/index.ts b/src/tools/dcp-switch-profile/index.ts new file mode 100644 index 00000000000..4fe6402873a --- /dev/null +++ b/src/tools/dcp-switch-profile/index.ts @@ -0,0 +1 @@ +export { createDcpSwitchProfileTool } from "./tools" diff --git a/src/tools/dcp-switch-profile/tools.ts b/src/tools/dcp-switch-profile/tools.ts new file mode 100644 index 00000000000..2381d80d4bb --- /dev/null +++ b/src/tools/dcp-switch-profile/tools.ts @@ -0,0 +1,151 @@ +import { existsSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool" +import type { DcpConfig } from "../../config/schema/dcp" +import { BUILTIN_DCP_PROFILES } from "../../config/schema/dcp" + +const DCP_PLUGIN_DIR = join(homedir(), ".config", "opencode", "node_modules", "@tarquinen", "opencode-dcp") +const DCP_SYMLINK = join(homedir(), ".config", "opencode", "dcp.jsonc") + +const VALID_PROFILES = ["economy", "balanced", "performance", "ultimate"] as const + +export interface DcpSwitchProfileOptions { + pluginConfig?: { dcp?: DcpConfig } +} + +/** + * Verify DCP plugin is installed at the standard OpenCode location. + */ +function checkDcpInstalled(): string | null { + if (!existsSync(DCP_PLUGIN_DIR)) { + return `DCP is not installed at ${DCP_PLUGIN_DIR}. Install it with: npm install --prefix ~/.config/opencode @tarquinen/opencode-dcp` + } + return null +} + +/** + * Build a full inline DCP PluginConfig object for the given profile. + * Starts with base values from dcpConfig.base, overlays profile-specific + * values from dcpConfig.profiles[profile], and falls back to built-in + * profile definitions when no Matrixx config is provided. + */ +function buildInlineConfig(profile: string, options?: DcpSwitchProfileOptions): Record { + const dcpConfig = options?.pluginConfig?.dcp + const base = dcpConfig?.base ? (dcpConfig.base as Record) : {} + const validProfiles = BUILTIN_DCP_PROFILES as unknown as Record> + const profileConfig = dcpConfig?.profiles?.[profile] ?? validProfiles[profile] ?? {} + + // Extract sub-objects with proper typing + const compressBase = (base.compress as Record) ?? {} + const strategiesBase = (base.strategies as Record) ?? {} + const commandsCfg = (base.commands as Record) ?? {} + const manualModeCfg = (base.manualMode as Record) ?? {} + const profileCompress = (profileConfig.compress as Record) ?? {} + const profileTurn = (profileConfig.turnProtection as Record) ?? {} + const profileExp = (profileConfig.experimental as Record) ?? {} + const profilePurge = ((profileConfig.strategies as Record)?.purgeErrors as Record) ?? {} + const basePurge = ((strategiesBase.purgeErrors as Record)) ?? {} + + return { + $schema: + "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/v3.1.14/dcp.schema.json", + enabled: true, + autoUpdate: (base.autoUpdate as boolean) ?? false, + debug: (base.debug as boolean) ?? false, + pruneNotification: (profileConfig.pruneNotification as string) ?? "minimal", + pruneNotificationType: (base.pruneNotificationType as string) ?? "chat", + compress: { + mode: (compressBase.mode as string) ?? "range", + permission: (compressBase.permission as string) ?? "allow", + showCompression: (compressBase.showCompression as boolean) ?? true, + summaryBuffer: (compressBase.summaryBuffer as boolean) ?? true, + maxContextLimit: (profileCompress.maxContextLimit as string | number) ?? "60%", + minContextLimit: (profileCompress.minContextLimit as string | number) ?? "30%", + nudgeFrequency: (profileCompress.nudgeFrequency as number) ?? 3, + iterationNudgeThreshold: + (profileCompress.iterationNudgeThreshold as number) ?? (compressBase.iterationNudgeThreshold as number) ?? 5, + nudgeForce: (profileCompress.nudgeForce as string) ?? (compressBase.nudgeForce as string) ?? "strong", + protectedTools: + (profileCompress.protectedTools as string[]) ?? (compressBase.protectedTools as string[]) ?? [], + protectTags: (profileCompress.protectTags as boolean) ?? false, + protectUserMessages: + (profileCompress.protectUserMessages as boolean) ?? (compressBase.protectUserMessages as boolean) ?? false, + }, + turnProtection: { + enabled: (profileTurn.enabled as boolean) ?? true, + turns: (profileTurn.turns as number) ?? 2, + }, + experimental: { + allowSubAgents: (profileExp.allowSubAgents as boolean) ?? true, + customPrompts: false, + }, + protectedFilePatterns: (base.protectedFilePatterns as string[]) ?? [], + commands: { + enabled: (commandsCfg.enabled as boolean) ?? true, + protectedTools: (commandsCfg.protectedTools as string[]) ?? [], + }, + manualMode: { + enabled: (manualModeCfg.enabled as boolean) ?? false, + automaticStrategies: (manualModeCfg.automaticStrategies as boolean) ?? true, + }, + strategies: { + deduplication: { + enabled: ((strategiesBase.deduplication as Record)?.enabled as boolean) ?? true, + protectedTools: ((strategiesBase.deduplication as Record)?.protectedTools as string[]) ?? [], + }, + purgeErrors: { + enabled: (basePurge.enabled as boolean) ?? true, + turns: (profilePurge.turns as number) ?? 2, + protectedTools: (basePurge.protectedTools as string[]) ?? [], + }, + }, + } +} + +/** + * Switch the active DCP profile. + * Reads profile parameters from the Matrixx plugin configuration and writes + * a full inline DCP config to ~/.config/opencode/dcp.jsonc. + */ +function switchProfile(profile: string, options?: DcpSwitchProfileOptions): string { + // Validate profile + if (!VALID_PROFILES.includes(profile as (typeof VALID_PROFILES)[number])) { + return `Error: Invalid profile "${profile}". Valid profiles: ${VALID_PROFILES.join(", ")}` + } + + // Check DCP installation + const dcpError = checkDcpInstalled() + if (dcpError) return dcpError + + // Build the full inline config from Matrixx configuration + const inlineConfig = buildInlineConfig(profile, options) + + // Write directly to the DCP config symlink target + writeFileSync(DCP_SYMLINK, JSON.stringify(inlineConfig, null, 2) + "\n") + + return `\u2713 Switched to DCP profile: ${profile}\n\nRestart OpenCode session for changes to take effect.` +} + +export function createDcpSwitchProfileTool(options?: DcpSwitchProfileOptions): Record { + const dcp_switch_profile: ToolDefinition = tool({ + description: + "Switch the active DCP (Dynamic Context Pruning) profile tier. " + + "Reads profile parameters from the Matrixx plugin configuration and writes a full inline DCP config " + + "to ~/.config/opencode/dcp.jsonc. No external files needed. " + + "Call this with one of: economy, balanced, performance, ultimate.", + args: { + profile: tool.schema + .enum(VALID_PROFILES) + .describe( + "Target DCP profile tier: economy (most aggressive), balanced, performance, ultimate (least aggressive)", + ), + }, + async execute(args) { + const profile = args.profile as string + return switchProfile(profile, options) + }, + }) + + return { dcp_switch_profile } +} diff --git a/src/tools/delegate-agent/constants.ts b/src/tools/delegate-agent/constants.ts index 5fc3666dbf9..77a6f62e89e 100644 --- a/src/tools/delegate-agent/constants.ts +++ b/src/tools/delegate-agent/constants.ts @@ -2,9 +2,6 @@ export const ALLOWED_AGENTS = [ "trinity", "operator", "oracle", - "hephaestus", - "metis", - "momus", "construct", ] as const diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index b3c616e240b..da4477ed60e 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -555,7 +555,7 @@ export function isPlanAgent(agentName: string | undefined): boolean { } /** - * Plan family: plan + prometheus. Shares mutual delegation blocking and task tool permission. + * Plan family: plan + oracle. Shares mutual delegation blocking and task tool permission. * Does NOT share system prompt (only isPlanAgent controls that). */ export const PLAN_FAMILY_NAMES = ["plan", "oracle"] diff --git a/src/tools/delegate-task/skill-resolver.ts b/src/tools/delegate-task/skill-resolver.ts index 4fed3466f35..158a2261423 100644 --- a/src/tools/delegate-task/skill-resolver.ts +++ b/src/tools/delegate-task/skill-resolver.ts @@ -1,21 +1,23 @@ import type { BrowserAutomationProvider } from "../../config/schema" -import { discoverSkills } from "../../features/opencode-skill-loader" -import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content" +import { createBuiltinSkills } from "../../features/builtin-skills" -export async function resolveSkillContent( +export function resolveSkillContent( skills: string[], options: { browserProvider?: BrowserAutomationProvider, disabledSkills?: Set } -): Promise<{ content: string | undefined; error: string | null }> { +): { content: string | undefined; error: string | null } { if (skills.length === 0) { return { content: undefined, error: null } } - const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options) + const builtinSkills = createBuiltinSkills({ browserProvider: options.browserProvider, disabledSkills: options.disabledSkills }) + const resolved = builtinSkills.filter(s => skills.includes(s.name)) + const resolvedNames = new Set(resolved.map(s => s.name)) + const notFound = skills.filter(name => !resolvedNames.has(name)) + if (notFound.length > 0) { - const allSkills = await discoverSkills({ includeClaudeCodePaths: true }) - const available = allSkills.map(s => s.name).join(", ") + const available = builtinSkills.map(s => s.name).join(", ") return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` } } - return { content: Array.from(resolved.values()).join("\n\n"), error: null } + return { content: resolved.map(s => s.template).join("\n\n"), error: null } } diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index eb218ea1faa..203995691c2 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -38,7 +38,7 @@ Mouse is spawned automatically when you specify a category. Pick the appropriate return { agentToUse: "", categoryModel: undefined, - error: `You are a plan-family agent (plan/prometheus). You cannot delegate to other plan-family agents via task. + error: `You are a plan-family agent (plan/oracle). You cannot delegate to other plan-family agents via task. Create the work plan directly - that's your job as the planning agent.`, } diff --git a/src/tools/index.ts b/src/tools/index.ts index f34296d6699..31284d9d53d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -22,7 +22,6 @@ export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./inte export { createSessionManagerTools } from "./session-manager" export { sessionExists } from "./session-manager/storage" export { createSkillTool } from "./skill" -export { createSkillMcpTool } from "./skill-mcp" export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand" export { lspManager } @@ -39,6 +38,7 @@ import { type OpencodeClient = PluginInput["client"] export { createAssemblyTool } from "./assembly" +export { createDcpSwitchProfileTool } from "./dcp-switch-profile" export { createDelegateAgent } from "./delegate-agent" export { createDelegateTask } from "./delegate-task" export { createHashlineEditTool } from "./hashline-edit" diff --git a/src/tools/skill-mcp/constants.ts b/src/tools/skill-mcp/constants.ts deleted file mode 100644 index 8fe23db779c..00000000000 --- a/src/tools/skill-mcp/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ - -export const SKILL_MCP_DESCRIPTION = `Invoke MCP server operations from skill-embedded MCPs. Requires mcp_name plus exactly one of: tool_name, resource_name, or prompt_name.` diff --git a/src/tools/skill-mcp/index.ts b/src/tools/skill-mcp/index.ts deleted file mode 100644 index a630a4a5edc..00000000000 --- a/src/tools/skill-mcp/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./constants" -export { createSkillMcpTool } from "./tools" -export * from "./types" diff --git a/src/tools/skill-mcp/tools.ts b/src/tools/skill-mcp/tools.ts deleted file mode 100644 index 9e2c24dcb88..00000000000 --- a/src/tools/skill-mcp/tools.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { type ToolDefinition, tool } from "@opencode-ai/plugin" -import type { LoadedSkill } from "../../features/opencode-skill-loader/types" -import type { SkillMcpClientInfo, SkillMcpManager, SkillMcpServerContext } from "../../features/skill-mcp-manager" -import { SKILL_MCP_DESCRIPTION } from "./constants" -import type { SkillMcpArgs } from "./types" - -interface SkillMcpToolOptions { - manager: SkillMcpManager - getLoadedSkills: () => LoadedSkill[] - getSessionID: () => string -} - -type OperationType = { type: "tool" | "resource" | "prompt"; name: string } - -function validateOperationParams(args: SkillMcpArgs): OperationType { - const operations: OperationType[] = [] - if (args.tool_name) operations.push({ type: "tool", name: args.tool_name }) - if (args.resource_name) operations.push({ type: "resource", name: args.resource_name }) - if (args.prompt_name) operations.push({ type: "prompt", name: args.prompt_name }) - - if (operations.length === 0) { - throw new Error( - `Missing operation. Exactly one of tool_name, resource_name, or prompt_name must be specified.\n\n` + - `Examples:\n` + - ` skill_mcp(mcp_name="sqlite", tool_name="query", arguments='{"sql": "SELECT * FROM users"}')\n` + - ` skill_mcp(mcp_name="memory", resource_name="memory://notes")\n` + - ` skill_mcp(mcp_name="helper", prompt_name="summarize", arguments='{"text": "..."}')`, - ) - } - - if (operations.length > 1) { - const provided = [ - args.tool_name && `tool_name="${args.tool_name}"`, - args.resource_name && `resource_name="${args.resource_name}"`, - args.prompt_name && `prompt_name="${args.prompt_name}"`, - ] - .filter(Boolean) - .join(", ") - - throw new Error( - `Multiple operations specified. Exactly one of tool_name, resource_name, or prompt_name must be provided.\n\n` + - `Received: ${provided}\n\n` + - `Use separate calls for each operation.`, - ) - } - - return operations[0] -} - -function findMcpServer( - mcpName: string, - skills: LoadedSkill[], -): { skill: LoadedSkill; config: NonNullable[string] } | null { - for (const skill of skills) { - if (skill.mcpConfig && mcpName in skill.mcpConfig) { - return { skill, config: skill.mcpConfig[mcpName] } - } - } - return null -} - -function formatAvailableMcps(skills: LoadedSkill[]): string { - const mcps: string[] = [] - for (const skill of skills) { - if (skill.mcpConfig) { - for (const serverName of Object.keys(skill.mcpConfig)) { - mcps.push(` - "${serverName}" from skill "${skill.name}"`) - } - } - } - return mcps.length > 0 ? mcps.join("\n") : " (none found)" -} - -function parseArguments(argsJson: string | Record | undefined): Record { - if (!argsJson) return {} - if (typeof argsJson === "object" && argsJson !== null) { - return argsJson - } - try { - // Strip outer single quotes if present (common in LLM output) - const jsonStr = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson - - const parsed = JSON.parse(jsonStr) - if (typeof parsed !== "object" || parsed === null) { - throw new Error("Arguments must be a JSON object") - } - return parsed as Record - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Invalid arguments JSON: ${errorMessage}\n\n` + - `Expected a valid JSON object, e.g.: '{"key": "value"}'\n` + - `Received: ${argsJson}`, - ) - } -} - -export function applyGrepFilter(output: string, pattern: string | undefined): string { - if (!pattern) return output - try { - const regex = new RegExp(pattern, "i") - const lines = output.split("\n") - const filtered = lines.filter((line) => regex.test(line)) - return filtered.length > 0 ? filtered.join("\n") : `[grep] No lines matched pattern: ${pattern}` - } catch { - return output - } -} - -export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition { - const { manager, getLoadedSkills, getSessionID } = options - - return tool({ - description: SKILL_MCP_DESCRIPTION, - args: { - mcp_name: tool.schema.string().describe("Name of the MCP server from skill config"), - tool_name: tool.schema.string().optional().describe("MCP tool to call"), - resource_name: tool.schema.string().optional().describe("MCP resource URI to read"), - prompt_name: tool.schema.string().optional().describe("MCP prompt to get"), - arguments: tool.schema - .union([tool.schema.string(), tool.schema.object({})]) - .optional() - .describe("JSON string or object of arguments"), - grep: tool.schema - .string() - .optional() - .describe("Regex pattern to filter output lines (only matching lines returned)"), - }, - async execute(args: SkillMcpArgs) { - const operation = validateOperationParams(args) - const skills = getLoadedSkills() - const found = findMcpServer(args.mcp_name, skills) - - if (!found) { - throw new Error( - `MCP server "${args.mcp_name}" not found.\n\n` + - `Available MCP servers in loaded skills:\n` + - formatAvailableMcps(skills) + - `\n\n` + - `Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`, - ) - } - - const info: SkillMcpClientInfo = { - serverName: args.mcp_name, - skillName: found.skill.name, - sessionID: getSessionID(), - } - - const context: SkillMcpServerContext = { - config: found.config, - skillName: found.skill.name, - } - - const parsedArgs = parseArguments(args.arguments) - - let output: string - switch (operation.type) { - case "tool": { - const result = await manager.callTool(info, context, operation.name, parsedArgs) - output = JSON.stringify(result, null, 2) - break - } - case "resource": { - const result = await manager.readResource(info, context, operation.name) - output = JSON.stringify(result, null, 2) - break - } - case "prompt": { - const stringArgs: Record = {} - for (const [key, value] of Object.entries(parsedArgs)) { - stringArgs[key] = String(value) - } - const result = await manager.getPrompt(info, context, operation.name, stringArgs) - output = JSON.stringify(result, null, 2) - break - } - } - return applyGrepFilter(output, args.grep) - }, - }) -} diff --git a/src/tools/skill-mcp/types.ts b/src/tools/skill-mcp/types.ts deleted file mode 100644 index 9fe44baa6c4..00000000000 --- a/src/tools/skill-mcp/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface SkillMcpArgs { - mcp_name: string - tool_name?: string - resource_name?: string - prompt_name?: string - arguments?: string | Record - grep?: string -} diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 43591071374..bb8af65f0ca 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -1,21 +1,16 @@ -import { dirname } from "node:path" -import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js" import { type ToolDefinition, tool } from "@opencode-ai/plugin" -import type { LoadedSkill } from "../../features/opencode-skill-loader" -import { extractSkillTemplate, getAllSkills } from "../../features/opencode-skill-loader/skill-content" -import type { SkillMcpClientInfo, SkillMcpManager, SkillMcpServerContext } from "../../features/skill-mcp-manager" +import { type BuiltinSkill, createBuiltinSkills } from "../../features/builtin-skills" import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants" import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types" -function loadedSkillToInfo(skill: LoadedSkill): SkillInfo { +function loadedSkillToInfo(skill: BuiltinSkill): SkillInfo { return { name: skill.name, - description: skill.definition.description || "", - location: skill.path, - scope: skill.scope, + description: skill.description || "", + location: undefined, license: skill.license, compatibility: skill.compatibility, - metadata: skill.metadata, + metadata: skill.metadata as Record | undefined, allowedTools: skill.allowedTools, } } @@ -39,106 +34,20 @@ function formatSkillsXml(skills: SkillInfo[]): string { return `\n\n\n${skillsXml}\n` } -async function extractSkillBody(skill: LoadedSkill): Promise { - if (skill.lazyContent) { - const fullTemplate = await skill.lazyContent.load() - const templateMatch = fullTemplate.match(/([\s\S]*?)<\/skill-instruction>/) - return templateMatch ? templateMatch[1].trim() : fullTemplate - } - - if (skill.path) { - return extractSkillTemplate(skill) - } - - const templateMatch = skill.definition.template?.match(/([\s\S]*?)<\/skill-instruction>/) - return templateMatch ? templateMatch[1].trim() : skill.definition.template || "" -} - -async function formatMcpCapabilities( - skill: LoadedSkill, - manager: SkillMcpManager, - sessionID: string -): Promise { - if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) { - return null - } - - const sections: string[] = ["", "## Available MCP Servers", ""] - - for (const [serverName, config] of Object.entries(skill.mcpConfig)) { - const info: SkillMcpClientInfo = { - serverName, - skillName: skill.name, - sessionID, - } - const context: SkillMcpServerContext = { - config, - skillName: skill.name, - } - - sections.push(`### ${serverName}`) - sections.push("") - - try { - const [tools, resources, prompts] = await Promise.all([ - manager.listTools(info, context).catch(() => []), - manager.listResources(info, context).catch(() => []), - manager.listPrompts(info, context).catch(() => []), - ]) - - if (tools.length > 0) { - sections.push("**Tools:**") - sections.push("") - for (const t of tools as Tool[]) { - sections.push(`#### \`${t.name}\``) - if (t.description) { - sections.push(t.description) - } - sections.push("") - sections.push("**inputSchema:**") - sections.push("```json") - sections.push(JSON.stringify(t.inputSchema, null, 2)) - sections.push("```") - sections.push("") - } - } - if (resources.length > 0) { - sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`) - } - if (prompts.length > 0) { - sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`) - } - - if (tools.length === 0 && resources.length === 0 && prompts.length === 0) { - sections.push("*No capabilities discovered*") - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`) - } - - sections.push("") - sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`) - sections.push("") - } - - return sections.join("\n") -} - export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition { - let cachedSkills: LoadedSkill[] | null = null + let cachedSkills: BuiltinSkill[] | null = null let cachedDescription: string | null = null - const getSkills = async (): Promise => { + const getSkills = (): BuiltinSkill[] => { if (options.skills) return options.skills if (cachedSkills) return cachedSkills - cachedSkills = await getAllSkills({disabledSkills: options?.disabledSkills}) + cachedSkills = createBuiltinSkills({ disabledSkills: options?.disabledSkills }) return cachedSkills } - const getDescription = async (): Promise => { + const getDescription = (): string => { if (cachedDescription) return cachedDescription - const skills = await getSkills() + const skills = getSkills() const skillInfos = skills.map(loadedSkillToInfo) cachedDescription = skillInfos.length === 0 ? TOOL_DESCRIPTION_NO_SKILLS @@ -163,7 +72,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), }, async execute(args: SkillArgs, ctx?: { agent?: string }) { - const skills = await getSkills() + const skills = getSkills() const skill = skills.find(s => s.name === args.name) if (!skill) { @@ -171,13 +80,12 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`) } - if (skill.definition.agent && (!ctx?.agent || skill.definition.agent !== ctx.agent)) { - throw new Error(`Skill "${args.name}" is restricted to agent "${skill.definition.agent}"`) + if (skill.agent && (!ctx?.agent || skill.agent !== ctx.agent)) { + throw new Error(`Skill "${args.name}" is restricted to agent "${skill.agent}"`) } - const body = await extractSkillBody(skill) - - const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd() + const body = skill.template + const dir = process.cwd() const output = [ `## Skill: ${skill.name}`, @@ -187,17 +95,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition body, ] - if (options.mcpManager && options.getSessionID && skill.mcpConfig) { - const mcpInfo = await formatMcpCapabilities( - skill, - options.mcpManager, - options.getSessionID() - ) - if (mcpInfo) { - output.push(mcpInfo) - } - } - return output.join("\n") }, }) diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts index 26103f38cf1..2306b8f158c 100644 --- a/src/tools/skill/types.ts +++ b/src/tools/skill/types.ts @@ -1,5 +1,5 @@ -import type { LoadedSkill, SkillScope } from "../../features/opencode-skill-loader/types" -import type { SkillMcpManager } from "../../features/skill-mcp-manager" +import type { BuiltinSkill } from "../../features/builtin-skills" + export interface SkillArgs { name: string } @@ -8,7 +8,6 @@ export interface SkillInfo { name: string description: string location?: string - scope: SkillScope license?: string compatibility?: string metadata?: Record @@ -16,12 +15,10 @@ export interface SkillInfo { } export interface SkillLoadOptions { - /** When true, only load from OpenCode paths (.opencode/skills/, ~/.config/opencode/skills/) */ - opencodeOnly?: boolean /** Pre-merged skills to use instead of discovering */ - skills?: LoadedSkill[] + skills?: BuiltinSkill[] /** MCP manager for querying skill-embedded MCP servers */ - mcpManager?: SkillMcpManager + mcpManager?: never /** Session ID getter for MCP client identification */ getSessionID?: () => string disabledSkills?: Set diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index 3a85c88b3c2..4ac759a343a 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -1,7 +1,6 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { basename, join } from "node:path" import { loadBuiltinCommands } from "../../features/builtin-commands" -import type { CommandFrontmatter } from "../../features/command-loader/types" import { getOpenCodeConfigDir, parseFrontmatter, sanitizeModelField } from "../../shared" import { isMarkdownFile } from "../../shared/file-utils" import type { CommandInfo, CommandMetadata, CommandScope } from "./types" @@ -20,7 +19,13 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm try { const content = readFileSync(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) + const { data, body } = parseFrontmatter<{ + description?: string + "argument-hint"?: string + agent?: string + model?: string + subtask?: boolean + }>(content) const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const metadata: CommandMetadata = { diff --git a/src/tools/slashcommand/skill-command-converter.ts b/src/tools/slashcommand/skill-command-converter.ts index 166b8ad99f6..eb294607ead 100644 --- a/src/tools/slashcommand/skill-command-converter.ts +++ b/src/tools/slashcommand/skill-command-converter.ts @@ -1,20 +1,18 @@ -import type { LoadedSkill } from "../../features/opencode-skill-loader" +import type { BuiltinSkill } from "../../features/builtin-skills" import type { CommandInfo } from "./types" -export function skillToCommandInfo(skill: LoadedSkill): CommandInfo { +export function skillToCommandInfo(skill: BuiltinSkill): CommandInfo { return { name: skill.name, - path: skill.path, metadata: { name: skill.name, - description: skill.definition.description || "", - argumentHint: skill.definition.argumentHint, - model: skill.definition.model, - agent: skill.definition.agent, - subtask: skill.definition.subtask, + description: skill.description || "", + argumentHint: skill.argumentHint, + model: skill.model, + agent: skill.agent, + subtask: skill.subtask, }, - content: skill.definition.template, - scope: skill.scope, - lazyContentLoader: skill.lazyContent, + content: skill.template, + scope: "builtin", } } diff --git a/src/tools/slashcommand/slashcommand-tool.ts b/src/tools/slashcommand/slashcommand-tool.ts index b931cba887d..7c8f3568c54 100644 --- a/src/tools/slashcommand/slashcommand-tool.ts +++ b/src/tools/slashcommand/slashcommand-tool.ts @@ -1,15 +1,43 @@ import { type ToolDefinition, tool } from "@opencode-ai/plugin" -import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" +import type { createOpencodeClient } from "@opencode-ai/sdk" +import { type BuiltinSkill, createBuiltinSkills } from "../../features/builtin-skills" +import { log } from "../../shared" import { discoverCommandsSync } from "./command-discovery" import { formatCommandList, formatLoadedCommand } from "./command-output-formatter" import { skillToCommandInfo } from "./skill-command-converter" import { buildDescriptionFromItems, TOOL_DESCRIPTION_PREFIX } from "./slashcommand-description" import type { CommandInfo, SlashcommandToolOptions } from "./types" +async function discoverPluginCommands(client?: ReturnType): Promise { + if (!client) return [] + + try { + const result = await client.command.list() + const commands = result.data ?? [] + return commands.map(cmd => ({ + name: cmd.name, + metadata: { + name: cmd.name, + description: cmd.description || "", + model: typeof cmd.model === "string" ? cmd.model : undefined, + agent: cmd.agent, + subtask: cmd.subtask, + }, + content: typeof cmd.template === "string" ? cmd.template : undefined, + scope: "plugin" as const, + })) + } catch (err) { + log(`[slashcommand] Failed to discover plugin commands:`, err) + return [] + } +} + export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition { let cachedCommands: CommandInfo[] | null = options.commands ?? null - let cachedSkills: LoadedSkill[] | null = options.skills ?? null + let cachedSkills: BuiltinSkill[] | null = options.skills ?? null + let cachedPluginCommands: CommandInfo[] | null = null let cachedDescription: string | null = null + const { client } = options const getCommands = (): CommandInfo[] => { if (cachedCommands) return cachedCommands @@ -17,16 +45,23 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T return cachedCommands } - const getSkills = async (): Promise => { + const getSkills = (): BuiltinSkill[] => { if (cachedSkills) return cachedSkills - cachedSkills = await discoverAllSkills() + cachedSkills = createBuiltinSkills() return cachedSkills } + const getPluginCommands = async (): Promise => { + if (cachedPluginCommands) return cachedPluginCommands + cachedPluginCommands = await discoverPluginCommands(client) + return cachedPluginCommands + } + const getAllItems = async (): Promise => { const commands = getCommands() - const skills = await getSkills() - return [...commands, ...skills.map(skillToCommandInfo)] + const skills = getSkills() + const pluginCommands = await getPluginCommands() + return [...commands, ...skills.map(skillToCommandInfo), ...pluginCommands] } const buildDescription = async (): Promise => { @@ -87,9 +122,7 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T return `No exact match for "/${commandName}". Did you mean: ${matchList}?\n\n${formatCommandList(allItems)}` } - return commandName.includes(":") - ? `Marketplace plugin commands like "/${commandName}" are not supported. Use .claude/commands/ for custom commands.\n\n${formatCommandList(allItems)}` - : `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.` + return `Command "/${commandName}" not found. Use the slashcommand tool with just a command name to list available commands.\n\n${formatCommandList(allItems)}` }, }) } diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts index 9375a1bf5b3..3cd1314b071 100644 --- a/src/tools/slashcommand/types.ts +++ b/src/tools/slashcommand/types.ts @@ -1,6 +1,12 @@ -import type { LazyContentLoader, LoadedSkill } from "../../features/opencode-skill-loader" +import type { BuiltinSkill } from "../../features/builtin-skills" -export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" + export interface LazyContentLoader { + loaded: boolean + content?: string + load: () => Promise +} + +export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" | "plugin" export interface CommandMetadata { name: string @@ -24,5 +30,7 @@ export interface SlashcommandToolOptions { /** Pre-loaded commands (skip discovery if provided) */ commands?: CommandInfo[] /** Pre-loaded skills (skip discovery if provided) */ - skills?: LoadedSkill[] + skills?: BuiltinSkill[] + /** OpenCode SDK client for discovering plugin-registered commands */ + client?: ReturnType } diff --git a/tests/agents/utils.test.ts b/tests/agents/utils.test.ts index b6135c1dc67..8fc8d303905 100644 --- a/tests/agents/utils.test.ts +++ b/tests/agents/utils.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import type { AgentConfig } from "@opencode-ai/sdk" -import { clearSkillCache } from "../../src/features/opencode-skill-loader/skill-content" import * as shared from "../../src/shared" import * as connectedProvidersCache from "../../src/shared/connected-providers-cache" import * as modelAvailability from "../../src/shared/model-availability" @@ -780,13 +779,6 @@ describe("buildAgent with category and skills", () => { const { buildAgent } = require("../../src/agents/agent-builder") const TEST_MODEL = "anthropic/claude-opus-4-6" - beforeEach(() => { - clearSkillCache() - }) - - afterEach(() => { - clearSkillCache() - }) test("agent with category inherits category settings", () => { // #given - agent factory that sets category but no model diff --git a/tests/features/background-agent/manager.test.ts b/tests/features/background-agent/manager.test.ts index 5039fa9d350..affbf65ba82 100644 --- a/tests/features/background-agent/manager.test.ts +++ b/tests/features/background-agent/manager.test.ts @@ -14,6 +14,10 @@ import { BackgroundManager } from "../../../src/features/background-agent/manage import { _resetMessageDirCacheForTesting, getMessageDir } from "../../../src/features/background-agent/message-dir" import type { BackgroundTask, ResumeInput } from "../../../src/features/background-agent/types" +// Shared fs call counters for mock.module("node:fs")-based tests +// (getMessageDir caching + B2 readFileSync regression). +// Declared here so both describe blocks can reference the same counters. +const mockCalls: { existsSync: number; readdirSync: number } = { existsSync: 0, readdirSync: 0 } const TASK_TTL_MS = 30 * 60 * 1000 @@ -3908,47 +3912,62 @@ describe("BackgroundManager regression fixes - resume and aborted notification", // ----- B4 regression: getMessageDir must cache results across calls ----- // -// The pre-fix getMessageDir did 3 + N sync IO ops per call (existsSync + readdirSync -// + N × existsSync for subdir scan). With many sessions, the readdirSync + N -// existsSync scan is expensive. B4 wraps the original body in a module-level -// LRU cache so the 2nd call for the same sessionID makes 0 sync IO ops. -// -// Uses spyOn (NOT mock.module) on `fs.existsSync` and `fs.readdirSync`. This -// works in bun because destructured `import { existsSync, readdirSync }` from -// "node:fs" goes through a live property lookup at call time, so spyOn on -// the namespace intercepts the call. This is bun-specific CJS interop behavior -// also exploited in src/shared/git-worktree/collect-git-diff-stats.test.ts. -// -// MUST run BEFORE the B2 describe block below: B2 uses mock.module("node:fs", ...) -// which is a global, process-wide mock that persists for the rest of the run. The -// spy-based assertions in this test would see 0 readdirSync calls (the mock wrapper -// is replaced by the spy in a way that confuses the live-binding chain). - +// Behavioral test: after the first call caches null, creating the session dir +// on disk should NOT change the cached result on the second call. This avoids +// mock.module("node:fs") which has inconsistent behavior across Bun versions. +// Instead, we mock opencode-storage-paths (same proven pattern as B2 below). describe("getMessageDir cached after first call", () => { - test("getMessageDir cached after first call", () => { + const testStorage = join(tmpdir(), `msgdir-b4-cache-${randomUUID()}`) + const testMessageStorage = join(testStorage, "message") + const sessionID = `ses_b4_${randomUUID()}` + const sessionDir = join(testMessageStorage, sessionID) + + beforeEach(() => { + mkdirSync(testMessageStorage, { recursive: true }) + }) + + afterEach(() => { + mock.restore() + try { + rmSync(testStorage, { recursive: true, force: true }) + } catch { + // best-effort cleanup + } + }) + + test("getMessageDir cached after first call", async () => { //#given - _resetMessageDirCacheForTesting() - const sessionID = `ses_b4_nonexistent_${randomUUID()}` - const existsSpy = spyOn(fs, "existsSync") - const readdirSpy = spyOn(fs, "readdirSync") + // Mock storage paths to point to our tmpdir (same pattern as B2) + mock.module("../../../src/shared/opencode-storage-paths", () => ({ + OPENCODE_STORAGE: testStorage, + MESSAGE_STORAGE: testMessageStorage, + PART_STORAGE: join(testStorage, "part"), + SESSION_STORAGE: join(testStorage, "session"), + })) - //#when - const result1 = getMessageDir(sessionID) + // Dynamic import with cache buster gets mocked storage paths + const { getMessageDir: isolatedGetMessageDir } = await import( + `../../../src/features/background-agent/message-dir?bust=${randomUUID()}` + ) + + // Session dir does NOT exist yet + expect(fs.existsSync(sessionDir)).toBe(false) + + //#when — first call: session not found, caches null + const result1 = isolatedGetMessageDir(sessionID) //#then expect(result1).toBeNull() - const existsAfter1 = existsSpy.mock.calls.length - const readdirAfter1 = readdirSpy.mock.calls.length - expect(existsAfter1).toBeGreaterThan(0) - expect(readdirAfter1).toBeGreaterThan(0) - //#when - const result2 = getMessageDir(sessionID) + // Now create the session dir (simulates session appearing after first call) + mkdirSync(sessionDir, { recursive: true }) - //#then + //#when — second call: should return cached null, NOT the new dir + const result2 = isolatedGetMessageDir(sessionID) + + //#then — if cache works, result2 is still null (cached from first call) + // if cache is broken, result2 would be sessionDir expect(result2).toBeNull() - expect(existsSpy.mock.calls.length).toBe(existsAfter1) - expect(readdirSpy.mock.calls.length).toBe(readdirAfter1) }) }) @@ -3980,6 +3999,7 @@ describe("findNearestMessageExcludingCompaction reads each file once", () => { }) afterEach(() => { + mock.restore() try { rmSync(B2_TEST_STORAGE, { recursive: true, force: true }) } catch { diff --git a/tests/features/builtin-commands/templates/dcp-profile.test.ts b/tests/features/builtin-commands/templates/dcp-profile.test.ts index c2584fb5953..cb2518be90a 100644 --- a/tests/features/builtin-commands/templates/dcp-profile.test.ts +++ b/tests/features/builtin-commands/templates/dcp-profile.test.ts @@ -35,14 +35,14 @@ describe("dcp-profile template", () => { expect(DCP_PROFILE_TEMPLATE).toContain("ultimate") }) - test("should reference the DCP install location and switch script", () => { + test("should reference the DCP install location and built-in tool", () => { //#given - the dcp-profile template //#when - we look for the install path and script path //#then - both should be present expect(DCP_PROFILE_TEMPLATE).toContain("@tarquinen/opencode-dcp") - expect(DCP_PROFILE_TEMPLATE).toContain("switch-profile.sh") + expect(DCP_PROFILE_TEMPLATE).toContain("dcp_switch_profile") }) test("should not contain emojis", () => { diff --git a/tests/features/mcp-oauth/callback-server.test.ts b/tests/features/mcp-oauth/callback-server.test.ts deleted file mode 100644 index e4ca555bede..00000000000 --- a/tests/features/mcp-oauth/callback-server.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test" -import { type CallbackServer, findAvailablePort, startCallbackServer } from "../../../src/features/mcp-oauth/callback-server" - -const nativeFetch = Bun.fetch.bind(Bun) - -describe("findAvailablePort", () => { - it("returns the start port when it is available", async () => { - // given - const startPort = 19877 - - // when - const port = await findAvailablePort(startPort) - - // then - expect(port).toBeGreaterThanOrEqual(startPort) - expect(port).toBeLessThan(startPort + 20) - }) - - it("skips busy ports and returns next available", async () => { - // given - const blocker = Bun.serve({ - port: 19877, - hostname: "127.0.0.1", - fetch: () => new Response(), - }) - - // when - const port = await findAvailablePort(19877) - - // then - expect(port).toBeGreaterThan(19877) - blocker.stop(true) - }) -}) - -describe("startCallbackServer", () => { - let server: CallbackServer | null = null - - afterEach(async () => { - server?.close() - server = null - // Allow time for port to be released before next test - await Bun.sleep(10) - }) - - it("starts server and returns port", async () => { - // given - no preconditions - - // when - server = await startCallbackServer() - - // then - expect(server.port).toBeGreaterThanOrEqual(19877) - expect(typeof server.waitForCallback).toBe("function") - expect(typeof server.close).toBe("function") - }) - - it("resolves callback with code and state from query params", async () => { - // given - server = await startCallbackServer() - const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state` - - // when - // Use Promise.all to ensure fetch and waitForCallback run concurrently - // This prevents race condition where waitForCallback blocks before fetch starts - const [result, response] = await Promise.all([ - server.waitForCallback(), - nativeFetch(callbackUrl) - ]) - - // then - expect(result).toEqual({ code: "test-code", state: "test-state" }) - expect(response.status).toBe(200) - const html = await response.text() - expect(html).toContain("Authorization successful") - }) - - it("returns 404 for non-callback routes", async () => { - // given - server = await startCallbackServer() - - // when - const response = await nativeFetch(`http://127.0.0.1:${server.port}/other`) - - // then - expect(response.status).toBe(404) - }) - - it("returns 400 and rejects when code is missing", async () => { - // given - server = await startCallbackServer() - const callbackRejection = server.waitForCallback().catch((e: Error) => e) - - // when - const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`) - - // then - expect(response.status).toBe(400) - const error = await callbackRejection - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain("missing code or state") - }) - - it("returns 400 and rejects when state is missing", async () => { - // given - server = await startCallbackServer() - const callbackRejection = server.waitForCallback().catch((e: Error) => e) - - // when - const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`) - - // then - expect(response.status).toBe(400) - const error = await callbackRejection - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain("missing code or state") - }) - - it("close stops the server immediately", async () => { - // given - server = await startCallbackServer() - const port = server.port - - // when - server.close() - server = null - - // then - try { - await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`) - expect(true).toBe(false) - } catch (error) { - expect(error).toBeDefined() - } - }) -}) diff --git a/tests/features/mcp-oauth/dcr.test.ts b/tests/features/mcp-oauth/dcr.test.ts deleted file mode 100644 index 25ee756e1eb..00000000000 --- a/tests/features/mcp-oauth/dcr.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { - type ClientCredentials, - type ClientRegistrationStorage, - type DcrFetch, - getOrRegisterClient, -} from "../../../src/features/mcp-oauth/dcr" - -function createStorage(initial: ClientCredentials | null): - & ClientRegistrationStorage - & { getLastKey: () => string | null; getLastSet: () => ClientCredentials | null } { - let stored = initial - let lastKey: string | null = null - let lastSet: ClientCredentials | null = null - - return { - getClientRegistration: () => stored, - setClientRegistration: (serverIdentifier: string, credentials: ClientCredentials) => { - lastKey = serverIdentifier - lastSet = credentials - stored = credentials - }, - getLastKey: () => lastKey, - getLastSet: () => lastSet, - } -} - -describe("getOrRegisterClient", () => { - it("returns cached registration when available", async () => { - // given - const storage = createStorage({ - clientId: "cached-client", - clientSecret: "cached-secret", - }) - const fetchMock: DcrFetch = async () => { - throw new Error("fetch should not be called") - } - - // when - const result = await getOrRegisterClient({ - registrationEndpoint: "https://server.example.com/register", - serverIdentifier: "server-1", - clientName: "Test Client", - redirectUris: ["https://app.example.com/callback"], - tokenEndpointAuthMethod: "client_secret_post", - storage, - fetch: fetchMock, - }) - - // then - expect(result).toEqual({ - clientId: "cached-client", - clientSecret: "cached-secret", - }) - }) - - it("registers client and stores credentials when endpoint available", async () => { - // given - const storage = createStorage(null) - let fetchCalled = false - const fetchMock: DcrFetch = async ( - input: string, - init?: { method?: string; headers?: Record; body?: string } - ) => { - fetchCalled = true - expect(input).toBe("https://server.example.com/register") - if (typeof init?.body !== "string") { - throw new Error("Expected request body string") - } - const payload = JSON.parse(init.body) - expect(payload).toEqual({ - redirect_uris: ["https://app.example.com/callback"], - client_name: "Test Client", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "client_secret_post", - }) - - return { - ok: true, - json: async () => ({ - client_id: "registered-client", - client_secret: "registered-secret", - }), - } - } - - // when - const result = await getOrRegisterClient({ - registrationEndpoint: "https://server.example.com/register", - serverIdentifier: "server-2", - clientName: "Test Client", - redirectUris: ["https://app.example.com/callback"], - tokenEndpointAuthMethod: "client_secret_post", - storage, - fetch: fetchMock, - }) - - // then - expect(fetchCalled).toBe(true) - expect(result).toEqual({ - clientId: "registered-client", - clientSecret: "registered-secret", - }) - expect(storage.getLastKey()).toBe("server-2") - expect(storage.getLastSet()).toEqual({ - clientId: "registered-client", - clientSecret: "registered-secret", - }) - }) - - it("uses config client id when registration endpoint missing", async () => { - // given - const storage = createStorage(null) - let fetchCalled = false - const fetchMock: DcrFetch = async () => { - fetchCalled = true - return { - ok: false, - json: async () => ({}), - } - } - - // when - const result = await getOrRegisterClient({ - registrationEndpoint: undefined, - serverIdentifier: "server-3", - clientName: "Test Client", - redirectUris: ["https://app.example.com/callback"], - tokenEndpointAuthMethod: "client_secret_post", - clientId: "config-client", - storage, - fetch: fetchMock, - }) - - // then - expect(fetchCalled).toBe(false) - expect(result).toEqual({ clientId: "config-client" }) - }) - - it("falls back to config client id when registration fails", async () => { - // given - const storage = createStorage(null) - const fetchMock: DcrFetch = async () => { - throw new Error("network error") - } - - // when - const result = await getOrRegisterClient({ - registrationEndpoint: "https://server.example.com/register", - serverIdentifier: "server-4", - clientName: "Test Client", - redirectUris: ["https://app.example.com/callback"], - tokenEndpointAuthMethod: "client_secret_post", - clientId: "fallback-client", - storage, - fetch: fetchMock, - }) - - // then - expect(result).toEqual({ clientId: "fallback-client" }) - expect(storage.getLastSet()).toBeNull() - }) -}) diff --git a/tests/features/mcp-oauth/discovery.test.ts b/tests/features/mcp-oauth/discovery.test.ts deleted file mode 100644 index 1dfb05159d8..00000000000 --- a/tests/features/mcp-oauth/discovery.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { discoverOAuthServerMetadata, resetDiscoveryCache } from "../../../src/features/mcp-oauth/discovery" - -describe("discoverOAuthServerMetadata", () => { - const originalFetch = globalThis.fetch - - beforeEach(() => { - resetDiscoveryCache() - }) - - afterEach(() => { - Object.defineProperty(globalThis, "fetch", { value: originalFetch, configurable: true }) - }) - - test("returns endpoints from PRM + AS discovery", () => { - // given - const resource = "https://mcp.example.com" - const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString() - const authServer = "https://auth.example.com" - const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString() - const calls: string[] = [] - const fetchMock = async (input: string | URL) => { - const url = typeof input === "string" ? input : input.toString() - calls.push(url) - if (url === prmUrl) { - return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 }) - } - if (url === asUrl) { - return new Response( - JSON.stringify({ - authorization_endpoint: "https://auth.example.com/authorize", - token_endpoint: "https://auth.example.com/token", - registration_endpoint: "https://auth.example.com/register", - }), - { status: 200 } - ) - } - return new Response("not found", { status: 404 }) - } - Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true }) - - // when - return discoverOAuthServerMetadata(resource).then((result) => { - // then - expect(result).toEqual({ - authorizationEndpoint: "https://auth.example.com/authorize", - tokenEndpoint: "https://auth.example.com/token", - registrationEndpoint: "https://auth.example.com/register", - resource, - }) - expect(calls).toEqual([prmUrl, asUrl]) - }) - }) - - test("falls back to RFC 8414 when PRM returns 404", () => { - // given - const resource = "https://mcp.example.com" - const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString() - const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString() - const calls: string[] = [] - const fetchMock = async (input: string | URL) => { - const url = typeof input === "string" ? input : input.toString() - calls.push(url) - if (url === prmUrl) { - return new Response("not found", { status: 404 }) - } - if (url === asUrl) { - return new Response( - JSON.stringify({ - authorization_endpoint: "https://mcp.example.com/authorize", - token_endpoint: "https://mcp.example.com/token", - }), - { status: 200 } - ) - } - return new Response("not found", { status: 404 }) - } - Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true }) - - // when - return discoverOAuthServerMetadata(resource).then((result) => { - // then - expect(result).toEqual({ - authorizationEndpoint: "https://mcp.example.com/authorize", - tokenEndpoint: "https://mcp.example.com/token", - registrationEndpoint: undefined, - resource, - }) - expect(calls).toEqual([prmUrl, asUrl]) - }) - }) - - test("throws when both PRM and AS discovery return 404", () => { - // given - const resource = "https://mcp.example.com" - const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString() - const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString() - const fetchMock = async (input: string | URL) => { - const url = typeof input === "string" ? input : input.toString() - if (url === prmUrl || url === asUrl) { - return new Response("not found", { status: 404 }) - } - return new Response("not found", { status: 404 }) - } - Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true }) - - // when - const result = discoverOAuthServerMetadata(resource) - - // then - return expect(result).rejects.toThrow("OAuth authorization server metadata not found") - }) - - test("throws when AS metadata is malformed", () => { - // given - const resource = "https://mcp.example.com" - const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString() - const authServer = "https://auth.example.com" - const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString() - const fetchMock = async (input: string | URL) => { - const url = typeof input === "string" ? input : input.toString() - if (url === prmUrl) { - return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 }) - } - if (url === asUrl) { - return new Response(JSON.stringify({ authorization_endpoint: "https://auth.example.com/authorize" }), { - status: 200, - }) - } - return new Response("not found", { status: 404 }) - } - Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true }) - - // when - const result = discoverOAuthServerMetadata(resource) - - // then - return expect(result).rejects.toThrow("token_endpoint") - }) - - test("caches discovery results per resource URL", () => { - // given - const resource = "https://mcp.example.com" - const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString() - const authServer = "https://auth.example.com" - const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString() - const calls: string[] = [] - const fetchMock = async (input: string | URL) => { - const url = typeof input === "string" ? input : input.toString() - calls.push(url) - if (url === prmUrl) { - return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 }) - } - if (url === asUrl) { - return new Response( - JSON.stringify({ - authorization_endpoint: "https://auth.example.com/authorize", - token_endpoint: "https://auth.example.com/token", - }), - { status: 200 } - ) - } - return new Response("not found", { status: 404 }) - } - Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true }) - - // when - return discoverOAuthServerMetadata(resource) - .then(() => discoverOAuthServerMetadata(resource)) - .then(() => { - // then - expect(calls).toEqual([prmUrl, asUrl]) - }) - }) -}) diff --git a/tests/features/mcp-oauth/provider.test.ts b/tests/features/mcp-oauth/provider.test.ts deleted file mode 100644 index a59b83c7e92..00000000000 --- a/tests/features/mcp-oauth/provider.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" -import { createHash } from "node:crypto" -import type { OAuthTokenData } from "../../../src/features/mcp-oauth/storage" - -const realProvider = await import("../../../src/features/mcp-oauth/provider?real=1" as string) -mock.module("../../../src/features/mcp-oauth/provider", () => realProvider) - -const { McpOAuthProvider, generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl } = realProvider - -describe("McpOAuthProvider", () => { - describe("generateCodeVerifier", () => { - it("returns a base64url-encoded 32-byte random string", () => { - // given - const verifier = generateCodeVerifier() - - // when - const decoded = Buffer.from(verifier, "base64url") - - // then - expect(decoded.length).toBe(32) - expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/) - }) - - it("produces unique values on each call", () => { - // given - const first = generateCodeVerifier() - - // when - const second = generateCodeVerifier() - - // then - expect(first).not.toBe(second) - }) - }) - - describe("generateCodeChallenge", () => { - it("returns SHA256 base64url digest of the verifier", () => { - // given - const verifier = "test-verifier-value" - const expected = createHash("sha256").update(verifier).digest("base64url") - - // when - const challenge = generateCodeChallenge(verifier) - - // then - expect(challenge).toBe(expected) - }) - }) - - describe("buildAuthorizationUrl", () => { - it("builds URL with all required PKCE parameters", () => { - // given - const endpoint = "https://auth.example.com/authorize" - - // when - const url = buildAuthorizationUrl(endpoint, { - clientId: "my-client", - redirectUri: "http://127.0.0.1:8912/callback", - codeChallenge: "challenge-value", - state: "state-value", - scopes: ["openid", "profile"], - resource: "https://mcp.example.com", - }) - - // then - const parsed = new URL(url) - expect(parsed.origin + parsed.pathname).toBe("https://auth.example.com/authorize") - expect(parsed.searchParams.get("response_type")).toBe("code") - expect(parsed.searchParams.get("client_id")).toBe("my-client") - expect(parsed.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8912/callback") - expect(parsed.searchParams.get("code_challenge")).toBe("challenge-value") - expect(parsed.searchParams.get("code_challenge_method")).toBe("S256") - expect(parsed.searchParams.get("state")).toBe("state-value") - expect(parsed.searchParams.get("scope")).toBe("openid profile") - expect(parsed.searchParams.get("resource")).toBe("https://mcp.example.com") - }) - - it("omits scope when empty", () => { - // given - const endpoint = "https://auth.example.com/authorize" - - // when - const url = buildAuthorizationUrl(endpoint, { - clientId: "my-client", - redirectUri: "http://127.0.0.1:8912/callback", - codeChallenge: "challenge-value", - state: "state-value", - scopes: [], - }) - - // then - const parsed = new URL(url) - expect(parsed.searchParams.has("scope")).toBe(false) - }) - - it("omits resource when undefined", () => { - // given - const endpoint = "https://auth.example.com/authorize" - - // when - const url = buildAuthorizationUrl(endpoint, { - clientId: "my-client", - redirectUri: "http://127.0.0.1:8912/callback", - codeChallenge: "challenge-value", - state: "state-value", - }) - - // then - const parsed = new URL(url) - expect(parsed.searchParams.has("resource")).toBe(false) - }) - }) - - describe("constructor and basic methods", () => { - it("stores serverUrl and optional clientId and scopes", () => { - // given - const options = { - serverUrl: "https://mcp.example.com", - clientId: "my-client", - scopes: ["openid"], - } - - // when - const provider = new McpOAuthProvider(options) - - // then - expect(provider.tokens()).toBeNull() - expect(provider.clientInformation()).toBeNull() - expect(provider.codeVerifier()).toBeNull() - }) - - it("defaults scopes to empty array", () => { - // given - const options = { serverUrl: "https://mcp.example.com" } - - // when - const provider = new McpOAuthProvider(options) - - // then - expect(provider.redirectUrl()).toBe("http://127.0.0.1:19877/callback") - }) - }) - - describe("saveCodeVerifier / codeVerifier", () => { - it("stores and retrieves code verifier", () => { - // given - const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" }) - - // when - provider.saveCodeVerifier("my-verifier") - - // then - expect(provider.codeVerifier()).toBe("my-verifier") - }) - }) - - describe("saveTokens / tokens", () => { - let originalEnv: string | undefined - - beforeEach(() => { - originalEnv = process.env.OPENCODE_CONFIG_DIR - const { mkdirSync } = require("node:fs") - const { tmpdir } = require("node:os") - const { join } = require("node:path") - const testDir = join(tmpdir(), `mcp-oauth-provider-test-${Date.now()}`) - mkdirSync(testDir, { recursive: true }) - process.env.OPENCODE_CONFIG_DIR = testDir - }) - - afterEach(() => { - if (originalEnv === undefined) { - delete process.env.OPENCODE_CONFIG_DIR - } else { - process.env.OPENCODE_CONFIG_DIR = originalEnv - } - }) - - it("persists and loads token data via storage", () => { - // given - const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" }) - const tokenData: OAuthTokenData = { - accessToken: "access-token-123", - refreshToken: "refresh-token-456", - expiresAt: 1710000000, - } - - // when - const saved = provider.saveTokens(tokenData) - const loaded = provider.tokens() - - // then - expect(saved).toBe(true) - expect(loaded).toEqual(tokenData) - }) - }) - - describe("redirectToAuthorization", () => { - it("throws when no client information is set", async () => { - // given - const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" }) - const metadata = { - authorizationEndpoint: "https://auth.example.com/authorize", - tokenEndpoint: "https://auth.example.com/token", - resource: "https://mcp.example.com", - } - - // when - const result = provider.redirectToAuthorization(metadata) - - // then - await expect(result).rejects.toThrow("No client information available") - }) - }) - - describe("redirectUrl", () => { - it("returns localhost callback URL with default port", () => { - // given - const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" }) - - // when - const url = provider.redirectUrl() - - // then - expect(url).toBe("http://127.0.0.1:19877/callback") - }) - }) -}) diff --git a/tests/features/mcp-oauth/step-up.test.ts b/tests/features/mcp-oauth/step-up.test.ts deleted file mode 100644 index 5ebfa0cc10b..00000000000 --- a/tests/features/mcp-oauth/step-up.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { isStepUpRequired, mergeScopes, parseWwwAuthenticate } from "../../../src/features/mcp-oauth/step-up" - -describe("parseWwwAuthenticate", () => { - it("parses scope from simple Bearer header", () => { - // given - const header = 'Bearer scope="read write"' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toEqual({ requiredScopes: ["read", "write"] }) - }) - - it("parses scope with error fields", () => { - // given - const header = 'Bearer error="insufficient_scope", scope="admin"' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toEqual({ - requiredScopes: ["admin"], - error: "insufficient_scope", - }) - }) - - it("parses all fields including error_description", () => { - // given - const header = - 'Bearer realm="example", error="insufficient_scope", error_description="Need admin access", scope="admin write"' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toEqual({ - requiredScopes: ["admin", "write"], - error: "insufficient_scope", - errorDescription: "Need admin access", - }) - }) - - it("returns null for non-Bearer scheme", () => { - // given - const header = 'Basic realm="example"' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toBeNull() - }) - - it("returns null when no scope parameter present", () => { - // given - const header = 'Bearer error="invalid_token"' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toBeNull() - }) - - it("returns null for empty scope value", () => { - // given - const header = 'Bearer scope=""' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toBeNull() - }) - - it("returns null for bare Bearer with no params", () => { - // given - const header = "Bearer" - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toBeNull() - }) - - it("handles case-insensitive Bearer prefix", () => { - // given - const header = 'bearer scope="read"' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toEqual({ requiredScopes: ["read"] }) - }) - - it("parses single scope value", () => { - // given - const header = 'Bearer scope="admin"' - - // when - const result = parseWwwAuthenticate(header) - - // then - expect(result).toEqual({ requiredScopes: ["admin"] }) - }) -}) - -describe("mergeScopes", () => { - it("merges new scopes into existing", () => { - // given - const existing = ["read", "write"] - const required = ["admin", "write"] - - // when - const result = mergeScopes(existing, required) - - // then - expect(result).toEqual(["read", "write", "admin"]) - }) - - it("returns required when existing is empty", () => { - // given - const existing: string[] = [] - const required = ["read", "write"] - - // when - const result = mergeScopes(existing, required) - - // then - expect(result).toEqual(["read", "write"]) - }) - - it("returns existing when required is empty", () => { - // given - const existing = ["read"] - const required: string[] = [] - - // when - const result = mergeScopes(existing, required) - - // then - expect(result).toEqual(["read"]) - }) - - it("deduplicates identical scopes", () => { - // given - const existing = ["read", "write"] - const required = ["read", "write"] - - // when - const result = mergeScopes(existing, required) - - // then - expect(result).toEqual(["read", "write"]) - }) -}) - -describe("isStepUpRequired", () => { - it("returns step-up info for 403 with WWW-Authenticate", () => { - // given - const statusCode = 403 - const headers = { "www-authenticate": 'Bearer scope="admin"' } - - // when - const result = isStepUpRequired(statusCode, headers) - - // then - expect(result).toEqual({ requiredScopes: ["admin"] }) - }) - - it("returns null for non-403 status", () => { - // given - const statusCode = 401 - const headers = { "www-authenticate": 'Bearer scope="admin"' } - - // when - const result = isStepUpRequired(statusCode, headers) - - // then - expect(result).toBeNull() - }) - - it("returns null when no WWW-Authenticate header", () => { - // given - const statusCode = 403 - const headers = { "content-type": "application/json" } - - // when - const result = isStepUpRequired(statusCode, headers) - - // then - expect(result).toBeNull() - }) - - it("handles capitalized WWW-Authenticate header", () => { - // given - const statusCode = 403 - const headers = { "WWW-Authenticate": 'Bearer scope="read write"' } - - // when - const result = isStepUpRequired(statusCode, headers) - - // then - expect(result).toEqual({ requiredScopes: ["read", "write"] }) - }) - - it("returns null for 403 with unparseable WWW-Authenticate", () => { - // given - const statusCode = 403 - const headers = { "www-authenticate": 'Basic realm="example"' } - - // when - const result = isStepUpRequired(statusCode, headers) - - // then - expect(result).toBeNull() - }) -}) diff --git a/tests/features/mcp-oauth/storage.test.ts b/tests/features/mcp-oauth/storage.test.ts deleted file mode 100644 index fad8acdd828..00000000000 --- a/tests/features/mcp-oauth/storage.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import type { OAuthTokenData } from "../../../src/features/mcp-oauth/storage" -import { - deleteToken, - getMcpOauthStoragePath, - listTokensByHost, - loadToken, - saveToken, -} from "../../../src/features/mcp-oauth/storage" - -describe("mcp-oauth storage", () => { - const TEST_CONFIG_DIR = join(tmpdir(), `mcp-oauth-test-${Date.now()}`) - let originalConfigDir: string | undefined - - beforeEach(() => { - originalConfigDir = process.env.OPENCODE_CONFIG_DIR - process.env.OPENCODE_CONFIG_DIR = TEST_CONFIG_DIR - if (!existsSync(TEST_CONFIG_DIR)) { - mkdirSync(TEST_CONFIG_DIR, { recursive: true }) - } - }) - - afterEach(() => { - if (originalConfigDir === undefined) { - delete process.env.OPENCODE_CONFIG_DIR - } else { - process.env.OPENCODE_CONFIG_DIR = originalConfigDir - } - if (existsSync(TEST_CONFIG_DIR)) { - rmSync(TEST_CONFIG_DIR, { recursive: true, force: true }) - } - }) - - test("should save tokens with {host}/{resource} key and set 0600 permissions", () => { - // given - const token: OAuthTokenData = { - accessToken: "access-1", - refreshToken: "refresh-1", - expiresAt: 1710000000, - clientInfo: { clientId: "client-1", clientSecret: "secret-1" }, - } - - // when - const success = saveToken("https://example.com:443", "mcp/v1", token) - const storagePath = getMcpOauthStoragePath() - const parsed = JSON.parse(readFileSync(storagePath, "utf-8")) as Record - const mode = statSync(storagePath).mode & 0o777 - - // then - expect(success).toBe(true) - expect(Object.keys(parsed)).toEqual(["example.com/mcp/v1"]) - expect(parsed["example.com/mcp/v1"].accessToken).toBe("access-1") - expect(mode).toBe(0o600) - }) - - test("should load a saved token", () => { - // given - const token: OAuthTokenData = { accessToken: "access-2", refreshToken: "refresh-2" } - saveToken("api.example.com", "resource-a", token) - - // when - const loaded = loadToken("api.example.com:8443", "resource-a") - - // then - expect(loaded).toEqual(token) - }) - - test("should delete a token", () => { - // given - const token: OAuthTokenData = { accessToken: "access-3" } - saveToken("api.example.com", "resource-b", token) - - // when - const success = deleteToken("api.example.com", "resource-b") - const loaded = loadToken("api.example.com", "resource-b") - - // then - expect(success).toBe(true) - expect(loaded).toBeNull() - }) - - test("should list tokens by host", () => { - // given - saveToken("api.example.com", "resource-a", { accessToken: "access-a" }) - saveToken("api.example.com", "resource-b", { accessToken: "access-b" }) - saveToken("other.example.com", "resource-c", { accessToken: "access-c" }) - - // when - const entries = listTokensByHost("api.example.com:5555") - - // then - expect(Object.keys(entries).sort()).toEqual([ - "api.example.com/resource-a", - "api.example.com/resource-b", - ]) - expect(entries["api.example.com/resource-a"].accessToken).toBe("access-a") - }) - - test("should handle missing storage file", () => { - // given - const storagePath = getMcpOauthStoragePath() - if (existsSync(storagePath)) { - rmSync(storagePath, { force: true }) - } - - // when - const loaded = loadToken("api.example.com", "resource-a") - const entries = listTokensByHost("api.example.com") - - // then - expect(loaded).toBeNull() - expect(entries).toEqual({}) - }) - - test("should handle invalid JSON", () => { - // given - const storagePath = getMcpOauthStoragePath() - const dir = join(storagePath, "..") - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - writeFileSync(storagePath, "{not-valid-json", "utf-8") - - // when - const loaded = loadToken("api.example.com", "resource-a") - const entries = listTokensByHost("api.example.com") - - // then - expect(loaded).toBeNull() - expect(entries).toEqual({}) - }) -}) diff --git a/tests/features/opencode-skill-loader/agents-skills-global.test.ts b/tests/features/opencode-skill-loader/agents-skills-global.test.ts deleted file mode 100644 index 2bfb3b61314..00000000000 --- a/tests/features/opencode-skill-loader/agents-skills-global.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" -import { mkdirSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" - -const TEST_DIR = join(tmpdir(), `agents-global-skills-test-${Date.now()}`) -const TEMP_HOME = join(TEST_DIR, "home") - -describe("discoverGlobalAgentsSkills", () => { - beforeEach(() => { - mkdirSync(TEST_DIR, { recursive: true }) - mkdirSync(TEMP_HOME, { recursive: true }) - }) - - afterEach(() => { - mock.restore() - rmSync(TEST_DIR, { recursive: true, force: true }) - }) - - it("#given a skill in ~/.agents/skills/ #when discoverGlobalAgentsSkills is called #then it discovers the skill", async () => { - //#given - const skillContent = `--- -name: agent-global-skill -description: A skill from global .agents/skills directory ---- -Skill body. -` - const agentsGlobalSkillsDir = join(TEMP_HOME, ".agents", "skills") - const skillDir = join(agentsGlobalSkillsDir, "agent-global-skill") - mkdirSync(skillDir, { recursive: true }) - writeFileSync(join(skillDir, "SKILL.md"), skillContent) - - mock.module("os", () => ({ - homedir: () => TEMP_HOME, - tmpdir, - })) - - //#when - const { discoverGlobalAgentsSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const skills = await discoverGlobalAgentsSkills() - const skill = skills.find(s => s.name === "agent-global-skill") - - //#then - expect(skill).toBeDefined() - expect(skill?.scope).toBe("user") - expect(skill?.definition.description).toContain("A skill from global .agents/skills directory") - }) -}) diff --git a/tests/features/opencode-skill-loader/config-source-discovery.test.ts b/tests/features/opencode-skill-loader/config-source-discovery.test.ts deleted file mode 100644 index 4d838215486..00000000000 --- a/tests/features/opencode-skill-loader/config-source-discovery.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { mkdirSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { SkillsConfigSchema } from "../../../src/config/schema/skills" -import { discoverConfigSourceSkills, normalizePathForGlob } from "../../../src/features/opencode-skill-loader/config-source-discovery" - -const TEST_DIR = join(tmpdir(), `config-source-discovery-test-${Date.now()}`) - -function writeSkill(path: string, name: string, description: string): void { - mkdirSync(path, { recursive: true }) - writeFileSync( - join(path, "SKILL.md"), - `---\nname: ${name}\ndescription: ${description}\n---\nBody\n`, - ) -} - -describe("config source discovery", () => { - beforeEach(() => { - mkdirSync(TEST_DIR, { recursive: true }) - }) - - afterEach(() => { - rmSync(TEST_DIR, { recursive: true, force: true }) - }) - - it("loads skills from local sources path", async () => { - // given - const configDir = join(TEST_DIR, "config") - const sourceDir = join(configDir, "custom-skills") - writeSkill(join(sourceDir, "local-skill"), "local-skill", "Loaded from local source") - const config = SkillsConfigSchema.parse({ - sources: [{ path: "./custom-skills", recursive: true }], - }) - - // when - const skills = await discoverConfigSourceSkills({ - config, - configDir, - }) - - // then - const localSkill = skills.find((skill) => skill.name === "local-skill") - expect(localSkill).toBeDefined() - expect(localSkill?.scope).toBe("config") - expect(localSkill?.definition.description).toContain("Loaded from local source") - }) - - it("filters discovered skills using source glob", async () => { - // given - const configDir = join(TEST_DIR, "config") - const sourceDir = join(configDir, "custom-skills") - - writeSkill(join(sourceDir, "keep", "kept"), "kept-skill", "Should be kept") - writeSkill(join(sourceDir, "skip", "skipped"), "skipped-skill", "Should be skipped") - const config = SkillsConfigSchema.parse({ - sources: [{ path: "./custom-skills", recursive: true, glob: "keep/**" }], - }) - - // when - const skills = await discoverConfigSourceSkills({ - config, - configDir, - }) - - // then - const names = skills.map((skill) => skill.name) - expect(names).toContain("keep/kept-skill") - expect(names).not.toContain("skip/skipped-skill") - }) - - it("normalizes windows separators before glob matching", () => { - // given - const windowsPath = "keep\\nested\\SKILL.md" - - // when - const normalized = normalizePathForGlob(windowsPath) - - // then - expect(normalized).toBe("keep/nested/SKILL.md") - }) -}) diff --git a/tests/features/opencode-skill-loader/loader.test.ts b/tests/features/opencode-skill-loader/loader.test.ts deleted file mode 100644 index 8fa5b658aee..00000000000 --- a/tests/features/opencode-skill-loader/loader.test.ts +++ /dev/null @@ -1,621 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { mkdirSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" - -const TEST_DIR = join(tmpdir(), `skill-loader-test-${Date.now()}`) -const SKILLS_DIR = join(TEST_DIR, ".opencode", "skills") - -function createTestSkill(name: string, content: string, mcpJson?: object): string { - const skillDir = join(SKILLS_DIR, name) - mkdirSync(skillDir, { recursive: true }) - const skillPath = join(skillDir, "SKILL.md") - writeFileSync(skillPath, content) - if (mcpJson) { - writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2)) - } - return skillDir -} - -describe("skill loader MCP parsing", () => { - beforeEach(() => { - mkdirSync(TEST_DIR, { recursive: true }) - }) - - afterEach(() => { - rmSync(TEST_DIR, { recursive: true, force: true }) - }) - - describe("parseSkillMcpConfig", () => { - it("parses skill with nested MCP config", async () => { - // given - const skillContent = `--- -name: test-skill -description: A test skill with MCP -mcp: - sqlite: - command: uvx - args: - - mcp-server-sqlite - - --db-path - - ./data.db - memory: - command: npx - args: [-y, "@anthropic-ai/mcp-server-memory"] ---- -This is the skill body. -` - createTestSkill("test-mcp-skill", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "test-skill") - - // then - expect(skill).toBeDefined() - expect(skill?.mcpConfig).toBeDefined() - expect(skill?.mcpConfig?.sqlite).toBeDefined() - expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx") - expect(skill?.mcpConfig?.sqlite?.args).toEqual([ - "mcp-server-sqlite", - "--db-path", - "./data.db" - ]) - expect(skill?.mcpConfig?.memory).toBeDefined() - expect(skill?.mcpConfig?.memory?.command).toBe("npx") - } finally { - process.chdir(originalCwd) - } - }) - - it("returns undefined mcpConfig for skill without MCP", async () => { - // given - const skillContent = `--- -name: simple-skill -description: A simple skill without MCP ---- -This is a simple skill. -` - createTestSkill("simple-skill", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "simple-skill") - - // then - expect(skill).toBeDefined() - expect(skill?.mcpConfig).toBeUndefined() - } finally { - process.chdir(originalCwd) - } - }) - - it("preserves env var placeholders without expansion", async () => { - // given - const skillContent = `--- -name: env-skill -mcp: - api-server: - command: node - args: [server.js] - env: - API_KEY: "\${API_KEY}" - DB_PATH: "\${HOME}/data.db" ---- -Skill with env vars. -` - createTestSkill("env-skill", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "env-skill") - - // then - // biome-ignore lint/suspicious/noTemplateCurlyInString: test expects literal ${...} in env var assertions - expect(skill?.mcpConfig?.["api-server"]?.env?.API_KEY).toBe("${API_KEY}") - // biome-ignore lint/suspicious/noTemplateCurlyInString: test expects literal ${...} in env var assertions - expect(skill?.mcpConfig?.["api-server"]?.env?.DB_PATH).toBe("${HOME}/data.db") - } finally { - process.chdir(originalCwd) - } - }) - - it("handles malformed YAML gracefully", async () => { - // given - malformed YAML causes entire frontmatter to fail parsing - const skillContent = `--- -name: bad-yaml -mcp: [this is not valid yaml for mcp ---- -Skill body. -` - createTestSkill("bad-yaml-skill", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - // then - when YAML fails, skill uses directory name as fallback - const skill = skills.find(s => s.name === "bad-yaml-skill") - - expect(skill).toBeDefined() - expect(skill?.mcpConfig).toBeUndefined() - } finally { - process.chdir(originalCwd) - } - }) - }) - - describe("mcp.json file loading (AmpCode compat)", () => { - it("loads MCP config from mcp.json with mcpServers format", async () => { - // given - const skillContent = `--- -name: ampcode-skill -description: Skill with mcp.json ---- -Skill body. -` - const mcpJson = { - mcpServers: { - playwright: { - command: "npx", - args: ["@playwright/mcp@latest"] - } - } - } - createTestSkill("ampcode-skill", skillContent, mcpJson) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "ampcode-skill") - - // then - expect(skill).toBeDefined() - expect(skill?.mcpConfig).toBeDefined() - expect(skill?.mcpConfig?.playwright).toBeDefined() - expect(skill?.mcpConfig?.playwright?.command).toBe("npx") - expect(skill?.mcpConfig?.playwright?.args).toEqual(["@playwright/mcp@latest"]) - } finally { - process.chdir(originalCwd) - } - }) - - it("mcp.json takes priority over YAML frontmatter", async () => { - // given - const skillContent = `--- -name: priority-skill -mcp: - from-yaml: - command: yaml-cmd - args: [yaml-arg] ---- -Skill body. -` - const mcpJson = { - mcpServers: { - "from-json": { - command: "json-cmd", - args: ["json-arg"] - } - } - } - createTestSkill("priority-skill", skillContent, mcpJson) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "priority-skill") - - // then - mcp.json should take priority - expect(skill?.mcpConfig?.["from-json"]).toBeDefined() - expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined() - } finally { - process.chdir(originalCwd) - } - }) - - it("supports direct format without mcpServers wrapper", async () => { - // given - const skillContent = `--- -name: direct-format ---- -Skill body. -` - const mcpJson = { - sqlite: { - command: "uvx", - args: ["mcp-server-sqlite"] - } - } - createTestSkill("direct-format", skillContent, mcpJson) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "direct-format") - - // then - expect(skill?.mcpConfig?.sqlite).toBeDefined() - expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx") - } finally { - process.chdir(originalCwd) - } - }) - }) - - describe("allowed-tools parsing", () => { - it("parses space-separated allowed-tools string", async () => { - // given - const skillContent = `--- -name: space-separated-tools -description: Skill with space-separated allowed-tools -allowed-tools: Read Write Edit Bash ---- -Skill body. -` - createTestSkill("space-separated-tools", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "space-separated-tools") - - // then - expect(skill).toBeDefined() - expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"]) - } finally { - process.chdir(originalCwd) - } - }) - - it("parses YAML inline array allowed-tools", async () => { - // given - const skillContent = `--- -name: yaml-inline-array -description: Skill with YAML inline array allowed-tools -allowed-tools: [Read, Write, Edit, Bash] ---- -Skill body. -` - createTestSkill("yaml-inline-array", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "yaml-inline-array") - - // then - expect(skill).toBeDefined() - expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"]) - } finally { - process.chdir(originalCwd) - } - }) - - it("parses YAML multi-line array allowed-tools", async () => { - // given - const skillContent = `--- -name: yaml-multiline-array -description: Skill with YAML multi-line array allowed-tools -allowed-tools: - - Read - - Write - - Edit - - Bash ---- -Skill body. -` - createTestSkill("yaml-multiline-array", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "yaml-multiline-array") - - // then - expect(skill).toBeDefined() - expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"]) - } finally { - process.chdir(originalCwd) - } - }) - - it("returns undefined for skill without allowed-tools", async () => { - // given - const skillContent = `--- -name: no-allowed-tools -description: Skill without allowed-tools field ---- -Skill body. -` - createTestSkill("no-allowed-tools", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - const skill = skills.find(s => s.name === "no-allowed-tools") - - // then - expect(skill).toBeDefined() - expect(skill?.allowedTools).toBeUndefined() - } finally { - process.chdir(originalCwd) - } - }) - }) - - describe("deduplication", () => { - it("deduplicates skills by name across scopes, keeping higher priority (opencode-project > opencode > project)", async () => { - const originalCwd = process.cwd() - const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR - const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR - - // given: same skill name in multiple scopes - const opencodeProjectSkillsDir = join(TEST_DIR, ".opencode", "skills") - const opencodeConfigDir = join(TEST_DIR, "opencode-global") - const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills") - const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills") - - process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir - process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user") - - mkdirSync(join(opencodeProjectSkillsDir, "duplicate-skill"), { recursive: true }) - mkdirSync(join(opencodeGlobalSkillsDir, "duplicate-skill"), { recursive: true }) - mkdirSync(join(projectClaudeSkillsDir, "duplicate-skill"), { recursive: true }) - - writeFileSync( - join(opencodeProjectSkillsDir, "duplicate-skill", "SKILL.md"), - `--- -name: duplicate-skill -description: From opencode-project (highest priority) ---- -opencode-project body. -` - ) - - writeFileSync( - join(opencodeGlobalSkillsDir, "duplicate-skill", "SKILL.md"), - `--- -name: duplicate-skill -description: From opencode-global (middle priority) ---- -opencode-global body. -` - ) - - writeFileSync( - join(projectClaudeSkillsDir, "duplicate-skill", "SKILL.md"), - `--- -name: duplicate-skill -description: From claude project (lowest priority among these) ---- -claude project body. -` - ) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills() - const duplicates = skills.filter(s => s.name === "duplicate-skill") - - // then - expect(duplicates).toHaveLength(1) - expect(duplicates[0]?.scope).toBe("opencode-project") - expect(duplicates[0]?.definition.description).toContain("opencode-project") - } finally { - process.chdir(originalCwd) - if (originalOpenCodeConfigDir === undefined) { - delete process.env.OPENCODE_CONFIG_DIR - } else { - process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir - } - if (originalClaudeConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR - } else { - process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir - } - } - }) - - it("prioritizes OpenCode global skills over legacy Claude project skills", async () => { - const originalCwd = process.cwd() - const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR - const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR - - const opencodeConfigDir = join(TEST_DIR, "opencode-global") - const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills") - const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills") - - process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir - process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user") - - mkdirSync(join(opencodeGlobalSkillsDir, "global-over-project"), { recursive: true }) - mkdirSync(join(projectClaudeSkillsDir, "global-over-project"), { recursive: true }) - - writeFileSync( - join(opencodeGlobalSkillsDir, "global-over-project", "SKILL.md"), - `--- -name: global-over-project -description: From opencode-global (should win) ---- -opencode-global body. -` - ) - - writeFileSync( - join(projectClaudeSkillsDir, "global-over-project", "SKILL.md"), - `--- -name: global-over-project -description: From claude project (should lose) ---- -claude project body. -` - ) - - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills() - const matches = skills.filter(s => s.name === "global-over-project") - - expect(matches).toHaveLength(1) - expect(matches[0]?.scope).toBe("opencode") - expect(matches[0]?.definition.description).toContain("opencode-global") - } finally { - process.chdir(originalCwd) - if (originalOpenCodeConfigDir === undefined) { - delete process.env.OPENCODE_CONFIG_DIR - } else { - process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir - } - if (originalClaudeConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR - } else { - process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir - } - } - }) - - it("returns no duplicates from discoverSkills", async () => { - const originalCwd = process.cwd() - const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR - - process.env.OPENCODE_CONFIG_DIR = join(TEST_DIR, "opencode-global") - - // given - const skillContent = `--- -name: unique-test-skill -description: A unique skill for dedup test ---- -Skill body. -` - createTestSkill("unique-test-skill", skillContent) - - // when - const { discoverSkills } = await import("../../../src/features/opencode-skill-loader/loader") - process.chdir(TEST_DIR) - - try { - const skills = await discoverSkills({ includeClaudeCodePaths: false }) - - // then - const names = skills.map(s => s.name) - const uniqueNames = [...new Set(names)] - expect(names.length).toBe(uniqueNames.length) - } finally { - process.chdir(originalCwd) - if (originalOpenCodeConfigDir === undefined) { - delete process.env.OPENCODE_CONFIG_DIR - } else { - process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir - } - } - }) - }) - - describe("agents skills discovery (.agents/skills/)", () => { - it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called #then it discovers the skill", async () => { - //#given - const skillContent = `--- -name: agent-project-skill -description: A skill from project .agents/skills directory ---- -Skill body. -` - const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills") - const skillDir = join(agentsProjectSkillsDir, "agent-project-skill") - mkdirSync(skillDir, { recursive: true }) - writeFileSync(join(skillDir, "SKILL.md"), skillContent) - - //#when - const { discoverProjectAgentsSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const originalCwd = process.cwd() - process.chdir(TEST_DIR) - - try { - const skills = await discoverProjectAgentsSkills() - const skill = skills.find(s => s.name === "agent-project-skill") - - //#then - expect(skill).toBeDefined() - expect(skill?.scope).toBe("project") - expect(skill?.definition.description).toContain("A skill from project .agents/skills directory") - } finally { - process.chdir(originalCwd) - } - }) - - it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called with directory #then it discovers the skill", async () => { - //#given - const skillContent = `--- -name: agent-dir-skill -description: A skill via explicit directory param ---- -Skill body. -` - const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills") - const skillDir = join(agentsProjectSkillsDir, "agent-dir-skill") - mkdirSync(skillDir, { recursive: true }) - writeFileSync(join(skillDir, "SKILL.md"), skillContent) - - //#when - const { discoverProjectAgentsSkills } = await import("../../../src/features/opencode-skill-loader/loader") - const skills = await discoverProjectAgentsSkills(TEST_DIR) - const skill = skills.find(s => s.name === "agent-dir-skill") - - //#then - expect(skill).toBeDefined() - expect(skill?.scope).toBe("project") - }) - }) -}) diff --git a/tests/features/opencode-skill-loader/merger.test.ts b/tests/features/opencode-skill-loader/merger.test.ts deleted file mode 100644 index 1a4c02f0b71..00000000000 --- a/tests/features/opencode-skill-loader/merger.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "bun:test" -import type { BuiltinSkill } from "../../../src/features/builtin-skills/types" -import type { CommandDefinition } from "../../../src/features/command-loader/types" -import { mergeSkills } from "../../../src/features/opencode-skill-loader/merger" -import type { LoadedSkill, SkillScope } from "../../../src/features/opencode-skill-loader/types" - -function createLoadedSkill(scope: SkillScope, name: string, description: string): LoadedSkill { - const definition: CommandDefinition = { - name, - description, - template: "template", - } - - return { - name, - definition, - scope, - } -} - -describe("mergeSkills", () => { - it("gives higher scopes priority over config source skills", () => { - // given - const builtinSkills: BuiltinSkill[] = [ - { - name: "priority-skill", - description: "builtin", - template: "builtin-template", - }, - ] - - const configSourceSkills: LoadedSkill[] = [ - createLoadedSkill("config", "priority-skill", "config source"), - ] - const userSkills: LoadedSkill[] = [ - createLoadedSkill("user", "priority-skill", "user skill"), - ] - - // when - const merged = mergeSkills( - builtinSkills, - undefined, - configSourceSkills, - userSkills, - [], - [], - [], - ) - - // then - expect(merged).toHaveLength(1) - expect(merged[0]?.scope).toBe("user") - expect(merged[0]?.definition.description).toBe("user skill") - }) -}) diff --git a/tests/features/opencode-skill-loader/skill-content.test.ts b/tests/features/opencode-skill-loader/skill-content.test.ts deleted file mode 100644 index 9335e4fdd61..00000000000 --- a/tests/features/opencode-skill-loader/skill-content.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -/// - -import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { resolveMultipleSkills, resolveMultipleSkillsAsync, resolveSkillContent, resolveSkillContentAsync } from "../../../src/features/opencode-skill-loader/skill-content" - -let originalEnv: Record -let testConfigDir: string - -beforeEach(() => { - originalEnv = { - CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, - OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, - } - const unique = `skill-content-test-${Date.now()}-${Math.random().toString(16).slice(2)}` - testConfigDir = join(tmpdir(), unique) - process.env.CLAUDE_CONFIG_DIR = testConfigDir - process.env.OPENCODE_CONFIG_DIR = testConfigDir -}) - -afterEach(() => { - for (const [key, value] of Object.entries(originalEnv)) { - if (value !== undefined) { - process.env[key] = value - } else { - delete process.env[key] - } - } -}) - -describe("resolveSkillContent", () => { - it("should return template for existing skill", () => { - // given: builtin skills with 'frontend-ui-ux' skill - // when: resolving content for 'frontend-ui-ux' - const result = resolveSkillContent("frontend-ui-ux") - - // then: returns template string - expect(result).not.toBeNull() - expect(typeof result).toBe("string") - expect(result).toContain("Role: Designer-Turned-Developer") - }) - - it("should return template for 'playwright' skill", () => { - // given: builtin skills with 'playwright' skill - // when: resolving content for 'playwright' - const result = resolveSkillContent("playwright") - - // then: returns template string - expect(result).not.toBeNull() - expect(typeof result).toBe("string") - expect(result).toContain("Playwright Browser Automation") - }) - - it("should return null for non-existent skill", () => { - // given: builtin skills without 'nonexistent' skill - // when: resolving content for 'nonexistent' - const result = resolveSkillContent("nonexistent") - - // then: returns null - expect(result).toBeNull() - }) - - it("should return null for disabled skill", () => { - // given: frontend-ui-ux skill disabled - const options = { disabledSkills: new Set(["frontend-ui-ux"]) } - - // when: resolving content for disabled skill - const result = resolveSkillContent("frontend-ui-ux", options) - - // then: returns null - expect(result).toBeNull() - }) -}) - -describe("resolveMultipleSkills", () => { - it("should resolve all existing skills", () => { - // given: list of existing skill names - const skillNames = ["frontend-ui-ux", "playwright"] - - // when: resolving multiple skills - const result = resolveMultipleSkills(skillNames) - - // then: all skills resolved, none not found - expect(result.resolved.size).toBe(2) - expect(result.notFound).toEqual([]) - expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer") - expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") - }) - - it("should handle partial success - some skills not found", () => { - // given: list with existing and non-existing skills - const skillNames = ["frontend-ui-ux", "nonexistent", "playwright", "another-missing"] - - // when: resolving multiple skills - const result = resolveMultipleSkills(skillNames) - - // then: resolves existing skills, lists not found skills - expect(result.resolved.size).toBe(2) - expect(result.notFound).toEqual(["nonexistent", "another-missing"]) - expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer") - expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") - }) - - it("should handle empty array", () => { - // given: empty skill names list - const skillNames: string[] = [] - - // when: resolving multiple skills - const result = resolveMultipleSkills(skillNames) - - // then: returns empty resolved and notFound - expect(result.resolved.size).toBe(0) - expect(result.notFound).toEqual([]) - }) - - it("should handle all skills not found", () => { - // given: list of non-existing skills - const skillNames = ["skill-one", "skill-two", "skill-three"] - - // when: resolving multiple skills - const result = resolveMultipleSkills(skillNames) - - // then: no skills resolved, all in notFound - expect(result.resolved.size).toBe(0) - expect(result.notFound).toEqual(["skill-one", "skill-two", "skill-three"]) - }) - - it("should treat disabled skills as not found", () => { - // #given: frontend-ui-ux disabled, playwright not disabled - const skillNames = ["frontend-ui-ux", "playwright"] - const options = { disabledSkills: new Set(["frontend-ui-ux"]) } - - // #when: resolving multiple skills with disabled one - const result = resolveMultipleSkills(skillNames, options) - - // #then: frontend-ui-ux in notFound, playwright resolved - expect(result.resolved.size).toBe(1) - expect(result.resolved.has("playwright")).toBe(true) - expect(result.notFound).toEqual(["frontend-ui-ux"]) - }) - - it("should preserve skill order in resolved map", () => { - // given: list of skill names in specific order - const skillNames = ["playwright", "frontend-ui-ux"] - - // when: resolving multiple skills - const result = resolveMultipleSkills(skillNames) - - // then: map contains skills with expected keys - expect(result.resolved.has("playwright")).toBe(true) - expect(result.resolved.has("frontend-ui-ux")).toBe(true) - expect(result.resolved.size).toBe(2) - }) -}) - -describe("resolveSkillContentAsync", () => { - it("should return template for builtin skill async", async () => { - // given: builtin skill 'frontend-ui-ux' - // when: resolving content async - const options = { disabledSkills: new Set(["frontend-ui-ux"]) } - const result = await resolveSkillContentAsync("git-master", options) - - // then: returns template string - expect(result).not.toBeNull() - expect(typeof result).toBe("string") - expect(result).toContain("Git Master Agent") - }) - - it("should return null for disabled skill async", async () => { - // given: frontend-ui-ux disabled - const options = { disabledSkills: new Set(["frontend-ui-ux"]) } - - // when: resolving content async for disabled skill - const result = await resolveSkillContentAsync("frontend-ui-ux", options) - - // then: returns null - expect(result).toBeNull() - }) -}) - -describe("resolveMultipleSkillsAsync", () => { - it("should resolve builtin skills async", async () => { - // given: builtin skill names - const skillNames = ["playwright", "git-master"] - - // when: resolving multiple skills async - const result = await resolveMultipleSkillsAsync(skillNames) - - // then: all builtin skills resolved - expect(result.resolved.size).toBe(2) - expect(result.notFound).toEqual([]) - expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") - expect(result.resolved.get("git-master")).toContain("Git Master Agent") - }) - - it("should handle partial success with non-existent skills async", async () => { - // given: mix of existing and non-existing skills - const skillNames = ["playwright", "nonexistent-skill-12345"] - - // when: resolving multiple skills async - const result = await resolveMultipleSkillsAsync(skillNames) - - // then: existing skills resolved, non-existing in notFound - expect(result.resolved.size).toBe(1) - expect(result.notFound).toEqual(["nonexistent-skill-12345"]) - expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") - }) - - it("should treat disabled skills as not found async", async () => { - // #given: frontend-ui-ux disabled - const skillNames = ["frontend-ui-ux", "playwright"] - const options = { disabledSkills: new Set(["frontend-ui-ux"]) } - - // #when: resolving multiple skills async with disabled one - const result = await resolveMultipleSkillsAsync(skillNames, options) - - // #then: frontend-ui-ux in notFound, playwright resolved - expect(result.resolved.size).toBe(1) - expect(result.resolved.has("playwright")).toBe(true) - expect(result.notFound).toEqual(["frontend-ui-ux"]) - }) - - - - it("should handle empty array", async () => { - // given: empty skill names - const skillNames: string[] = [] - - // when: resolving multiple skills async - const result = await resolveMultipleSkillsAsync(skillNames) - - // then: empty results - expect(result.resolved.size).toBe(0) - expect(result.notFound).toEqual([]) - }) -}) - -describe("resolveSkillContent with browserProvider", () => { - it("should resolve agent-browser skill when browserProvider is 'agent-browser'", () => { - // given: browserProvider set to agent-browser - const options = { browserProvider: "agent-browser" as const } - - // when: resolving content for 'agent-browser' - const result = resolveSkillContent("agent-browser", options) - - // then: returns agent-browser template - expect(result).not.toBeNull() - expect(result).toContain("agent-browser") - }) - - it("should return null for agent-browser when browserProvider is default", () => { - // given: no browserProvider (defaults to playwright) - - // when: resolving content for 'agent-browser' - const result = resolveSkillContent("agent-browser") - - // then: returns null because agent-browser is not in default builtin skills - expect(result).toBeNull() - }) - - it("should return null for playwright when browserProvider is agent-browser", () => { - // given: browserProvider set to agent-browser - const options = { browserProvider: "agent-browser" as const } - - // when: resolving content for 'playwright' - const result = resolveSkillContent("playwright", options) - - // then: returns null because playwright is replaced by agent-browser - expect(result).toBeNull() - }) -}) - -describe("resolveMultipleSkills with browserProvider", () => { - it("should resolve agent-browser when browserProvider is set", () => { - // given: agent-browser and git-master requested with browserProvider - const skillNames = ["agent-browser", "git-master"] - const options = { browserProvider: "agent-browser" as const } - - // when: resolving multiple skills - const result = resolveMultipleSkills(skillNames, options) - - // then: both resolved - expect(result.resolved.has("agent-browser")).toBe(true) - expect(result.resolved.has("git-master")).toBe(true) - expect(result.notFound).toHaveLength(0) - }) - - it("should not resolve agent-browser without browserProvider option", () => { - // given: agent-browser requested without browserProvider - const skillNames = ["agent-browser"] - - // when: resolving multiple skills - const result = resolveMultipleSkills(skillNames) - - // then: agent-browser not found - expect(result.resolved.has("agent-browser")).toBe(false) - expect(result.notFound).toContain("agent-browser") - }) -}) - -describe("resolveMultipleSkillsAsync with browserProvider filtering", () => { - it("should exclude discovered agent-browser when browserProvider is playwright", async () => { - // given: playwright is the selected browserProvider (default) - const skillNames = ["playwright", "git-master"] - const options = { browserProvider: "playwright" as const } - - // when: resolving multiple skills - const result = await resolveMultipleSkillsAsync(skillNames, options) - - // then: playwright resolved, agent-browser would be excluded if discovered - expect(result.resolved.has("playwright")).toBe(true) - expect(result.resolved.has("git-master")).toBe(true) - expect(result.notFound).not.toContain("playwright") - }) - - it("should exclude discovered playwright when browserProvider is agent-browser", async () => { - // given: agent-browser is the selected browserProvider - const skillNames = ["agent-browser", "git-master"] - const options = { browserProvider: "agent-browser" as const } - - // when: resolving multiple skills - const result = await resolveMultipleSkillsAsync(skillNames, options) - - // then: agent-browser resolved, playwright would be excluded if discovered - expect(result.resolved.has("agent-browser")).toBe(true) - expect(result.resolved.has("git-master")).toBe(true) - expect(result.notFound).not.toContain("agent-browser") - }) -}) diff --git a/tests/features/skill-mcp-manager/env-cleaner.test.ts b/tests/features/skill-mcp-manager/env-cleaner.test.ts deleted file mode 100644 index baf3d8eeeb5..00000000000 --- a/tests/features/skill-mcp-manager/env-cleaner.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test" -import { createCleanMcpEnvironment, EXCLUDED_ENV_PATTERNS } from "../../../src/features/skill-mcp-manager/env-cleaner" - -describe("createCleanMcpEnvironment", () => { - // Store original env to restore after tests - const originalEnv = { ...process.env } - - afterEach(() => { - // Restore original environment - for (const key of Object.keys(process.env)) { - if (!(key in originalEnv)) { - delete process.env[key] - } - } - for (const [key, value] of Object.entries(originalEnv)) { - process.env[key] = value - } - }) - - describe("NPM_CONFIG_* filtering", () => { - it("filters out uppercase NPM_CONFIG_* variables", () => { - // given - process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com" - process.env.NPM_CONFIG_CACHE = "/some/cache/path" - process.env.NPM_CONFIG_PREFIX = "/some/prefix" - process.env.PATH = "/usr/bin" - - // when - const cleanEnv = createCleanMcpEnvironment() - - // then - expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined() - expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined() - expect(cleanEnv.NPM_CONFIG_PREFIX).toBeUndefined() - expect(cleanEnv.PATH).toBe("/usr/bin") - }) - - it("filters out lowercase npm_config_* variables", () => { - // given - process.env.npm_config_registry = "https://private.registry.com" - process.env.npm_config_cache = "/some/cache/path" - process.env.npm_config_https_proxy = "http://proxy:8080" - process.env.npm_config_proxy = "http://proxy:8080" - process.env.HOME = "/home/user" - - // when - const cleanEnv = createCleanMcpEnvironment() - - // then - expect(cleanEnv.npm_config_registry).toBeUndefined() - expect(cleanEnv.npm_config_cache).toBeUndefined() - expect(cleanEnv.npm_config_https_proxy).toBeUndefined() - expect(cleanEnv.npm_config_proxy).toBeUndefined() - expect(cleanEnv.HOME).toBe("/home/user") - }) - }) - - describe("YARN_* filtering", () => { - it("filters out YARN_* variables", () => { - // given - process.env.YARN_CACHE_FOLDER = "/yarn/cache" - process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = "true" - process.env.YARN_REGISTRY = "https://yarn.registry.com" - process.env.NODE_ENV = "production" - - // when - const cleanEnv = createCleanMcpEnvironment() - - // then - expect(cleanEnv.YARN_CACHE_FOLDER).toBeUndefined() - expect(cleanEnv.YARN_ENABLE_IMMUTABLE_INSTALLS).toBeUndefined() - expect(cleanEnv.YARN_REGISTRY).toBeUndefined() - expect(cleanEnv.NODE_ENV).toBe("production") - }) - }) - - describe("PNPM_* filtering", () => { - it("filters out PNPM_* variables", () => { - // given - process.env.PNPM_HOME = "/pnpm/home" - process.env.PNPM_STORE_DIR = "/pnpm/store" - process.env.USER = "testuser" - - // when - const cleanEnv = createCleanMcpEnvironment() - - // then - expect(cleanEnv.PNPM_HOME).toBeUndefined() - expect(cleanEnv.PNPM_STORE_DIR).toBeUndefined() - expect(cleanEnv.USER).toBe("testuser") - }) - }) - - describe("NO_UPDATE_NOTIFIER filtering", () => { - it("filters out NO_UPDATE_NOTIFIER variable", () => { - // given - process.env.NO_UPDATE_NOTIFIER = "1" - process.env.SHELL = "/bin/bash" - - // when - const cleanEnv = createCleanMcpEnvironment() - - // then - expect(cleanEnv.NO_UPDATE_NOTIFIER).toBeUndefined() - expect(cleanEnv.SHELL).toBe("/bin/bash") - }) - }) - - describe("custom environment overlay", () => { - it("merges custom env on top of clean process.env", () => { - // given - process.env.PATH = "/usr/bin" - process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com" - const customEnv = { - MCP_API_KEY: "secret-key", - CUSTOM_VAR: "custom-value", - } - - // when - const cleanEnv = createCleanMcpEnvironment(customEnv) - - // then - expect(cleanEnv.PATH).toBe("/usr/bin") - expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined() - expect(cleanEnv.MCP_API_KEY).toBe("secret-key") - expect(cleanEnv.CUSTOM_VAR).toBe("custom-value") - }) - - it("custom env can override process.env values", () => { - // given - process.env.NODE_ENV = "development" - const customEnv = { - NODE_ENV: "production", - } - - // when - const cleanEnv = createCleanMcpEnvironment(customEnv) - - // then - expect(cleanEnv.NODE_ENV).toBe("production") - }) - }) - - describe("undefined value handling", () => { - it("skips undefined values from process.env", () => { - // given - process.env can have undefined values in TypeScript - const envWithUndefined = { ...process.env, UNDEFINED_VAR: undefined } - Object.assign(process.env, envWithUndefined) - - // when - const cleanEnv = createCleanMcpEnvironment() - - // then - should not throw and should not include undefined values - expect(cleanEnv.UNDEFINED_VAR).toBeUndefined() - expect(Object.values(cleanEnv).every((v) => v !== undefined)).toBe(true) - }) - }) - - describe("mixed case handling", () => { - it("filters both uppercase and lowercase npm config variants", () => { - // given - pnpm/yarn can set both cases simultaneously - process.env.NPM_CONFIG_CACHE = "/uppercase/cache" - process.env.npm_config_cache = "/lowercase/cache" - process.env.NPM_CONFIG_REGISTRY = "https://uppercase.registry.com" - process.env.npm_config_registry = "https://lowercase.registry.com" - - // when - const cleanEnv = createCleanMcpEnvironment() - - // then - expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined() - expect(cleanEnv.npm_config_cache).toBeUndefined() - expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined() - expect(cleanEnv.npm_config_registry).toBeUndefined() - }) - }) -}) - -describe("EXCLUDED_ENV_PATTERNS", () => { - it("contains patterns for npm, yarn, and pnpm configs", () => { - // given / #when / #then - expect(EXCLUDED_ENV_PATTERNS.length).toBeGreaterThanOrEqual(4) - - // Test that patterns match expected strings - const testCases = [ - { pattern: "NPM_CONFIG_REGISTRY", shouldMatch: true }, - { pattern: "npm_config_registry", shouldMatch: true }, - { pattern: "YARN_CACHE_FOLDER", shouldMatch: true }, - { pattern: "PNPM_HOME", shouldMatch: true }, - { pattern: "NO_UPDATE_NOTIFIER", shouldMatch: true }, - { pattern: "PATH", shouldMatch: false }, - { pattern: "HOME", shouldMatch: false }, - { pattern: "NODE_ENV", shouldMatch: false }, - ] - - for (const { pattern, shouldMatch } of testCases) { - const matches = EXCLUDED_ENV_PATTERNS.some((regex: RegExp) => regex.test(pattern)) - expect(matches).toBe(shouldMatch) - } - }) -}) diff --git a/tests/features/skill-mcp-manager/manager.test.ts b/tests/features/skill-mcp-manager/manager.test.ts deleted file mode 100644 index eae554174da..00000000000 --- a/tests/features/skill-mcp-manager/manager.test.ts +++ /dev/null @@ -1,856 +0,0 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" -import { SkillMcpManager } from "../../../src/features/skill-mcp-manager/manager" -import type { McpServerDefinition, SkillMcpClientInfo, SkillMcpServerContext } from "../../../src/features/skill-mcp-manager/types" - -// Mock the MCP SDK transports to avoid network calls -const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure"))) -const mockHttpClose = mock(() => Promise.resolve()) -let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {} - -mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: class MockStreamableHTTPClientTransport { - constructor(public url: URL, public options?: { requestInit?: RequestInit }) { - lastTransportInstance = { url, options } - } - async start() { - await mockHttpConnect() - } - async close() { - await mockHttpClose() - } - }, -})) - -const mockTokens = mock(() => null as { accessToken: string; refreshToken?: string; expiresAt?: number } | null) -const mockLogin = mock(() => Promise.resolve({ accessToken: "new-token" })) - -const realMcpOAuthProvider = require("../../../src/features/mcp-oauth/provider") - -mock.module("../../../src/features/mcp-oauth/provider", () => ({ - McpOAuthProvider: class MockMcpOAuthProvider { - constructor(public options: { serverUrl: string; clientId?: string; scopes?: string[] }) {} - tokens() { - return mockTokens() - } - async login() { - return mockLogin() - } - }, -})) - -afterAll(() => { - mock.module("../../../src/features/mcp-oauth/provider", () => realMcpOAuthProvider) -}) - - - - - - - - - - - - - - -describe("SkillMcpManager", () => { - let manager: SkillMcpManager - - beforeEach(() => { - manager = new SkillMcpManager() - mockHttpConnect.mockClear() - mockHttpClose.mockClear() - }) - - afterEach(async () => { - await manager.disconnectAll() - }) - - describe("getOrCreateClient", () => { - describe("configuration validation", () => { - it("throws error when neither url nor command is provided", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "test-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = {} - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /no valid connection configuration/ - ) - }) - - it("includes both HTTP and stdio examples in error message", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "my-mcp", - skillName: "data-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = {} - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /HTTP[\s\S]*Stdio/ - ) - }) - - it("includes server and skill names in error message", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "custom-server", - skillName: "custom-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = {} - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /custom-server[\s\S]*custom-skill/ - ) - }) - }) - - describe("connection type detection", () => { - it("detects HTTP connection from explicit type='http'", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "http-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - type: "http", - url: "https://example.com/mcp", - } - - // when / #then - should fail at connection, not config validation - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Failed to connect/ - ) - }) - - it("detects HTTP connection from explicit type='sse'", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "sse-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - type: "sse", - url: "https://example.com/mcp", - } - - // when / #then - should fail at connection, not config validation - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Failed to connect/ - ) - }) - - it("detects HTTP connection from url field when type is not specified", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "inferred-http", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - url: "https://example.com/mcp", - } - - // when / #then - should fail at connection, not config validation - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Failed to connect[\s\S]*URL/ - ) - }) - - it("detects stdio connection from explicit type='stdio'", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "stdio-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - type: "stdio", - command: "node", - args: ["-e", "process.exit(0)"], - } - - // when / #then - should fail at connection, not config validation - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Failed to connect[\s\S]*Command/ - ) - }) - - it("detects stdio connection from command field when type is not specified", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "inferred-stdio", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - command: "node", - args: ["-e", "process.exit(0)"], - } - - // when / #then - should fail at connection, not config validation - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Failed to connect[\s\S]*Command/ - ) - }) - - it("prefers explicit type over inferred type", async () => { - // given - has both url and command, but type is explicitly stdio - const info: SkillMcpClientInfo = { - serverName: "mixed-config", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - type: "stdio", - url: "https://example.com/mcp", // should be ignored - command: "node", - args: ["-e", "process.exit(0)"], - } - - // when / #then - should use stdio (show Command in error, not URL) - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Command: node/ - ) - }) - }) - - describe("HTTP connection", () => { - it("throws error for invalid URL", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "bad-url-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - type: "http", - url: "not-a-valid-url", - } - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /invalid URL/ - ) - }) - - it("includes URL in HTTP connection error", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "http-error-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - url: "https://nonexistent.example.com/mcp", - } - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /https:\/\/nonexistent\.example\.com\/mcp/ - ) - }) - - it("includes helpful hints for HTTP connection failures", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "hint-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - url: "https://nonexistent.example.com/mcp", - } - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Hints[\s\S]*Verify the URL[\s\S]*authentication headers[\s\S]*MCP over HTTP/ - ) - }) - - it("calls mocked transport connect for HTTP connections", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "mock-test-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - url: "https://example.com/mcp", - } - - // when - try { - await manager.getOrCreateClient(info, config) - } catch { - // Expected to fail - } - - // then - verify mock was called (transport was instantiated) - // The connection attempt happens through the Client.connect() which - // internally calls transport.start() - expect(mockHttpConnect).toHaveBeenCalled() - }) - }) - - describe("stdio connection (backward compatibility)", () => { - it("throws error when command is missing for stdio type", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "missing-command", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - type: "stdio", - // command is missing - } - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /missing 'command' field/ - ) - }) - - it("includes command in stdio connection error", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "test-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - command: "nonexistent-command-xyz", - args: ["--foo"], - } - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /nonexistent-command-xyz --foo/ - ) - }) - - it("includes helpful hints for stdio connection failures", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "test-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - command: "nonexistent-command", - } - - // when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Hints[\s\S]*PATH[\s\S]*package exists/ - ) - }) - }) - }) - - describe("disconnectSession", () => { - it("removes all clients for a specific session", async () => { - // given - const session1Info: SkillMcpClientInfo = { - serverName: "server1", - skillName: "skill1", - sessionID: "session-1", - } - const session2Info: SkillMcpClientInfo = { - serverName: "server1", - skillName: "skill1", - sessionID: "session-2", - } - - // when - await manager.disconnectSession("session-1") - - // then - expect(manager.isConnected(session1Info)).toBe(false) - expect(manager.isConnected(session2Info)).toBe(false) - }) - - it("does not throw when session has no clients", async () => { - // given / #when / #then - await expect(manager.disconnectSession("nonexistent")).resolves.toBeUndefined() - }) - }) - - describe("disconnectAll", () => { - it("clears all clients", async () => { - // given - no actual clients connected (would require real MCP server) - - // when - await manager.disconnectAll() - - // then - expect(manager.getConnectedServers()).toEqual([]) - }) - - it("unregisters signal handlers after disconnectAll", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "signal-server", - skillName: "signal-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - url: "https://example.com/mcp", - } - - const before = process.listenerCount("SIGINT") - - // when - try { - await manager.getOrCreateClient(info, config) - } catch { - // Expected to fail connection, still registers cleanup handlers - } - const afterRegister = process.listenerCount("SIGINT") - - await manager.disconnectAll() - const afterDisconnect = process.listenerCount("SIGINT") - - // then - expect(afterRegister).toBe(before + 1) - expect(afterDisconnect).toBe(before) - }) - }) - - describe("isConnected", () => { - it("returns false for unconnected server", () => { - // given - const info: SkillMcpClientInfo = { - serverName: "unknown", - skillName: "test", - sessionID: "session-1", - } - - // when / #then - expect(manager.isConnected(info)).toBe(false) - }) - }) - - describe("getConnectedServers", () => { - it("returns empty array when no servers connected", () => { - // given / #when / #then - expect(manager.getConnectedServers()).toEqual([]) - }) - }) - - describe("environment variable handling", () => { - it("always inherits process.env even when config.env is undefined", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "test-server", - skillName: "test-skill", - sessionID: "session-1", - } - const configWithoutEnv: McpServerDefinition = { - command: "node", - args: ["-e", "process.exit(0)"], - } - - // when - attempt connection (will fail but exercises env merging code path) - // then - should not throw "undefined" related errors for env - try { - await manager.getOrCreateClient(info, configWithoutEnv) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - expect(message).not.toContain("env") - expect(message).not.toContain("undefined") - } - }) - - it("overlays config.env on top of inherited process.env", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "test-server", - skillName: "test-skill", - sessionID: "session-2", - } - const configWithEnv: McpServerDefinition = { - command: "node", - args: ["-e", "process.exit(0)"], - env: { - CUSTOM_VAR: "custom_value", - }, - } - - // when - attempt connection - // then - should not throw, env merging should work - try { - await manager.getOrCreateClient(info, configWithEnv) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - expect(message).toContain("Failed to connect") - } - }) - }) - - describe("HTTP headers handling", () => { - it("accepts configuration with headers", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "auth-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - url: "https://example.com/mcp", - headers: { - Authorization: "Bearer test-token", - "X-Custom-Header": "custom-value", - }, - } - - // when / #then - should fail at connection, not config validation - // Headers are passed through to the transport - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Failed to connect/ - ) - - // Verify headers were forwarded to transport - expect(lastTransportInstance.options?.requestInit?.headers).toEqual({ - Authorization: "Bearer test-token", - "X-Custom-Header": "custom-value", - }) - }) - - it("works without headers (optional)", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "no-auth-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: McpServerDefinition = { - url: "https://example.com/mcp", - // no headers - } - - // when / #then - should fail at connection, not config validation - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /Failed to connect/ - ) - }) - }) - - describe("operation retry logic", () => { - it("should retry operation when 'Not connected' error occurs", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "retry-server", - skillName: "retry-skill", - sessionID: "session-retry-1", - } - const context: SkillMcpServerContext = { - config: { - url: "https://example.com/mcp", - }, - skillName: "retry-skill", - } - - let callCount = 0 - const mockClient = { - callTool: mock(async () => { - callCount++ - if (callCount === 1) { - throw new Error("Not connected") - } - return { content: [{ type: "text", text: "success" }] } - }), - close: mock(() => Promise.resolve()), - } - - const getOrCreateSpy = spyOn( - manager as { getOrCreateClientWithRetry: (...args: unknown[]) => Promise }, - "getOrCreateClientWithRetry", -) - getOrCreateSpy.mockResolvedValue(mockClient) - - // when - const result = await manager.callTool(info, context, "test-tool", {}) - - // then - expect(callCount).toBe(2) - expect(result).toEqual([{ type: "text", text: "success" }]) - expect(getOrCreateSpy).toHaveBeenCalledTimes(2) - }) - - it("should fail after 3 retry attempts", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "fail-server", - skillName: "fail-skill", - sessionID: "session-fail-1", - } - const context: SkillMcpServerContext = { - config: { - url: "https://example.com/mcp", - }, - skillName: "fail-skill", - } - - const mockClient = { - callTool: mock(async () => { - throw new Error("Not connected") - }), - close: mock(() => Promise.resolve()), - } - - const getOrCreateSpy = spyOn( - manager as { getOrCreateClientWithRetry: (...args: unknown[]) => Promise }, - "getOrCreateClientWithRetry", -) - getOrCreateSpy.mockResolvedValue(mockClient) - - // when / #then - await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow( - /Failed after 3 reconnection attempts/ - ) - expect(getOrCreateSpy).toHaveBeenCalledTimes(3) - }) - - it("should not retry on non-connection errors", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "error-server", - skillName: "error-skill", - sessionID: "session-error-1", - } - const context: SkillMcpServerContext = { - config: { - url: "https://example.com/mcp", - }, - skillName: "error-skill", - } - - const mockClient = { - callTool: mock(async () => { - throw new Error("Tool not found") - }), - close: mock(() => Promise.resolve()), - } - - const getOrCreateSpy = spyOn( - manager as { getOrCreateClientWithRetry: (...args: unknown[]) => Promise }, - "getOrCreateClientWithRetry", -) - getOrCreateSpy.mockResolvedValue(mockClient) - - // when / #then - await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow( - "Tool not found" - ) - expect(getOrCreateSpy).toHaveBeenCalledTimes(1) - }) - }) - - describe("OAuth integration", () => { - beforeEach(() => { - mockTokens.mockClear() - mockLogin.mockClear() - }) - - it("injects Authorization header when oauth config has stored tokens", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "oauth-server", - skillName: "oauth-skill", - sessionID: "session-oauth-1", - } - const config: McpServerDefinition = { - url: "https://mcp.example.com/mcp", - oauth: { - clientId: "my-client", - scopes: ["read", "write"], - }, - } - mockTokens.mockReturnValue({ accessToken: "stored-access-token" }) - - // when - try { - await manager.getOrCreateClient(info, config) - } catch { /* connection fails in test */ } - - // then - const headers = lastTransportInstance.options?.requestInit?.headers as Record | undefined - expect(headers?.Authorization).toBe("Bearer stored-access-token") - }) - - it("does not inject Authorization header when no stored tokens exist and login fails", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "oauth-no-token", - skillName: "oauth-skill", - sessionID: "session-oauth-2", - } - const config: McpServerDefinition = { - url: "https://mcp.example.com/mcp", - oauth: { - clientId: "my-client", - }, - } - mockTokens.mockReturnValue(null) - mockLogin.mockRejectedValue(new Error("Login failed")) - - // when - try { - await manager.getOrCreateClient(info, config) - } catch { /* connection fails in test */ } - - // then - const headers = lastTransportInstance.options?.requestInit?.headers as Record | undefined - expect(headers?.Authorization).toBeUndefined() - }) - - it("preserves existing static headers alongside OAuth token", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "oauth-with-headers", - skillName: "oauth-skill", - sessionID: "session-oauth-3", - } - const config: McpServerDefinition = { - url: "https://mcp.example.com/mcp", - headers: { - "X-Custom": "custom-value", - }, - oauth: { - clientId: "my-client", - }, - } - mockTokens.mockReturnValue({ accessToken: "oauth-token" }) - - // when - try { - await manager.getOrCreateClient(info, config) - } catch { /* connection fails in test */ } - - // then - const headers = lastTransportInstance.options?.requestInit?.headers as Record | undefined - expect(headers?.["X-Custom"]).toBe("custom-value") - expect(headers?.Authorization).toBe("Bearer oauth-token") - }) - - it("does not create auth provider when oauth config is absent", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "no-oauth-server", - skillName: "test-skill", - sessionID: "session-no-oauth", - } - const config: McpServerDefinition = { - url: "https://mcp.example.com/mcp", - headers: { - Authorization: "Bearer static-token", - }, - } - - // when - try { - await manager.getOrCreateClient(info, config) - } catch { /* connection fails in test */ } - - // then - const headers = lastTransportInstance.options?.requestInit?.headers as Record | undefined - expect(headers?.Authorization).toBe("Bearer static-token") - expect(mockTokens).not.toHaveBeenCalled() - }) - - it("handles step-up auth by triggering re-login on 403 with scope", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "stepup-server", - skillName: "stepup-skill", - sessionID: "session-stepup-1", - } - const config: McpServerDefinition = { - url: "https://mcp.example.com/mcp", - oauth: { - clientId: "my-client", - scopes: ["read"], - }, - } - const context: SkillMcpServerContext = { - config, - skillName: "stepup-skill", - } - - mockTokens.mockReturnValue({ accessToken: "initial-token" }) - mockLogin.mockResolvedValue({ accessToken: "upgraded-token" }) - - let callCount = 0 - const mockClient = { - callTool: mock(async () => { - callCount++ - if (callCount === 1) { - throw new Error('403 WWW-Authenticate: Bearer scope="admin write"') - } - return { content: [{ type: "text", text: "success" }] } - }), - close: mock(() => Promise.resolve()), - } - - const getOrCreateSpy = spyOn( - manager as { getOrCreateClientWithRetry: (...args: unknown[]) => Promise }, - "getOrCreateClientWithRetry", -) - getOrCreateSpy.mockResolvedValue(mockClient) - - // when - const result = await manager.callTool(info, context, "test-tool", {}) - - // then - expect(result).toEqual([{ type: "text", text: "success" }]) - expect(mockLogin).toHaveBeenCalled() - }) - - it("does not attempt step-up when oauth config is absent", async () => { - // given - const info: SkillMcpClientInfo = { - serverName: "no-stepup-server", - skillName: "no-stepup-skill", - sessionID: "session-no-stepup", - } - const context: SkillMcpServerContext = { - config: { - url: "https://mcp.example.com/mcp", - }, - skillName: "no-stepup-skill", - } - - const mockClient = { - callTool: mock(async () => { - throw new Error('403 WWW-Authenticate: Bearer scope="admin"') - }), - close: mock(() => Promise.resolve()), - } - - const getOrCreateSpy = spyOn( - manager as { getOrCreateClientWithRetry: (...args: unknown[]) => Promise }, - "getOrCreateClientWithRetry", -) - getOrCreateSpy.mockResolvedValue(mockClient) - - // when / #then - await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(/403/) - expect(mockLogin).not.toHaveBeenCalled() - }) - }) -}) diff --git a/tests/features/tmux-subagent/manager.test.ts b/tests/features/tmux-subagent/manager.test.ts index 56709461d79..ba04b5d054c 100644 --- a/tests/features/tmux-subagent/manager.test.ts +++ b/tests/features/tmux-subagent/manager.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' import type { TmuxConfig } from '../../../src/config/schema' import type { ActionResult, ExecuteContext } from '../../../src/features/tmux-subagent/action-executor' import type { TmuxUtilDeps } from '../../../src/features/tmux-subagent/manager' @@ -717,3 +717,7 @@ describe('DecisionEngine', () => { }) }) }) + +afterAll(() => { + mock.restore() +}) diff --git a/tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts b/tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts index 977cc3115bd..f44c7c363d7 100644 --- a/tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts +++ b/tests/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test" +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" import { fixEmptyMessagesWithSDK } from "../../../src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk" const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false)) @@ -11,6 +11,10 @@ mock.module("../../../src/hooks/session-recovery/storage/text-part-injector", () injectTextPartAsync: mockInjectTextPart, })) +afterAll(() => { + mock.restore() +}) + function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) { return { session: { diff --git a/tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts b/tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts index 91f39c0e078..12549acc06c 100644 --- a/tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts +++ b/tests/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" const executeCompactMock = mock(async () => {}) @@ -24,6 +24,10 @@ mock.module("../../../src/shared/logger", () => ({ log: () => {}, })) +afterAll(() => { + mock.restore() +}) + function createMockContext(): PluginInput { return { client: { diff --git a/tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts b/tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts index 758666d08e7..5c234f5f57a 100644 --- a/tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts +++ b/tests/hooks/anthropic-context-window-limit-recovery/storage.test.ts @@ -13,6 +13,7 @@ mock.module("../../../src/hooks/anthropic-context-window-limit-recovery/storage" afterAll(() => { mock.module("../../../src/hooks/anthropic-context-window-limit-recovery/storage", () => storage) + mock.restore() }) describe("truncateUntilTargetTokens", () => { diff --git a/tests/hooks/architect/index.test.ts b/tests/hooks/architect/index.test.ts index 132d538ad42..7bf0a710983 100644 --- a/tests/hooks/architect/index.test.ts +++ b/tests/hooks/architect/index.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { randomUUID } from "node:crypto" import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -60,6 +60,10 @@ mock.module("/home/klpanagi/matrixx/src/shared/session-utils.ts", () => ({ const { createArchitectHook } = await import("../../../src/hooks/architect/index") const { MESSAGE_STORAGE } = await import("../../../src/features/hook-message-injector") +afterAll(() => { + mock.restore() +}) + describe("architect hook", () => { let TEST_DIR: string let MATRIX_DIR: string diff --git a/tests/hooks/auto-update-checker/hook.test.ts b/tests/hooks/auto-update-checker/hook.test.ts index 3cdd1ce97af..b064c709b91 100644 --- a/tests/hooks/auto-update-checker/hook.test.ts +++ b/tests/hooks/auto-update-checker/hook.test.ts @@ -48,6 +48,7 @@ const { createAutoUpdateCheckerHook } = await import("../../../src/hooks/auto-up afterAll(() => { mock.module("../../../src/hooks/auto-update-checker/hook/background-update-check", () => realBackgroundUpdateCheckModule) mock.module("../../../src/hooks/auto-update-checker/checker", () => realCheckerModule) + mock.restore() }) describe("createAutoUpdateCheckerHook", () => { diff --git a/tests/hooks/auto-update-checker/hook/background-update-check.test.ts b/tests/hooks/auto-update-checker/hook/background-update-check.test.ts index 91199df543e..2495972ff1a 100644 --- a/tests/hooks/auto-update-checker/hook/background-update-check.test.ts +++ b/tests/hooks/auto-update-checker/hook/background-update-check.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test" +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" const mockFindPluginEntry = mock(() => null) const mockGetCachedVersion = mock(() => null as string | null) @@ -110,6 +110,10 @@ mock.module("../../../../src/hooks/auto-update-checker/hook/background-update-ch return { runBackgroundUpdateCheck } }) +afterAll(() => { + mock.restore() +}) + const { runBackgroundUpdateCheck } = await import("../../../../src/hooks/auto-update-checker/hook/background-update-check") describe("runBackgroundUpdateCheck", () => { diff --git a/tests/hooks/comment-checker/cli.test.ts b/tests/hooks/comment-checker/cli.test.ts index 9fdf06f56d3..9ff0629b8ad 100644 --- a/tests/hooks/comment-checker/cli.test.ts +++ b/tests/hooks/comment-checker/cli.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from "bun:test" +import { afterAll, describe, expect, mock, test } from "bun:test" import { chmodSync, mkdtempSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -201,5 +201,9 @@ exit 2 // then expect(callCount).toBe(2) }) + + afterAll(() => { + mock.restore() + }) }) }) diff --git a/tests/hooks/comment-checker/hook.apply-patch.test.ts b/tests/hooks/comment-checker/hook.apply-patch.test.ts index bee0789ca35..196b0c80c74 100644 --- a/tests/hooks/comment-checker/hook.apply-patch.test.ts +++ b/tests/hooks/comment-checker/hook.apply-patch.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test" +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" const processApplyPatchEditsWithCli = mock(async () => {}) @@ -10,6 +10,10 @@ mock.module("../../../src/hooks/comment-checker/cli-runner", () => ({ processApplyPatchEditsWithCli, })) +afterAll(() => { + mock.restore() +}) + const { createCommentCheckerHooks } = await import("../../../src/hooks/comment-checker/hook") describe("comment-checker apply_patch integration", () => { diff --git a/tests/hooks/compaction-context-injector/index.test.ts b/tests/hooks/compaction-context-injector/index.test.ts index 0a7c5ae9764..b0eb51b4ee7 100644 --- a/tests/hooks/compaction-context-injector/index.test.ts +++ b/tests/hooks/compaction-context-injector/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, mock } from "bun:test" +import { afterAll, describe, expect, it, mock } from "bun:test" mock.module("../../../src/shared/system-directive", () => ({ createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`, @@ -10,10 +10,14 @@ mock.module("../../../src/shared/system-directive", () => ({ SINGLE_TASK_ONLY: "SINGLE TASK ONLY", COMPACTION_CONTEXT: "COMPACTION CONTEXT", CONTEXT_WINDOW_MONITOR: "CONTEXT WINDOW MONITOR", - PROMETHEUS_READ_ONLY: "PROMETHEUS READ-ONLY", + ORACLE_READ_ONLY: "ORACLE READ-ONLY", }, })) +afterAll(() => { + mock.restore() +}) + import { TaskHistory } from "../../../src/features/background-agent/task-history" import { createCompactionContextInjector } from "../../../src/hooks/compaction-context-injector/index" diff --git a/tests/hooks/compaction-todo-preserver/index.test.ts b/tests/hooks/compaction-todo-preserver/index.test.ts index 638f7921f70..44d3c855764 100644 --- a/tests/hooks/compaction-todo-preserver/index.test.ts +++ b/tests/hooks/compaction-todo-preserver/index.test.ts @@ -18,6 +18,7 @@ afterAll(() => { update: async () => {}, }, })) + mock.restore() }) function createMockContext(todoResponses: Array[]): PluginInput { diff --git a/tests/hooks/directory-agents-injector/injector.test.ts b/tests/hooks/directory-agents-injector/injector.test.ts index 732604efacb..e3f9bced506 100644 --- a/tests/hooks/directory-agents-injector/injector.test.ts +++ b/tests/hooks/directory-agents-injector/injector.test.ts @@ -47,8 +47,7 @@ mock.module("../../../src/hooks/directory-agents-injector/storage", () => ({ const { processFilePathForAgentsInjection } = await import("../../../src/hooks/directory-agents-injector/injector") afterAll(() => { - mock.module("node:fs/promises", () => realFsPromises) - mock.module("node:fs", () => realFs) + mock.restore() }) describe("processFilePathForAgentsInjection", () => { diff --git a/tests/hooks/directory-readme-injector/injector.test.ts b/tests/hooks/directory-readme-injector/injector.test.ts index 18890234f08..724c644cb36 100644 --- a/tests/hooks/directory-readme-injector/injector.test.ts +++ b/tests/hooks/directory-readme-injector/injector.test.ts @@ -47,8 +47,7 @@ mock.module("../../../src/hooks/directory-readme-injector/storage", () => ({ const { processFilePathForReadmeInjection } = await import("../../../src/hooks/directory-readme-injector/injector") afterAll(() => { - mock.module("node:fs/promises", () => realFsPromises) - mock.module("node:fs", () => realFs) + mock.restore() }) describe("processFilePathForReadmeInjection", () => { diff --git a/tests/hooks/env-context-injector/index.test.ts b/tests/hooks/env-context-injector/index.test.ts index 5794054bdd7..5305bd1bfda 100644 --- a/tests/hooks/env-context-injector/index.test.ts +++ b/tests/hooks/env-context-injector/index.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, setSystemTime } from "bun:test" import { createKeymakerAgent } from "../../../src/agents/keymaker" import { createMorpheusAgent } from "../../../src/agents/morpheus" -import { createLibrarianAgent } from "../../../src/agents/operator" +import { createOperatorAgent } from "../../../src/agents/operator" import { createEnvContextInjectorHook } from "../../../src/hooks/env-context-injector/index" @@ -244,7 +244,7 @@ describe("P2: prefix-cache stability — affected agents are clean", () => { it("Operator (Librarian) agent config does not contain embedded env-context strings", () => { //#given - const agent = createLibrarianAgent("claude-haiku-4-5") + const agent = createOperatorAgent("claude-haiku-4-5") //#then for (const token of envTokens) { diff --git a/tests/hooks/prometheus-md-only/index.test.ts b/tests/hooks/oracle-md-only/index.test.ts similarity index 98% rename from tests/hooks/prometheus-md-only/index.test.ts rename to tests/hooks/oracle-md-only/index.test.ts index 26e0ba4e4da..c819737cc97 100644 --- a/tests/hooks/prometheus-md-only/index.test.ts +++ b/tests/hooks/oracle-md-only/index.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { randomUUID } from "node:crypto" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -11,9 +11,13 @@ mock.module("../../../src/shared/opencode-storage-detection", () => ({ resetSqliteBackendCache: () => {}, })) -const { createOracleMdOnlyHook } = await import("../../../src/hooks/prometheus-md-only/index") +const { createOracleMdOnlyHook } = await import("../../../src/hooks/oracle-md-only/index") const { MESSAGE_STORAGE } = await import("../../../src/features/hook-message-injector") +afterAll(() => { + mock.restore() +}) + describe("oracle-md-only", () => { const TEST_SESSION_ID = "ses_test-session-oracle" let testMessageDir: string @@ -225,10 +229,10 @@ describe("oracle-md-only", () => { await hook["tool.execute.before"](input, output) // then - expect(output.message).toContain("PROMETHEUS MANDATORY WORKFLOW REMINDER") + expect(output.message).toContain("ORACLE MANDATORY WORKFLOW REMINDER") expect(output.message).toContain("INTERVIEW") - expect(output.message).toContain("METIS CONSULTATION") - expect(output.message).toContain("MOMUS REVIEW") + expect(output.message).toContain("SERAPH CONSULTATION") + expect(output.message).toContain("SMITH REVIEW") }) test("should NOT inject workflow reminder for .matrixx/drafts/", async () => { diff --git a/tests/hooks/preemptive-compaction.test.ts b/tests/hooks/preemptive-compaction.test.ts index 8f252c1a88d..9529e88e50f 100644 --- a/tests/hooks/preemptive-compaction.test.ts +++ b/tests/hooks/preemptive-compaction.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test" +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" const logMock = mock(() => {}) @@ -8,6 +8,10 @@ mock.module("../../src/shared/logger", () => ({ const { createPreemptiveCompactionHook } = await import("../../src/hooks/preemptive-compaction") +afterAll(() => { + mock.restore() +}) + function createMockCtx() { return { client: { diff --git a/tests/hooks/rules-injector/injector.test.ts b/tests/hooks/rules-injector/injector.test.ts index 4aaf5f7ad88..8ecdfcad3a4 100644 --- a/tests/hooks/rules-injector/injector.test.ts +++ b/tests/hooks/rules-injector/injector.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; @@ -18,6 +18,10 @@ mock.module("../../../src/hooks/rules-injector/matcher", () => ({ isDuplicateByContentHash: (hash: string, cache: Set) => cache.has(hash), })); +afterAll(() => { + mock.restore() +}) + function createOutput(): { title: string; output: string; metadata: unknown } { return { title: "tool", output: "", metadata: {} }; } diff --git a/tests/plugin-handlers/command-config-handler.test.ts b/tests/plugin-handlers/command-config-handler.test.ts index 40bfdfabac4..c88fd2c2c30 100644 --- a/tests/plugin-handlers/command-config-handler.test.ts +++ b/tests/plugin-handlers/command-config-handler.test.ts @@ -4,21 +4,12 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import type { MatrixxConfig } from "../../src/config" import * as builtinCommandsModule from "../../src/features/builtin-commands" import * as ccCommandLoaderModule from "../../src/features/command-loader" -import * as opencodeSkillLoaderModule from "../../src/features/opencode-skill-loader" -import * as skillDefinitionRecordModule from "../../src/features/opencode-skill-loader/skill-definition-record" -import type { LoadedSkill } from "../../src/features/opencode-skill-loader/types" let loadBuiltinCommandsSpy: ReturnType let loadUserCommandsSpy: ReturnType let loadProjectCommandsSpy: ReturnType let loadOpencodeGlobalCommandsSpy: ReturnType let loadOpencodeProjectCommandsSpy: ReturnType -let discoverConfigSourceSkillsSpy: ReturnType -let loadUserSkillsSpy: ReturnType -let loadProjectSkillsSpy: ReturnType -let loadOpencodeGlobalSkillsSpy: ReturnType -let loadOpencodeProjectSkillsSpy: ReturnType -let skillsToCommandDefinitionRecordSpy: ReturnType beforeEach(() => { loadBuiltinCommandsSpy = spyOn(builtinCommandsModule, "loadBuiltinCommands").mockReturnValue({}) @@ -26,12 +17,6 @@ beforeEach(() => { loadProjectCommandsSpy = spyOn(ccCommandLoaderModule, "loadProjectCommands").mockResolvedValue({}) loadOpencodeGlobalCommandsSpy = spyOn(ccCommandLoaderModule, "loadOpencodeGlobalCommands").mockResolvedValue({}) loadOpencodeProjectCommandsSpy = spyOn(ccCommandLoaderModule, "loadOpencodeProjectCommands").mockResolvedValue({}) - discoverConfigSourceSkillsSpy = spyOn(opencodeSkillLoaderModule, "discoverConfigSourceSkills").mockResolvedValue([]) - loadUserSkillsSpy = spyOn(opencodeSkillLoaderModule, "loadUserSkills").mockResolvedValue({}) - loadProjectSkillsSpy = spyOn(opencodeSkillLoaderModule, "loadProjectSkills").mockResolvedValue({}) - loadOpencodeGlobalSkillsSpy = spyOn(opencodeSkillLoaderModule, "loadOpencodeGlobalSkills").mockResolvedValue({}) - loadOpencodeProjectSkillsSpy = spyOn(opencodeSkillLoaderModule, "loadOpencodeProjectSkills").mockResolvedValue({}) - skillsToCommandDefinitionRecordSpy = spyOn(skillDefinitionRecordModule, "skillsToCommandDefinitionRecord").mockReturnValue({}) }) afterEach(() => { @@ -40,12 +25,6 @@ afterEach(() => { loadProjectCommandsSpy.mockRestore() loadOpencodeGlobalCommandsSpy.mockRestore() loadOpencodeProjectCommandsSpy.mockRestore() - discoverConfigSourceSkillsSpy.mockRestore() - loadUserSkillsSpy.mockRestore() - loadProjectSkillsSpy.mockRestore() - loadOpencodeGlobalSkillsSpy.mockRestore() - loadOpencodeProjectSkillsSpy.mockRestore() - skillsToCommandDefinitionRecordSpy.mockRestore() }) const EMPTY_PLUGIN_COMPONENTS = { @@ -183,44 +162,4 @@ describe("applyCommandConfig", () => { const result = config.command as Record> expect(result["empty-agent-cmd"].agent).toBe("") }) - - test("does NOT remap agent field from skill sources", async () => { - //#given - const config: Record = { command: {} } - const pluginConfig = createPluginConfig() - loadUserSkillsSpy.mockResolvedValue({}) - discoverConfigSourceSkillsSpy.mockResolvedValue([{ - name: "oracle-skill", - path: "/tmp/oracle-skill/SKILL.md", - resolvedPath: "/tmp/oracle-skill/SKILL.md", - definition: { - name: "oracle-skill", - description: "Oracle analysis skill", - agent: "oracle", - template: "template", - }, - scope: "user", - } as LoadedSkill]) - skillsToCommandDefinitionRecordSpy.mockReturnValue({ - "oracle-skill": { - name: "oracle-skill", - description: "Oracle analysis skill", - agent: "oracle", - template: "template", - }, - }) - - //#when - const { applyCommandConfig } = await import("../../src/plugin-handlers/command-config-handler") - await applyCommandConfig({ - config, - pluginConfig, - ctx: { directory: "/tmp" }, - pluginComponents: EMPTY_PLUGIN_COMPONENTS, - }) - - //#then - const result = config.command as Record> - expect(result["oracle-skill"].agent).toBe("oracle") - }) }) diff --git a/tests/plugin-handlers/config-handler.test.ts b/tests/plugin-handlers/config-handler.test.ts deleted file mode 100644 index b4f930a9020..00000000000 --- a/tests/plugin-handlers/config-handler.test.ts +++ /dev/null @@ -1,1005 +0,0 @@ -/// - -import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" -import * as agents from "../../src/agents" -import * as mouse from "../../src/agents/mouse" -import type { MatrixxConfig } from "../../src/config" -import type { CategoryConfig } from "../../src/config/schema" -import * as builtinCommands from "../../src/features/builtin-commands" -import * as commandLoader from "../../src/features/command-loader" -import * as skillLoader from "../../src/features/opencode-skill-loader" -import * as mcpModule from "../../src/mcp" -import * as shared from "../../src/shared" -import * as connectedProvidersCache from "../../src/shared/connected-providers-cache" -import * as modelResolver from "../../src/shared/model-resolver" -import * as configDir from "../../src/shared/opencode-config-dir" -import * as permissionCompat from "../../src/shared/permission-compat" -import { createConfigHandler, resolveCategoryConfig } from "../../src/plugin-handlers/config-handler" - -const realResolveModelWithFallback = require("../../src/shared/model-resolver").resolveModelWithFallback -const realResolveModelPipeline = require("../../src/shared/model-resolution-pipeline").resolveModelPipeline - -afterAll(() => { - mock.module("../../src/shared/model-resolver", () => ({ - ...require("../../src/shared/model-resolver"), - resolveModelWithFallback: realResolveModelWithFallback, - })) - mock.module("../../src/shared/model-resolution-pipeline", () => ({ - ...require("../../src/shared/model-resolution-pipeline"), - resolveModelPipeline: realResolveModelPipeline, - })) - mock.module("../../src/shared", () => ({ - ...require("../../src/shared"), - resolveModelPipeline: realResolveModelPipeline, - })) -}) - -beforeEach(() => { - spyOn(agents, "createBuiltinAgents").mockResolvedValue({ - morpheus: { name: "morpheus", prompt: "test", mode: "primary" }, - }) - - spyOn(commandLoader, "loadUserCommands").mockResolvedValue({}) - spyOn(commandLoader, "loadProjectCommands").mockResolvedValue({}) - spyOn(commandLoader, "loadOpencodeGlobalCommands").mockResolvedValue({}) - spyOn(commandLoader, "loadOpencodeProjectCommands").mockResolvedValue({}) - - spyOn(builtinCommands, "loadBuiltinCommands").mockReturnValue({}) - - spyOn(skillLoader, "loadUserSkills").mockResolvedValue({}) - spyOn(skillLoader, "loadProjectSkills").mockResolvedValue({}) - spyOn(skillLoader, "loadOpencodeGlobalSkills").mockResolvedValue({}) - spyOn(skillLoader, "loadOpencodeProjectSkills").mockResolvedValue({}) - - spyOn(mcpModule, "createBuiltinMcps").mockReturnValue({}) - - spyOn(shared, "log").mockImplementation(() => {}) - spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set(["anthropic/claude-opus-4-6"])) - spyOn(shared, "readConnectedProvidersCache").mockReturnValue(null) - - spyOn(configDir, "getOpenCodeConfigPaths").mockReturnValue({ - global: "/tmp/.config/opencode", - project: "/tmp/.opencode", - }) - - spyOn(permissionCompat, "migrateAgentConfig").mockImplementation((config: Record) => config) - - spyOn(modelResolver, "resolveModelWithFallback").mockReturnValue({ model: "anthropic/claude-opus-4-6" }) -}) - -afterEach(() => { - (agents.createBuiltinAgents as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(mouse.createMouseAgentWithOverrides as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(commandLoader.loadUserCommands as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(commandLoader.loadProjectCommands as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(commandLoader.loadOpencodeGlobalCommands as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(commandLoader.loadOpencodeProjectCommands as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(builtinCommands.loadBuiltinCommands as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(skillLoader.loadUserSkills as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(skillLoader.loadProjectSkills as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(skillLoader.loadOpencodeGlobalSkills as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(skillLoader.loadOpencodeProjectSkills as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(mcpModule.createBuiltinMcps as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(shared.log as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(shared.fetchAvailableModels as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(shared.readConnectedProvidersCache as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(configDir.getOpenCodeConfigPaths as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(permissionCompat.migrateAgentConfig as unknown as { mockRestore?: () => void })?.mockRestore?.() - ;(modelResolver.resolveModelWithFallback as unknown as { mockRestore?: () => void })?.mockRestore?.() -}) - -describe("Mouse model inheritance", () => { - test("does not inherit UI-selected model as system default", async () => { - // #given - const pluginConfig: MatrixxConfig = {} - const config: Record = { - model: "opencode/kimi-k2.5-free", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - const agentConfig = config.agent as Record - expect(agentConfig.mouse?.model).toBe( - mouse.MOUSE_DEFAULTS.model - ) - }) - - test("uses explicitly configured mouse model", async () => { - // #given - const pluginConfig: MatrixxConfig = { - agents: { - "mouse": { - model: "openai/gpt-5.3-codex", - }, - }, - } - const config: Record = { - model: "opencode/kimi-k2.5-free", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - const agentConfig = config.agent as Record - expect(agentConfig.mouse?.model).toBe( - "openai/gpt-5.3-codex" - ) - }) -}) - -describe("Plan agent demote behavior", () => { - test("orders core agents as morpheus -> keymaker -> oracle -> architect", async () => { - // #given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { - mockResolvedValue: (value: Record) => void - } - createBuiltinAgentsMock.mockResolvedValue({ - morpheus: { name: "morpheus", prompt: "test", mode: "primary" }, - keymaker: { name: "keymaker", prompt: "test", mode: "primary" }, - oracle: { name: "oracle", prompt: "test", mode: "subagent" }, - architect: { name: "architect", prompt: "test", mode: "primary" }, - }) - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - const keys = Object.keys(config.agent as Record) - const coreAgents = ["morpheus", "keymaker", "oracle", "architect"] - const ordered = keys.filter((key) => coreAgents.includes(key)) - expect(ordered).toEqual(coreAgents) - }) - - test("plan agent should be demoted to subagent without inheriting oracle prompt", async () => { - // #given - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - replace_plan: true, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: { - plan: { - name: "plan", - mode: "primary", - prompt: "original plan prompt", - }, - }, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - plan is demoted to subagent but does NOT inherit oracle prompt - const agents = config.agent as Record - expect(agents.plan).toBeDefined() - expect(agents.plan.mode).toBe("subagent") - expect(agents.plan.prompt).toBeUndefined() - expect(agents.oracle?.prompt).toBeDefined() - }) - - test("plan agent remains unchanged when planner is disabled", async () => { - // #given - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: false, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: { - plan: { - name: "plan", - mode: "primary", - prompt: "original plan prompt", - }, - }, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - plan is not touched, oracle is not created - const agents = config.agent as Record - expect(agents.oracle).toBeUndefined() - expect(agents.plan).toBeDefined() - expect(agents.plan.mode).toBe("primary") - expect(agents.plan.prompt).toBe("original plan prompt") - }) - - test("oracle should have mode 'all' to be callable via task", async () => { - // given - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // when - await handler(config) - - // then - const agents = config.agent as Record - expect(agents.oracle).toBeDefined() - expect(agents.oracle.mode).toBe("all") - }) -}) - -describe("Agent permission defaults", () => { - test("keymaker should allow task", async () => { - // #given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { - mockResolvedValue: (value: Record) => void - } - createBuiltinAgentsMock.mockResolvedValue({ - morpheus: { name: "morpheus", prompt: "test", mode: "primary" }, - keymaker: { name: "keymaker", prompt: "test", mode: "primary" }, - oracle: { name: "oracle", prompt: "test", mode: "subagent" }, - }) - const pluginConfig: MatrixxConfig = {} - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - const agentConfig = config.agent as Record }> - expect(agentConfig.keymaker).toBeDefined() - expect(agentConfig.keymaker.permission?.task).toBe("allow") - }) -}) - -describe("default_agent behavior with Morpheus orchestration", () => { - test("preserves existing default_agent when already set", async () => { - // #given - const pluginConfig: MatrixxConfig = {} - const config: Record = { - model: "anthropic/claude-opus-4-6", - default_agent: "keymaker", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - expect(config.default_agent).toBe("keymaker") - }) - - test("sets default_agent to morpheus when missing", async () => { - // #given - const pluginConfig: MatrixxConfig = {} - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - expect(config.default_agent).toBe("morpheus") - }) -}) - -describe("Oracle category config resolution", () => { - let providerModelsSpy: ReturnType | undefined - let connectedProvidersSpy: ReturnType | undefined - - beforeEach(() => { - providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ - models: { - anthropic: [ - "claude-opus-4-6", - "claude-sonnet-4-6", - "claude-haiku-4-5", - ], - }, - connected: ["anthropic"], - updatedAt: new Date().toISOString(), - }) - connectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"]) - }) - - afterEach(() => { - providerModelsSpy?.mockRestore() - connectedProvidersSpy?.mockRestore() - }) - - test("resolves source category config", () => { - // given - const categoryName = "source" - - // when - const config = resolveCategoryConfig(categoryName) - - // then - expect(config).toBeDefined() - expect(config?.model).toBe("anthropic/claude-opus-4-6") - expect(config?.variant).toBe("max") - }) - - test("resolves construct category config", () => { - // given - const categoryName = "construct" - - // when - const config = resolveCategoryConfig(categoryName) - - // then - expect(config).toBeDefined() - expect(config?.model).toBe("anthropic/claude-sonnet-4-6") - }) - - test("user categories override default categories", () => { - // given - const categoryName = "source" - const userCategories: Record = { - source: { - model: "google/antigravity-claude-opus-4-5-thinking", - temperature: 0.1, - }, - } - - // when - const config = resolveCategoryConfig(categoryName, userCategories) - - // then - expect(config).toBeDefined() - expect(config?.model).toBe("google/antigravity-claude-opus-4-5-thinking") - expect(config?.temperature).toBe(0.1) - }) - - test("returns undefined for unknown category", () => { - // given - const categoryName = "nonexistent-category" - - // when - const config = resolveCategoryConfig(categoryName) - - // then - expect(config).toBeUndefined() - }) - - test("falls back to default when user category has no entry", () => { - // given - const categoryName = "source" - const userCategories: Record = { - "construct": { - model: "custom/visual-model", - }, - } - - // when - const config = resolveCategoryConfig(categoryName, userCategories) - - // then - falls back to DEFAULT_CATEGORIES - expect(config).toBeDefined() - expect(config?.model).toBe("anthropic/claude-opus-4-6") - expect(config?.variant).toBe("max") - }) - - test("preserves all category properties (temperature, top_p, tools, etc.)", () => { - // given - const categoryName = "custom-category" - const userCategories: Record = { - "custom-category": { - model: "test/model", - temperature: 0.5, - top_p: 0.9, - maxTokens: 32000, - tools: { tool1: true, tool2: false }, - }, - } - - // when - const config = resolveCategoryConfig(categoryName, userCategories) - - // then - expect(config).toBeDefined() - expect(config?.model).toBe("test/model") - expect(config?.temperature).toBe(0.5) - expect(config?.top_p).toBe(0.9) - expect(config?.maxTokens).toBe(32000) - expect(config?.tools).toEqual({ tool1: true, tool2: false }) - }) -}) - -describe("Oracle direct override priority over category", () => { - test("direct reasoningEffort takes priority over category reasoningEffort", async () => { - // given - category has reasoningEffort=xhigh, direct override says "low" - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - }, - categories: { - "test-planning": { - model: "openai/gpt-5.2", - reasoningEffort: "xhigh", - }, - }, - agents: { - oracle: { - category: "test-planning", - reasoningEffort: "low", - }, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // when - await handler(config) - - // then - direct override's reasoningEffort wins - const agents = config.agent as Record - expect(agents.oracle).toBeDefined() - expect(agents.oracle.reasoningEffort).toBe("low") - }) - - test("category reasoningEffort applied when no direct override", async () => { - // given - category has reasoningEffort but no direct override - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - }, - categories: { - "reasoning-cat": { - model: "openai/gpt-5.2", - reasoningEffort: "high", - }, - }, - agents: { - oracle: { - category: "reasoning-cat", - }, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // when - await handler(config) - - // then - category's reasoningEffort is applied - const agents = config.agent as Record - expect(agents.oracle).toBeDefined() - expect(agents.oracle.reasoningEffort).toBe("high") - }) - - test("direct temperature takes priority over category temperature", async () => { - // given - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - }, - categories: { - "temp-cat": { - model: "openai/gpt-5.2", - temperature: 0.8, - }, - }, - agents: { - oracle: { - category: "temp-cat", - temperature: 0.1, - }, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // when - await handler(config) - - // then - direct temperature wins over category - const agents = config.agent as Record - expect(agents.oracle).toBeDefined() - expect(agents.oracle.temperature).toBe(0.1) - }) - - test("oracle prompt_append is appended to base prompt", async () => { - // #given - oracle override with prompt_append - const customInstructions = "## Custom Project Rules\nUse max 2 commits." - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - }, - agents: { - oracle: { - prompt_append: customInstructions, - }, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // #when - await handler(config) - - // #then - prompt_append is appended to base prompt, not overwriting it - const agents = config.agent as Record - expect(agents.oracle).toBeDefined() - expect(agents.oracle.prompt).toContain("Oracle") - expect(agents.oracle.prompt).toContain(customInstructions) - expect(agents.oracle.prompt?.endsWith(customInstructions)).toBe(true) - }) -}) - -describe("Plan agent model inheritance from oracle", () => { - test("plan agent inherits oracle config", async () => { - //#given - oracle resolves to claude-opus-4-6 with model settings - spyOn(shared, "resolveModelPipeline").mockReturnValue({ - model: "anthropic/claude-opus-4-6", - provenance: "provider-fallback", - variant: "max", - }) - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - replace_plan: true, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: { - plan: { - name: "plan", - mode: "primary", - prompt: "original plan prompt", - }, - }, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - //#when - await handler(config) - - //#then - plan inherits model and variant from oracle, but NOT prompt - const agents = config.agent as Record - expect(agents.plan).toBeDefined() - expect(agents.plan.mode).toBe("subagent") - expect(agents.plan.model).toBe("anthropic/claude-opus-4-6") - expect(agents.plan.variant).toBe("max") - expect(agents.plan.prompt).toBeUndefined() - }) - - test("plan agent inherits temperature, reasoningEffort, and other model settings from oracle", async () => { - //#given - oracle configured with category that has temperature and reasoningEffort - spyOn(shared, "resolveModelPipeline").mockReturnValue({ - model: "openai/gpt-5.2", - provenance: "override", - variant: "high", - }) - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - replace_plan: true, - }, - agents: { - oracle: { - model: "openai/gpt-5.2", - variant: "high", - temperature: 0.3, - top_p: 0.9, - maxTokens: 16000, - reasoningEffort: "high", - textVerbosity: "medium", - thinking: { type: "enabled", budgetTokens: 8000 }, - }, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - //#when - await handler(config) - - //#then - plan inherits oracle - const agents = config.agent as Record> - expect(agents.plan).toBeDefined() - expect(agents.plan.mode).toBe("subagent") - expect(agents.plan.model).toBe("openai/gpt-5.2") - expect(agents.plan.variant).toBe("high") - expect(agents.plan.temperature).toBe(0.3) - expect(agents.plan.top_p).toBe(0.9) - expect(agents.plan.maxTokens).toBe(16000) - expect(agents.plan.reasoningEffort).toBe("high") - expect(agents.plan.textVerbosity).toBe("medium") - expect(agents.plan.thinking).toEqual({ type: "enabled", budgetTokens: 8000 }) - }) - - test("plan agent user override takes priority over oracle inherited settings", async () => { - //#given - oracle resolves to opus, but user has plan override for gpt-5.2 - spyOn(shared, "resolveModelPipeline").mockReturnValue({ - model: "anthropic/claude-opus-4-6", - provenance: "provider-fallback", - variant: "max", - }) - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - replace_plan: true, - }, - agents: { - plan: { - model: "openai/gpt-5.2", - variant: "high", - temperature: 0.5, - }, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - //#when - await handler(config) - - //#then - plan uses its own override, not oracle settings - const agents = config.agent as Record> - expect(agents.plan.model).toBe("openai/gpt-5.2") - expect(agents.plan.variant).toBe("high") - expect(agents.plan.temperature).toBe(0.5) - }) - - test("plan agent does NOT inherit prompt, description, or color from oracle", async () => { - //#given - spyOn(shared, "resolveModelPipeline").mockReturnValue({ - model: "anthropic/claude-opus-4-6", - provenance: "provider-fallback", - variant: "max", - }) - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - replace_plan: true, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - //#when - await handler(config) - - //#then - plan has model settings but NOT prompt/description/color - const agents = config.agent as Record> - expect(agents.plan.model).toBe("anthropic/claude-opus-4-6") - expect(agents.plan.prompt).toBeUndefined() - expect(agents.plan.description).toBeUndefined() - expect(agents.plan.color).toBeUndefined() - }) -}) - -describe("Deadlock prevention - fetchAvailableModels must not receive client", () => { - test("fetchAvailableModels should be called with undefined client to prevent deadlock during plugin init", async () => { - // given - This test ensures we don't regress on issue #1301 - // Passing client to fetchAvailableModels during config handler causes deadlock: - // - Plugin init waits for server response (client.provider.list()) - // - Server waits for plugin init to complete before handling requests - const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set()) - - const pluginConfig: MatrixxConfig = { - morpheus_agent: { - planner_enabled: true, - }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const mockClient = { - provider: { list: () => Promise.resolve({ data: { connected: [] } }) }, - model: { list: () => Promise.resolve({ data: [] }) }, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp", client: mockClient }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - // when - await handler(config) - - // then - fetchAvailableModels must be called with undefined as first argument (no client) - // This prevents the deadlock described in issue #1301 - expect(fetchSpy).toHaveBeenCalled() - const firstCallArgs = fetchSpy.mock.calls[0] - expect(firstCallArgs[0]).toBeUndefined() - - fetchSpy.mockRestore?.() - }) -}) - -describe("per-agent todowrite/todoread deny when task_system enabled", () => { - const PRIMARY_AGENTS = [ - "morpheus", - "keymaker", - "architect", - "oracle", - "mouse", - ] - - test("denies todowrite and todoread for primary agents when task_system is enabled", async () => { - //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { - mockResolvedValue: (value: Record) => void - } - createBuiltinAgentsMock.mockResolvedValue({ - morpheus: { name: "morpheus", prompt: "test", mode: "primary" }, - keymaker: { name: "keymaker", prompt: "test", mode: "primary" }, - architect: { name: "architect", prompt: "test", mode: "primary" }, - oracle: { name: "oracle", prompt: "test", mode: "primary" }, - "mouse": { name: "mouse", prompt: "test", mode: "subagent" }, - }) - - const pluginConfig: MatrixxConfig = { - experimental: { task_system: true }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - //#when - await handler(config) - - //#then - const agentResult = config.agent as Record }> - for (const agentName of PRIMARY_AGENTS) { - expect(agentResult[agentName]?.permission?.todowrite).toBe("deny") - expect(agentResult[agentName]?.permission?.todoread).toBe("deny") - } - }) - - test("does not deny todowrite/todoread when task_system is disabled", async () => { - //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { - mockResolvedValue: (value: Record) => void - } - createBuiltinAgentsMock.mockResolvedValue({ - morpheus: { name: "morpheus", prompt: "test", mode: "primary" }, - keymaker: { name: "keymaker", prompt: "test", mode: "primary" }, - }) - - const pluginConfig: MatrixxConfig = { - experimental: { task_system: false }, - } - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - //#when - await handler(config) - - //#then - const agentResult = config.agent as Record }> - expect(agentResult.morpheus?.permission?.todowrite).toBeUndefined() - expect(agentResult.morpheus?.permission?.todoread).toBeUndefined() - expect(agentResult.keymaker?.permission?.todowrite).toBeUndefined() - expect(agentResult.keymaker?.permission?.todoread).toBeUndefined() - }) - - test("does not deny todowrite/todoread when task_system is undefined", async () => { - //#given - const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { - mockResolvedValue: (value: Record) => void - } - createBuiltinAgentsMock.mockResolvedValue({ - morpheus: { name: "morpheus", prompt: "test", mode: "primary" }, - }) - - const pluginConfig: MatrixxConfig = {} - const config: Record = { - model: "anthropic/claude-opus-4-6", - agent: {}, - } - const handler = createConfigHandler({ - ctx: { directory: "/tmp" }, - pluginConfig, - modelCacheState: { - anthropicContext1MEnabled: false, - modelContextLimitsCache: new Map(), - }, - }) - - //#when - await handler(config) - - //#then - const agentResult = config.agent as Record }> - expect(agentResult.morpheus?.permission?.todowrite).toBeUndefined() - expect(agentResult.morpheus?.permission?.todoread).toBeUndefined() - }) -}) diff --git a/tests/plugin/tool-execute-before.parallel.test.ts b/tests/plugin/tool-execute-before.parallel.test.ts index f80abb64ddc..9f4eb7cfc10 100644 --- a/tests/plugin/tool-execute-before.parallel.test.ts +++ b/tests/plugin/tool-execute-before.parallel.test.ts @@ -12,7 +12,7 @@ * every invocation. This proves the refactor did not accidentally * short-circuit any wave (e.g., an early return that skips Wave 3). * - * 3. prometheusMdOnly dual-invocation: since this hook runs in BOTH + * 3. oracleMdOnly dual-invocation: since this hook runs in BOTH * Wave 2 (BLOCKING) and Wave 3 (MUTATOR), it must be called exactly * TWICE per invocation (200 total across 100 invocations). * @@ -45,7 +45,7 @@ const FAST_FAIL_HOOK_NAMES = [ "envFileWriteGuard", "writeExistingFileGuard", "tasksTodowriteDisabler", - "prometheusMdOnly", + "oracleMdOnly", // Wave 3: MUTATOR "nonInteractiveEnv", "bashFileReadGuard", @@ -304,10 +304,10 @@ describe("tool.execute.before — T1.1 parallel safety", () => { expect(spies[name]?.mock.calls.length).toBe(N) } - //#then — 3: prometheusMdOnly dual-invocation (Wave 2 + Wave 3) + //#then — 3: oracleMdOnly dual-invocation (Wave 2 + Wave 3) // It must be called exactly 2N times across all invocations. - expect(spies.prometheusMdOnly).toBeDefined() - expect(spies.prometheusMdOnly?.mock.calls.length).toBe(N * 2) + expect(spies.oracleMdOnly).toBeDefined() + expect(spies.oracleMdOnly?.mock.calls.length).toBe(N * 2) }, 10_000, ) diff --git a/tests/plugin/tool-execute-before.wave3-ordering.test.ts b/tests/plugin/tool-execute-before.wave3-ordering.test.ts index e2f622f259d..63b88464855 100644 --- a/tests/plugin/tool-execute-before.wave3-ordering.test.ts +++ b/tests/plugin/tool-execute-before.wave3-ordering.test.ts @@ -57,7 +57,7 @@ describe("Wave 3 hook ordering", () => { nonInteractiveEnv: { "tool.execute.before": async () => { callOrder.push("nonInteractiveEnv") } }, bashFileReadGuard: { "tool.execute.before": async () => { callOrder.push("bashFileReadGuard") } }, questionLabelTruncator: { "tool.execute.before": async () => { callOrder.push("questionLabelTruncator") } }, - prometheusMdOnly: { "tool.execute.before": async () => { callOrder.push("prometheusMdOnly") } }, + oracleMdOnly: { "tool.execute.before": async () => { callOrder.push("oracleMdOnly") } }, mouseNotepad: { "tool.execute.before": async () => { callOrder.push("mouseNotepad") } }, architectHook: { "tool.execute.before": async () => { callOrder.push("architectHook") } }, } @@ -72,15 +72,15 @@ describe("Wave 3 hook ordering", () => { //#then // Verify all 7 Wave 3 hooks executed in correct order - // prometheusMdOnly runs TWICE: once in Wave 2 (BLOCKING) before all Wave 3 hooks, + // oracleMdOnly runs TWICE: once in Wave 2 (BLOCKING) before all Wave 3 hooks, // and once in Wave 3e (MUTATOR) after questionLabelTruncator expect(callOrder).toEqual([ - "prometheusMdOnly", // Wave 2 (BLOCKING) + "oracleMdOnly", // Wave 2 (BLOCKING) "rtkBashRewriter", // Wave 3a "nonInteractiveEnv", // Wave 3b "bashFileReadGuard", // Wave 3c "questionLabelTruncator", // Wave 3d - "prometheusMdOnly", // Wave 3e (MUTATOR) + "oracleMdOnly", // Wave 3e (MUTATOR) "mouseNotepad", // Wave 3f "architectHook", // Wave 3g ]) diff --git a/tests/shared/opencode-message-dir.test.ts b/tests/shared/opencode-message-dir.test.ts index 070509381dc..f760e02c83e 100644 --- a/tests/shared/opencode-message-dir.test.ts +++ b/tests/shared/opencode-message-dir.test.ts @@ -19,6 +19,10 @@ mock.module("../../src/shared/opencode-storage-detection", () => ({ resetSqliteBackendCache: () => {}, })) +afterAll(() => { + mock.restore() +}) + const { getMessageDir } = await import("../../src/shared/opencode-message-dir") describe("getMessageDir", () => { diff --git a/tests/test-setup.ts b/tests/test-setup.ts index 8c8bfe67b44..f222c2697e5 100644 --- a/tests/test-setup.ts +++ b/tests/test-setup.ts @@ -1,6 +1,26 @@ import { beforeEach } from "bun:test" import { _resetForTesting } from "../src/features/session-state/state" +import { _resetAssemblyStateForTesting } from "../src/features/assembly-state/manager" +import { _resetPruneThrottleForTesting } from "../src/features/background-agent/manager" +import { _resetMessageDirCacheForTesting } from "../src/features/background-agent/message-dir" +import { _resetTaskToastManagerForTesting } from "../src/features/task-toast-manager/manager" +import { _resetUltraworkStateForTesting } from "../src/features/ultrawork-state/manager" +import { _resetRuleCacheForTesting } from "../src/hooks/rules-injector/injector" +import { _resetThinkModeStateForTesting } from "../src/hooks/think-mode/hook" +import { _resetNormalizeModelCacheForTesting } from "../src/hooks/think-mode/switcher" +import { _resetDisabledSetsCacheForTesting } from "../src/plugin-config" +import { _resetMessagesTransformCacheForTesting } from "../src/plugin/messages-transform" beforeEach(() => { _resetForTesting() + _resetAssemblyStateForTesting() + _resetPruneThrottleForTesting() + _resetMessageDirCacheForTesting() + _resetTaskToastManagerForTesting() + _resetUltraworkStateForTesting() + _resetRuleCacheForTesting() + _resetThinkModeStateForTesting() + _resetNormalizeModelCacheForTesting() + _resetDisabledSetsCacheForTesting() + _resetMessagesTransformCacheForTesting() }) diff --git a/tests/tools/bdd-create-contract/tools.test.ts b/tests/tools/bdd-create-contract/tools.test.ts index dbe261cd6cc..1901646896a 100644 --- a/tests/tools/bdd-create-contract/tools.test.ts +++ b/tests/tools/bdd-create-contract/tools.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, describe, expect, it, mock } from "bun:test" +import { afterAll, beforeAll, afterEach, describe, expect, it, mock, spyOn } from "bun:test" import crypto from "node:crypto" import * as fs from "node:fs" import { generateMessages } from "@cucumber/gherkin" @@ -80,19 +80,22 @@ const RULES_FEATURE = `Feature: Pagination const existingFiles = new Set() const writtenFiles = new Map() -const _originalFs = { ...fs } +describe("bdd_create_contract tool", () => { + beforeAll(() => { + spyOn(fs, "existsSync").mockImplementation((p: string) => existingFiles.has(p)) + spyOn(fs, "writeFileSync").mockImplementation((p: string, data: string) => { + writtenFiles.set(p, data) + }) + }) -mock.module("node:fs", () => ({ - ..._originalFs, - existsSync: (p: string) => existingFiles.has(p), - writeFileSync: (p: string, data: string) => { - writtenFiles.set(p, data) - }, -})) + afterAll(() => { + mock.restore() + }) -afterAll(() => { - mock.restore() -}) + afterEach(() => { + existingFiles.clear() + writtenFiles.clear() + }) const mockContext: ToolContext = { sessionID: "s", @@ -109,11 +112,6 @@ const mockContext: ToolContext = { // Tests // --------------------------------------------------------------------------- -describe("bdd_create_contract tool", () => { - afterEach(() => { - existingFiles.clear() - writtenFiles.clear() - }) it("produces valid Contract JSON from valid AST and annotations", async () => { // given diff --git a/tests/tools/bdd-pipeline/pipeline-runner.test.ts b/tests/tools/bdd-pipeline/pipeline-runner.test.ts index 8bcdd227e8c..a06ca4973d8 100644 --- a/tests/tools/bdd-pipeline/pipeline-runner.test.ts +++ b/tests/tools/bdd-pipeline/pipeline-runner.test.ts @@ -1,11 +1,13 @@ import { afterAll, afterEach, + beforeAll, beforeEach, describe, expect, it, mock, + spyOn, } from "bun:test" import * as fs from "node:fs" import type { BddPipelineArgs } from "../../../src/tools/bdd-pipeline/types" @@ -14,24 +16,23 @@ const callLog: string[] = [] const writtenFiles = new Map() const existingFiles = new Set() -const _originalFs = { ...fs } -mock.module("node:fs", () => ({ - ..._originalFs, - existsSync: (p: string) => existingFiles.has(p), - writeFileSync: (p: string, data: string) => { - writtenFiles.set(p, data) - callLog.push(`write:${p}`) - }, - readFileSync: (p: string) => { - const v = writtenFiles.get(p) ?? "" - callLog.push(`read:${p}`) - return v - }, -})) - -afterAll(() => { - mock.restore() -}) +describe("bdd-pipeline runner", () => { + beforeAll(() => { + spyOn(fs, "existsSync").mockImplementation((p: string) => existingFiles.has(p)) + spyOn(fs, "writeFileSync").mockImplementation((p: string, data: string) => { + writtenFiles.set(p, data) + callLog.push(`write:${p}`) + }) + spyOn(fs, "readFileSync").mockImplementation((p: string) => { + const v = writtenFiles.get(p) ?? "" + callLog.push(`read:${p}`) + return v + }) + }) + + afterAll(() => { + mock.restore() + }) // --------------------------------------------------------------------------- // Fixture factories @@ -179,7 +180,6 @@ function buildDeps(recorder: DepsRecorder, overrides: Partial<{ // Tests // --------------------------------------------------------------------------- -describe("bdd-pipeline runner", () => { let recorder: DepsRecorder let runBddPipeline: typeof import("../../../src/tools/bdd-pipeline/pipeline-runner").runBddPipeline diff --git a/tests/tools/dcp-switch-profile/tools.test.ts b/tests/tools/dcp-switch-profile/tools.test.ts new file mode 100644 index 00000000000..f43e8c6ebb2 --- /dev/null +++ b/tests/tools/dcp-switch-profile/tools.test.ts @@ -0,0 +1,504 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" +import type { ToolContext } from "@opencode-ai/plugin/tool" +import { BUILTIN_DCP_PROFILES, DcpConfigSchema } from "../../../src/config/schema/dcp" + +// --------------------------------------------------------------------------- +// Mock fs BEFORE importing the module under test +// --------------------------------------------------------------------------- + +let capturedWriteData: string | null = null +const mockExistsSync = mock((_path: string) => true) +const mockWriteFileSync = mock((_path: string, data: string) => { + capturedWriteData = data +}) + +mock.module("node:fs", () => ({ + existsSync: mockExistsSync, + writeFileSync: mockWriteFileSync, +})) + +import { createDcpSwitchProfileTool } from "../../../src/tools/dcp-switch-profile/tools" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const mockContext: ToolContext = { sessionID: "test-session" } as ToolContext + +/** Capture argument from the last writeFileSync call and parse as JSON. */ +function extractWrittenConfig(): Record | null { + if (capturedWriteData === null) return null + return JSON.parse(capturedWriteData) +} + +type AnyRecord = Record + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +afterAll(() => { + mock.restore() +}) + +describe("dcp_switch_profile tool", () => { + beforeEach(() => { + capturedWriteData = null + mockExistsSync.mockImplementation(() => true) + }) + + // ── Factory ───────────────────────────────────────────────────────── + + describe("factory", () => { + test("creates tool with no options", () => { + const tools = createDcpSwitchProfileTool() + expect(tools).toHaveProperty("dcp_switch_profile") + }) + + test("creates tool with empty pluginConfig", () => { + const tools = createDcpSwitchProfileTool({ pluginConfig: {} }) + expect(tools).toHaveProperty("dcp_switch_profile") + }) + + test("creates tool with full DcpConfig", () => { + const dcp = DcpConfigSchema.parse({}) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + expect(tools).toHaveProperty("dcp_switch_profile") + }) + }) + + // ── Metadata ──────────────────────────────────────────────────────── + + describe("metadata", () => { + test("description mentions DCP and profile switching", () => { + const tools = createDcpSwitchProfileTool() + const desc = tools.dcp_switch_profile.description + expect(desc.toLowerCase()).toContain("dcp") + expect(desc.toLowerCase()).toContain("profile") + }) + + test("profile argument restricts to valid values", () => { + const tools = createDcpSwitchProfileTool() + const args = tools.dcp_switch_profile.args as AnyRecord + expect(args).toHaveProperty("profile") + }) + }) + + // ── Built-in profile values (no pluginConfig — exercises fallback) ── + + describe("built-in economy profile values", () => { + const profile = "economy" + const expected = BUILTIN_DCP_PROFILES.economy + + test("produces valid JSON that can be parsed", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + expect(capturedWriteData).not.toBeNull() + const config = extractWrittenConfig() + expect(config).not.toBeNull() + }) + + test("compress.maxContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.maxContextLimit).toBe(expected.compress.maxContextLimit) + }) + + test("compress.minContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.minContextLimit).toBe(expected.compress.minContextLimit) + }) + + test("compress.nudgeFrequency", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.nudgeFrequency).toBe(expected.compress.nudgeFrequency) + }) + + test("pruneNotification is off", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + expect(extractWrittenConfig()!.pruneNotification).toBe("off") + }) + + test("turnProtection is disabled", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const tp = extractWrittenConfig()!.turnProtection as AnyRecord + expect(tp.enabled).toBe(false) + }) + + test("experimental.allowSubAgents is false for economy", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const exp = extractWrittenConfig()!.experimental as AnyRecord + expect(exp.allowSubAgents).toBe(false) + }) + }) + + describe("built-in balanced profile values", () => { + const profile = "balanced" + const expected = BUILTIN_DCP_PROFILES.balanced + + test("compress.maxContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.maxContextLimit).toBe(expected.compress.maxContextLimit) + }) + + test("compress.minContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.minContextLimit).toBe(expected.compress.minContextLimit) + }) + + test("compress.nudgeFrequency", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.nudgeFrequency).toBe(expected.compress.nudgeFrequency) + }) + + test("turnProtection has enabled: true and correct turns", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const tp = extractWrittenConfig()!.turnProtection as AnyRecord + expect(tp.enabled).toBe(true) + expect(tp.turns).toBe(2) + }) + }) + + describe("built-in performance profile values", () => { + const profile = "performance" + const expected = BUILTIN_DCP_PROFILES.performance + + test("compress.maxContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.maxContextLimit).toBe(expected.compress.maxContextLimit) + }) + + test("compress.minContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.minContextLimit).toBe(expected.compress.minContextLimit) + }) + + test("compress.nudgeFrequency", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.nudgeFrequency).toBe(expected.compress.nudgeFrequency) + }) + }) + + describe("built-in ultimate profile values", () => { + const profile = "ultimate" + const expected = BUILTIN_DCP_PROFILES.ultimate + + test("compress.maxContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.maxContextLimit).toBe(expected.compress.maxContextLimit) + }) + + test("compress.minContextLimit", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.minContextLimit).toBe(expected.compress.minContextLimit) + }) + + test("compress.nudgeFrequency", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.nudgeFrequency).toBe(expected.compress.nudgeFrequency) + }) + + test("compress.protectTags is true", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.protectTags).toBe(true) + }) + + test("pruneNotification is detailed", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile }, mockContext) + expect(extractWrittenConfig()!.pruneNotification).toBe("detailed") + }) + }) + + // ── Output structure (applies to all profiles) ────────────────────── + + describe("output structure", () => { + test("contains $schema field", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const config = extractWrittenConfig()! + expect(config).toHaveProperty("$schema") + expect(typeof config.$schema).toBe("string") + }) + + test("enabled is true", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + expect(extractWrittenConfig()!.enabled).toBe(true) + }) + + test("does NOT contain extend key", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + expect(extractWrittenConfig()!).not.toHaveProperty("extend") + }) + + test("compress section contains all required fields", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + const required = [ + "mode", + "permission", + "showCompression", + "summaryBuffer", + "maxContextLimit", + "minContextLimit", + "nudgeFrequency", + "iterationNudgeThreshold", + "nudgeForce", + "protectedTools", + "protectTags", + "protectUserMessages", + ] + for (const key of required) { + expect(c).toHaveProperty(key) + } + }) + + test("strategies section contains deduplication and purgeErrors", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const s = extractWrittenConfig()!.strategies as AnyRecord + expect(s).toHaveProperty("deduplication") + expect(s).toHaveProperty("purgeErrors") + expect((s.deduplication as AnyRecord).enabled).toBe(true) + expect((s.purgeErrors as AnyRecord).enabled).toBe(true) + }) + + test("commands section is present and enabled by default", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const cmds = extractWrittenConfig()!.commands as AnyRecord + expect(cmds.enabled).toBe(true) + }) + + test("manualMode section is present and disabled by default", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const mm = extractWrittenConfig()!.manualMode as AnyRecord + expect(mm.enabled).toBe(false) + }) + + test("turnProtection section is present", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const tp = extractWrittenConfig()!.turnProtection as AnyRecord + expect(tp).toHaveProperty("enabled") + expect(tp).toHaveProperty("turns") + }) + + test("experimental section is present", async () => { + const tools = createDcpSwitchProfileTool() + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const exp = extractWrittenConfig()!.experimental as AnyRecord + expect(exp).toHaveProperty("allowSubAgents") + expect(exp).toHaveProperty("customPrompts") + }) + }) + + // ── Base config overrides ─────────────────────────────────────────── + + describe("base config overrides", () => { + test("base.debug is applied to output when set", async () => { + const dcp = DcpConfigSchema.parse({ base: { debug: true } }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + expect(extractWrittenConfig()!.debug).toBe(true) + }) + + test("base.pruneNotificationType is applied", async () => { + const dcp = DcpConfigSchema.parse({ base: { pruneNotificationType: "toast" } }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + expect(extractWrittenConfig()!.pruneNotificationType).toBe("toast") + }) + + test("base.autoUpdate is applied when true", async () => { + const dcp = DcpConfigSchema.parse({ base: { autoUpdate: true } }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + expect(extractWrittenConfig()!.autoUpdate).toBe(true) + }) + + test("base.protectedFilePatterns flows through", async () => { + const dcp = DcpConfigSchema.parse({ base: { protectedFilePatterns: ["*.secret", "*.key"] } }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + expect(extractWrittenConfig()!.protectedFilePatterns).toEqual(["*.secret", "*.key"]) + }) + + test("base.compress.mode overrides default", async () => { + const dcp = DcpConfigSchema.parse({ base: { compress: { mode: "message" } } }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.mode).toBe("message") + }) + + test("base.commands.enabled can be disabled", async () => { + const dcp = DcpConfigSchema.parse({ base: { commands: { enabled: false } } }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const cmds = extractWrittenConfig()!.commands as AnyRecord + expect(cmds.enabled).toBe(false) + }) + + test("base manualMode shares base with turnProtection disabled", async () => { + const dcp = DcpConfigSchema.parse({ + base: { + manualMode: { enabled: true, automaticStrategies: false }, + }, + }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const mm = extractWrittenConfig()!.manualMode as AnyRecord + expect(mm.enabled).toBe(true) + expect(mm.automaticStrategies).toBe(false) + }) + }) + + // ── Profile override values ───────────────────────────────────────── + + describe("profile overrides override built-in defaults", () => { + test("custom economy profile overrides compress values", async () => { + const dcp = DcpConfigSchema.parse({ + profiles: { + economy: { + compress: { + maxContextLimit: "50%", + minContextLimit: "25%", + nudgeFrequency: 5, + }, + }, + }, + }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "economy" }, mockContext) + const c = extractWrittenConfig()!.compress as AnyRecord + expect(c.maxContextLimit).toBe("50%") + expect(c.minContextLimit).toBe("25%") + expect(c.nudgeFrequency).toBe(5) + }) + + test("custom profile overrides turnProtection", async () => { + const dcp = DcpConfigSchema.parse({ + profiles: { + balanced: { + turnProtection: { enabled: false, turns: 1 }, + }, + }, + }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + const tp = extractWrittenConfig()!.turnProtection as AnyRecord + expect(tp.enabled).toBe(false) + expect(tp.turns).toBe(1) + }) + + test("custom profile overrides pruneNotification", async () => { + const dcp = DcpConfigSchema.parse({ + profiles: { + economy: { pruneNotification: "detailed" }, + }, + }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "economy" }, mockContext) + expect(extractWrittenConfig()!.pruneNotification).toBe("detailed") + }) + + test("custom profile has no effect on other profiles", async () => { + const dcp = DcpConfigSchema.parse({ + profiles: { + economy: { pruneNotification: "off" }, + }, + }) + const tools = createDcpSwitchProfileTool({ pluginConfig: { dcp } }) + await tools.dcp_switch_profile.execute({ profile: "balanced" }, mockContext) + expect(extractWrittenConfig()!.pruneNotification).toBe("minimal") + }) + }) + + + // ── Error handling ────────────────────────────────────────────────── + + describe("error handling", () => { + test("invalid profile returns error message", async () => { + const tools = createDcpSwitchProfileTool() + const result = await tools.dcp_switch_profile.execute( + { profile: "invalid_profile_name" }, + mockContext, + ) + expect(result).toContain("Error") + expect(result).toContain("Invalid profile") + expect(result).toContain("invalid_profile_name") + expect(capturedWriteData).toBeNull() + }) + + test("invalid profile lists valid options", async () => { + const tools = createDcpSwitchProfileTool() + const result = await tools.dcp_switch_profile.execute( + { profile: "wrong" }, + mockContext, + ) + expect(result).toContain("economy") + expect(result).toContain("balanced") + expect(result).toContain("performance") + expect(result).toContain("ultimate") + }) + + test("DCP not installed returns appropriate error", async () => { + mockExistsSync.mockImplementation(() => false) + const tools = createDcpSwitchProfileTool() + const result = await tools.dcp_switch_profile.execute( + { profile: "balanced" }, + mockContext, + ) + expect(result).toContain("DCP is not installed") + expect(capturedWriteData).toBeNull() + }) + }) + + // ── Return value ──────────────────────────────────────────────────── + + describe("return value", () => { + test("success message contains profile name", async () => { + const tools = createDcpSwitchProfileTool() + const result = await tools.dcp_switch_profile.execute( + { profile: "ultimate" }, + mockContext, + ) + expect(result).toContain("ultimate") + expect(result).toContain("Restart OpenCode") + }) + }) +}) diff --git a/tests/tools/delegate-task/tools.test.ts b/tests/tools/delegate-task/tools.test.ts index e732aa06689..2759bd6535b 100644 --- a/tests/tools/delegate-task/tools.test.ts +++ b/tests/tools/delegate-task/tools.test.ts @@ -3,7 +3,6 @@ const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require(" import type { CategoryConfig } from "../../../src/config/schema" import type { LaunchInput } from "../../../src/features/background-agent/types" -import { clearSkillCache } from "../../../src/features/opencode-skill-loader/skill-content" import * as connectedProvidersCache from "../../../src/shared/connected-providers-cache" import { __resetModelCache } from "../../../src/shared/model-availability" import { CATEGORY_DESCRIPTIONS, CATEGORY_PROMPT_APPENDS, DEFAULT_CATEGORIES, isPlanAgent, isPlanFamily, PLAN_AGENT_NAMES, PLAN_FAMILY_NAMES } from "../../../src/tools/delegate-task/constants" @@ -55,7 +54,6 @@ describe("morpheus-task", () => { beforeEach(() => { mock.restore() __resetModelCache() - clearSkillCache() __setTimingConfig({ POLL_INTERVAL_MS: 10, MIN_STABILITY_TIME_MS: 50, diff --git a/tests/tools/lsp/client.test.ts b/tests/tools/lsp/client.test.ts index 9db2d36d627..747b4d129bd 100644 --- a/tests/tools/lsp/client.test.ts +++ b/tests/tools/lsp/client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -11,6 +11,10 @@ mock.module("vscode-jsonrpc/node", () => ({ StreamMessageWriter: function StreamMessageWriter() {}, })) +afterAll(() => { + mock.restore() +}) + import { LSPClient, lspManager, validateCwd } from "../../../src/tools/lsp/client" import type { ResolvedServer } from "../../../src/tools/lsp/types" diff --git a/tests/tools/session-manager/storage.test.ts b/tests/tools/session-manager/storage.test.ts index 5d36149c02b..30da1ae3144 100644 --- a/tests/tools/session-manager/storage.test.ts +++ b/tests/tools/session-manager/storage.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { randomUUID } from "node:crypto" import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -59,6 +59,10 @@ mock.module("../../../src/shared/opencode-message-dir", () => ({ return null }, })) + +afterAll(() => { + mock.restore() +}) const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("../../../src/tools/session-manager/storage") @@ -511,4 +515,8 @@ describe("session-manager storage - SDK path (beta mode)", () => { //#then should return empty array since no client and no JSON fallback expect(messages).toEqual([]) }) + + afterAll(() => { + mock.restore() + }) }) diff --git a/tests/tools/skill-mcp/tools.test.ts b/tests/tools/skill-mcp/tools.test.ts deleted file mode 100644 index efe450708dd..00000000000 --- a/tests/tools/skill-mcp/tools.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { beforeEach, describe, expect, it, } from "bun:test" -import type { ToolContext } from "@opencode-ai/plugin/tool" -import type { LoadedSkill } from "../../../src/features/opencode-skill-loader/types" -import { SkillMcpManager } from "../../../src/features/skill-mcp-manager" -import { applyGrepFilter, createSkillMcpTool } from "../../../src/tools/skill-mcp/tools" - -function createMockSkillWithMcp(name: string, mcpServers: Record): LoadedSkill { - return { - name, - path: `/test/skills/${name}/SKILL.md`, - resolvedPath: `/test/skills/${name}`, - definition: { - name, - description: `Test skill ${name}`, - template: "Test template", - }, - scope: "opencode-project", - mcpConfig: mcpServers as LoadedSkill["mcpConfig"], - } -} - -const mockContext: ToolContext = { - sessionID: "test-session", - messageID: "msg-1", - agent: "test-agent", - directory: "/test", - worktree: "/test", - abort: new AbortController().signal, - metadata: () => {}, - ask: async () => {}, -} - -describe("skill_mcp tool", () => { - let manager: SkillMcpManager - let loadedSkills: LoadedSkill[] - let sessionID: string - - beforeEach(() => { - manager = new SkillMcpManager() - loadedSkills = [] - sessionID = "test-session-1" - }) - - describe("parameter validation", () => { - it("throws when no operation specified", async () => { - // given - const tool = createSkillMcpTool({ - manager, - getLoadedSkills: () => loadedSkills, - getSessionID: () => sessionID, - }) - - // when / #then - await expect( - tool.execute({ mcp_name: "test-server" }, mockContext) - ).rejects.toThrow(/Missing operation/) - }) - - it("throws when multiple operations specified", async () => { - // given - const tool = createSkillMcpTool({ - manager, - getLoadedSkills: () => loadedSkills, - getSessionID: () => sessionID, - }) - - // when / #then - await expect( - tool.execute({ - mcp_name: "test-server", - tool_name: "some-tool", - resource_name: "some://resource", - }, mockContext) - ).rejects.toThrow(/Multiple operations/) - }) - - it("throws when mcp_name not found in any skill", async () => { - // given - loadedSkills = [ - createMockSkillWithMcp("test-skill", { - "known-server": { command: "echo", args: ["test"] }, - }), - ] - const tool = createSkillMcpTool({ - manager, - getLoadedSkills: () => loadedSkills, - getSessionID: () => sessionID, - }) - - // when / #then - await expect( - tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext) - ).rejects.toThrow(/not found/) - }) - - it("includes available MCP servers in error message", async () => { - // given - loadedSkills = [ - createMockSkillWithMcp("db-skill", { - sqlite: { command: "uvx", args: ["mcp-server-sqlite"] }, - }), - createMockSkillWithMcp("api-skill", { - "rest-api": { command: "node", args: ["server.js"] }, - }), - ] - const tool = createSkillMcpTool({ - manager, - getLoadedSkills: () => loadedSkills, - getSessionID: () => sessionID, - }) - - // when / #then - await expect( - tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext) - ).rejects.toThrow(/sqlite.*db-skill|rest-api.*api-skill/s) - }) - - it("throws on invalid JSON arguments", async () => { - // given - loadedSkills = [ - createMockSkillWithMcp("test-skill", { - "test-server": { command: "echo" }, - }), - ] - const tool = createSkillMcpTool({ - manager, - getLoadedSkills: () => loadedSkills, - getSessionID: () => sessionID, - }) - - // when / #then - await expect( - tool.execute({ - mcp_name: "test-server", - tool_name: "some-tool", - arguments: "not valid json", - }, mockContext) - ).rejects.toThrow(/Invalid arguments JSON/) - }) - }) - - describe("tool description", () => { - it("has concise description", () => { - // given / #when - const tool = createSkillMcpTool({ - manager, - getLoadedSkills: () => [], - getSessionID: () => "session", - }) - - // then - expect(tool.description.length).toBeLessThan(200) - expect(tool.description).toContain("mcp_name") - }) - - it("includes grep parameter in schema", () => { - // given / #when - const tool = createSkillMcpTool({ - manager, - getLoadedSkills: () => [], - getSessionID: () => "session", - }) - - // then - expect(tool.description).toBeDefined() - }) - }) -}) - -describe("applyGrepFilter", () => { - it("filters lines matching pattern", () => { - // given - const output = `line1: hello world -line2: foo bar -line3: hello again -line4: baz qux` - - // when - const result = applyGrepFilter(output, "hello") - - // then - expect(result).toContain("line1: hello world") - expect(result).toContain("line3: hello again") - expect(result).not.toContain("foo bar") - expect(result).not.toContain("baz qux") - }) - - it("returns original output when pattern is undefined", () => { - // given - const output = "some output" - - // when - const result = applyGrepFilter(output, undefined) - - // then - expect(result).toBe(output) - }) - - it("returns message when no lines match", () => { - // given - const output = "line1\nline2\nline3" - - // when - const result = applyGrepFilter(output, "xyz") - - // then - expect(result).toContain("[grep] No lines matched pattern") - }) - - it("handles invalid regex gracefully", () => { - // given - const output = "some output" - - // when - const result = applyGrepFilter(output, "[invalid") - - // then - expect(result).toBe(output) - }) -}) diff --git a/tests/tools/skill/tools.test.ts b/tests/tools/skill/tools.test.ts index d5acdf67cf8..7c5c57d165e 100644 --- a/tests/tools/skill/tools.test.ts +++ b/tests/tools/skill/tools.test.ts @@ -1,9 +1,7 @@ -import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" +import { afterAll, describe, expect, it, mock } from "bun:test" import * as fs from "node:fs" -import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js" import type { ToolContext } from "@opencode-ai/plugin/tool" import type { LoadedSkill } from "../../../src/features/opencode-skill-loader/types" -import { SkillMcpManager } from "../../../src/features/skill-mcp-manager" import { createSkillTool } from "../../../src/tools/skill/tools" const originalReadFileSync = fs.readFileSync.bind(fs) @@ -40,20 +38,6 @@ function createMockSkill(name: string, options: { agent?: string } = {}): Loaded } } -function createMockSkillWithMcp(name: string, mcpServers: Record): LoadedSkill { - return { - name, - path: `/test/skills/${name}/SKILL.md`, - resolvedPath: `/test/skills/${name}`, - definition: { - name, - description: `Test skill ${name}`, - template: "Test template", - }, - scope: "opencode-project", - mcpConfig: mcpServers as LoadedSkill["mcpConfig"], - } -} const mockContext: ToolContext = { sessionID: "test-session", @@ -105,251 +89,3 @@ describe("skill tool - synchronous description", () => { }) }) -describe("skill tool - agent restriction", () => { - it("allows skill without agent restriction to any agent", async () => { - // given - const loadedSkills = [createMockSkill("public-skill")] - const tool = createSkillTool({ skills: loadedSkills }) - const context = { ...mockContext, agent: "any-agent" } - - // when - const result = await tool.execute({ name: "public-skill" }, context) - - // then - expect(result).toContain("public-skill") - }) - - it("allows skill when agent matches restriction", async () => { - // given - const loadedSkills = [createMockSkill("restricted-skill", { agent: "morpheus" })] - const tool = createSkillTool({ skills: loadedSkills }) - const context = { ...mockContext, agent: "morpheus" } - - // when - const result = await tool.execute({ name: "restricted-skill" }, context) - - // then - expect(result).toContain("restricted-skill") - }) - - it("throws error when agent does not match restriction", async () => { - // given - const loadedSkills = [createMockSkill("morpheus-only-skill", { agent: "morpheus" })] - const tool = createSkillTool({ skills: loadedSkills }) - const context = { ...mockContext, agent: "oracle" } - - // when / #then - await expect(tool.execute({ name: "morpheus-only-skill" }, context)).rejects.toThrow( - 'Skill "morpheus-only-skill" is restricted to agent "morpheus"' - ) - }) - - it("throws error when context agent is undefined for restricted skill", async () => { - // given - const loadedSkills = [createMockSkill("morpheus-only-skill", { agent: "morpheus" })] - const tool = createSkillTool({ skills: loadedSkills }) - const contextWithoutAgent = { ...mockContext, agent: undefined as unknown as string } - - // when / #then - await expect(tool.execute({ name: "morpheus-only-skill" }, contextWithoutAgent)).rejects.toThrow( - 'Skill "morpheus-only-skill" is restricted to agent "morpheus"' - ) - }) - -}) - -describe("skill tool - MCP schema display", () => { - let manager: SkillMcpManager - let loadedSkills: LoadedSkill[] - let sessionID: string - - beforeEach(() => { - manager = new SkillMcpManager() - loadedSkills = [] - sessionID = "test-session-1" - }) - - describe("formatMcpCapabilities with inputSchema", () => { - it("displays tool inputSchema when available", async () => { - // given - const mockToolsWithSchema: McpTool[] = [ - { - name: "browser_type", - description: "Type text into an element", - inputSchema: { - type: "object", - properties: { - element: { type: "string", description: "Human-readable element description" }, - ref: { type: "string", description: "Element reference from page snapshot" }, - text: { type: "string", description: "Text to type into the element" }, - submit: { type: "boolean", description: "Submit form after typing" }, - }, - required: ["element", "ref", "text"], - }, - }, - ] - - loadedSkills = [ - createMockSkillWithMcp("test-skill", { - playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] }, - }), - ] - - // Mock manager.listTools to return our mock tools - spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema) - spyOn(manager, "listResources").mockResolvedValue([]) - spyOn(manager, "listPrompts").mockResolvedValue([]) - - const tool = createSkillTool({ - skills: loadedSkills, - mcpManager: manager, - getSessionID: () => sessionID, - }) - - // when - const result = await tool.execute({ name: "test-skill" }, mockContext) - - // then - // Should include inputSchema details - expect(result).toContain("browser_type") - expect(result).toContain("inputSchema") - expect(result).toContain("element") - expect(result).toContain("ref") - expect(result).toContain("text") - expect(result).toContain("submit") - expect(result).toContain("required") - }) - - it("displays multiple tools with their schemas", async () => { - // given - const mockToolsWithSchema: McpTool[] = [ - { - name: "browser_navigate", - description: "Navigate to a URL", - inputSchema: { - type: "object", - properties: { - url: { type: "string", description: "URL to navigate to" }, - }, - required: ["url"], - }, - }, - { - name: "browser_click", - description: "Click an element", - inputSchema: { - type: "object", - properties: { - element: { type: "string" }, - ref: { type: "string" }, - }, - required: ["element", "ref"], - }, - }, - ] - - loadedSkills = [ - createMockSkillWithMcp("playwright-skill", { - playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] }, - }), - ] - - spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema) - spyOn(manager, "listResources").mockResolvedValue([]) - spyOn(manager, "listPrompts").mockResolvedValue([]) - - const tool = createSkillTool({ - skills: loadedSkills, - mcpManager: manager, - getSessionID: () => sessionID, - }) - - // when - const result = await tool.execute({ name: "playwright-skill" }, mockContext) - - // then - expect(result).toContain("browser_navigate") - expect(result).toContain("browser_click") - expect(result).toContain("url") - expect(result).toContain("Navigate to a URL") - }) - - it("handles tools without inputSchema gracefully", async () => { - // given - const mockToolsMinimal: McpTool[] = [ - { - name: "simple_tool", - inputSchema: { type: "object" }, - }, - ] - - loadedSkills = [ - createMockSkillWithMcp("simple-skill", { - simple: { command: "echo", args: ["test"] }, - }), - ] - - spyOn(manager, "listTools").mockResolvedValue(mockToolsMinimal) - spyOn(manager, "listResources").mockResolvedValue([]) - spyOn(manager, "listPrompts").mockResolvedValue([]) - - const tool = createSkillTool({ - skills: loadedSkills, - mcpManager: manager, - getSessionID: () => sessionID, - }) - - // when - const result = await tool.execute({ name: "simple-skill" }, mockContext) - - // then - expect(result).toContain("simple_tool") - // Should not throw, should handle gracefully - }) - - it("formats schema in a way LLM can understand for skill_mcp calls", async () => { - // given - const mockTools: McpTool[] = [ - { - name: "query", - description: "Execute SQL query", - inputSchema: { - type: "object", - properties: { - sql: { type: "string", description: "SQL query to execute" }, - params: { type: "array", description: "Query parameters" }, - }, - required: ["sql"], - }, - }, - ] - - loadedSkills = [ - createMockSkillWithMcp("db-skill", { - sqlite: { command: "uvx", args: ["mcp-server-sqlite"] }, - }), - ] - - spyOn(manager, "listTools").mockResolvedValue(mockTools) - spyOn(manager, "listResources").mockResolvedValue([]) - spyOn(manager, "listPrompts").mockResolvedValue([]) - - const tool = createSkillTool({ - skills: loadedSkills, - mcpManager: manager, - getSessionID: () => sessionID, - }) - - // when - const result = await tool.execute({ name: "db-skill" }, mockContext) - - // then - // Should provide enough info for LLM to construct valid skill_mcp call - expect(result).toContain("sqlite") - expect(result).toContain("query") - expect(result).toContain("sql") - expect(result).toContain("required") - expect(result).toMatch(/sql[\s\S]*string/i) - }) - }) -})