Compare commits

..

174 Commits

Author SHA1 Message Date
github-actions[bot]
e3040ecb28 release: v2.8.2 2025-12-30 13:42:51 +00:00
sisyphus-dev-ai
066ab4b303 chore: changes by sisyphus-dev-ai 2025-12-30 13:34:59 +00:00
YeonGyu-Kim
bceeba8ca9 feat(hooks): enable tool-output-truncator by default
Enable tool-output-truncator hook by default instead of requiring experimental config opt-in. Users can disable it via disabled_hooks if needed.

Changes:
- Add tool-output-truncator to HookNameSchema
- Remove tool_output_truncator from ExperimentalConfigSchema
- Update all README files (EN, KO, JA, ZH-CN)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-30 22:23:20 +09:00
Sisyphus
d8f10f53d4 fix(windows): resolve paths[0] TypeError crash on Windows startup (#351)
- Fix comment-checker/downloader.ts to use Windows-appropriate cache paths (%LOCALAPPDATA% or %APPDATA%) instead of Unix-style ~/.cache
- Guard against undefined import.meta.url in cli.ts which can occur during Windows plugin loading
- Reorder cache check before module resolution for safer fallback behavior

Fixes #347

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-30 22:14:04 +09:00
Sisyphus
45076041af fix: improve installation error handling with actionable suggestions (#343)
- Add error classification helpers for permission, file-not-found, and filesystem errors
- Handle empty/corrupt config files gracefully with recovery suggestions
- Add 60-second timeout to runBunInstall() to prevent hanging forever
- Improve error messages with specific recovery suggestions for each error type
- Export BunInstallResult type with detailed error info (success, timedOut, error)
- Handle SyntaxError in JSON parsing with user-friendly suggestions
- Add validation for config file contents (empty, whitespace-only, non-object)

Fixes #338

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-30 22:04:56 +09:00
Sohye Choi
bcf1d02f13 chore: remove unused empty file (#349) 2025-12-30 22:04:14 +09:00
Sisyphus
a63f76107b fix: add --external @ast-grep/napi to CLI build command (#350)
The CLI build was missing --external @ast-grep/napi flag, causing the bundler
to inline absolute paths from the CI environment (/home/runner/work/...).
This made the doctor check for @ast-grep/napi always fail on user machines.

Fixes #344

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-30 22:03:30 +09:00
github-actions[bot]
7b57364aa2 @purelledhand has signed the CLA in code-yeongyu/oh-my-opencode#349 2025-12-30 12:05:09 +00:00
github-actions[bot]
37c92b86e6 release: v2.8.1 2025-12-30 11:45:42 +00:00
Sisyphus
058e6adf96 revert(truncation-compaction): rollback to experimental opt-in config (#348) 2025-12-30 20:42:06 +09:00
Sisyphus
355f18d411 revert(dcp-for-compaction): move back to experimental config from hook (#346) 2025-12-30 20:27:19 +09:00
github-actions[bot]
048ed36120 release: v2.8.0 2025-12-30 10:12:40 +00:00
YeonGyu-Kim
ec61350664 refactor(dcp-for-compaction): migrate from experimental config to hook system
- Add 'dcp-for-compaction' to HookNameSchema
- Remove dcp_for_compaction from ExperimentalConfigSchema
- Update executor.ts to use dcpForCompaction parameter
- Enable DCP by default (can be disabled via disabled_hooks)
- Update all 4 README files (EN, KO, JA, ZH-CN)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-30 19:09:16 +09:00
sisyphus-dev-ai
61251737d4 chore: changes by sisyphus-dev-ai 2025-12-30 09:29:24 +00:00
github-actions[bot]
c11aa598d7 @lgandecki has signed the CLA in code-yeongyu/oh-my-opencode#341 2025-12-30 09:10:56 +00:00
Marcus R. Brown
5138c50a6a fix(think-mode): support GitHub Copilot proxy provider (#336)
* fix(think-mode): support GitHub Copilot proxy provider

### Summary
- Adds `github-copilot` support to think-mode by resolving the underlying provider from the model name (Claude → Anthropic, Gemini → Google, GPT/o* → OpenAI).
- Normalizes model IDs to handle dotted versions defensively (e.g. `claude-opus-4.5` → `claude-opus-4-5`, `gpt-5.2` → `gpt-5-2`) so high-variant upgrades and capability checks work reliably.
- Expands high-variant mappings to cover Gemini preview/flash variants and aligns GPT-5.1/5.2 mappings with normalized IDs.
- Adds OpenAI “thinking mode” config (`reasoning_effort: "high"`) alongside existing provider configs.

### Tests
- Adds unit coverage for the switcher (`switcher.test.ts`) and integration coverage for the hook (`index.test.ts`), including:
  - GitHub Copilot model routing + thinking config injection
  - Dots vs hyphens normalization
  - Already-`-high` variants not being re-upgraded
  - Unknown models/providers handled gracefully

* fix: support multiple digits in model minor
2025-12-30 17:46:16 +09:00
YeonGyu-Kim
0f0f49b823 feat: add Ralph Loop self-referential development loop (#337)
* feat(config): add RalphLoopConfigSchema and hook name

- Add ralph-loop to HookNameSchema enum
- Add RalphLoopConfigSchema with enabled, default_max_iterations, state_dir
- Add ralph_loop field to OhMyOpenCodeConfigSchema
- Export RalphLoopConfig type

* feat(ralph-loop): add hook directory structure with constants and types

- Add constants.ts with HOOK_NAME, DEFAULT_STATE_FILE, COMPLETION_TAG_PATTERN
- Add types.ts with RalphLoopState and RalphLoopOptions interfaces
- Export RalphLoopConfig from config/index.ts

* feat(ralph-loop): add storage module for markdown state file management

- Implement readState/writeState/clearState/incrementIteration
- Use YAML frontmatter format for state persistence
- Support custom state file paths via config

* feat(ralph-loop): implement main hook with session.idle handler

- Add createRalphLoopHook factory with event handler
- Implement startLoop, cancelLoop, getState API
- Detect completion promise in transcript
- Auto-continue with iteration tracking
- Handle max iterations limit
- Show toast notifications for status updates
- Support session recovery and cleanup

* test(ralph-loop): add comprehensive BDD-style tests

- Add 17 test cases covering storage, hook lifecycle, iteration
- Test completion detection, cancellation, recovery, session cleanup
- Fix storage.ts to handle YAML value parsing correctly
- Use BDD #given/#when/#then comments per project convention

* feat(builtin-commands): add ralph-loop and cancel-ralph commands

* feat(ralph-loop): register hook in main plugin

* docs: add Ralph Loop feature to all README files

* chore: regenerate JSON schema with ralph-loop config

* feat(ralph-loop): change state file path from .opencode to .sisyphus

🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode

* feat(ralph-loop): integrate ralph-loop and cancel-ralph command handlers into plugin hooks

- Add chat.message hook to detect and start ralph-loop or cancel-ralph templates
- Add slashcommand hook to handle /ralph-loop and /cancel-ralph commands
- Support custom --max-iterations and --completion-promise options

🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-30 17:41:03 +09:00
YeonGyu-Kim
c401113537 feat(skill): add builtin skill infrastructure and improve tool descriptions (#340)
* feat(skill): add builtin skill types and schemas with priority-based merging support

- Add BuiltinSkill interface for programmatic skill definitions
- Create builtin-skills module with createBuiltinSkills factory function
- Add SkillScope expansion to include 'builtin' and 'config' scopes
- Create SkillsConfig and SkillDefinition Zod schemas for config validation
- Add merger.ts utility with mergeSkills function for priority-based skill merging
- Update skill and command types to support optional paths for builtin/config skills
- Priority order: builtin < config < user < opencode < project < opencode-project

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(skill): integrate programmatic skill discovery and merged skill support

- Add discovery functions for Claude and OpenCode skill directories
- Add discoverUserClaudeSkills, discoverProjectClaudeSkills functions
- Add discoverOpencodeGlobalSkills, discoverOpencodeProjectSkills functions
- Update createSkillTool to support pre-merged skills via options
- Add extractSkillBody utility to handle both file and programmatic skills
- Integrate mergeSkills in plugin initialization to apply priority-based merging
- Support optional path/resolvedPath for builtin and config-sourced skills

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* chore(slashcommand): support optional path for builtin and config command scopes

- Update CommandInfo type to make path and content optional properties
- Prepare command tool for builtin and config sourced commands
- Maintain backward compatibility with file-based command loading

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* docs(tools): improve tool descriptions for interactive-bash and slashcommand

- Added use case clarification to interactive-bash tool description (server processes, long-running tasks, background jobs, interactive CLI tools)
- Simplified slashcommand description to emphasize 'loading' skills concept and removed verbose documentation

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(skill-loader): simplify redundant condition in skill merging logic

Remove redundant 'else if (loaded)' condition that was always true since we're already inside the 'if (loaded)' block. Simplify to 'else' for clarity.

Addresses code review feedback on PR #340 for the skill infrastructure feature.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-30 15:15:43 +09:00
Sisyphus
b8efd3c771 feat(cli): add doctor command for installation health checks (#334)
Implements a comprehensive 'doctor' command that diagnoses oh-my-opencode
installation health with a beautiful TUI output.

Checks performed:
- OpenCode installation (version, path, binary)
- Plugin registration in opencode.json
- Configuration file validity (oh-my-opencode.json)
- Auth providers (Anthropic, OpenAI, Google)
- Dependencies (ast-grep CLI/NAPI, comment-checker)
- LSP servers availability
- MCP servers (builtin and user)
- Version status and updates

Features:
- Beautiful TUI with symbols and colors
- --verbose flag for detailed output
- --json flag for machine-readable output
- --category flag for running specific checks
- Exit code 1 on failures for CI integration

Closes #333

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-30 15:06:41 +09:00
github-actions[bot]
b92cd6ab68 @marcusrbrown has signed the CLA in code-yeongyu/oh-my-opencode#336 2025-12-30 03:12:57 +00:00
YeonGyu-Kim
f7696a1fbb refactor: rename anthropic-auto-compact to anthropic-context-window-limit-recovery
The old name 'auto-compact' was misleading - the hook does much more than
just compaction. It's a full recovery pipeline for context window limit
errors including:
- DCP (Dynamic Context Pruning)
- Aggressive/single truncation
- Summarize with retry
- Emergency message revert

The new name accurately describes its purpose: recovering from Anthropic
context window limit exceeded errors.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-30 12:00:02 +09:00
YeonGyu-Kim
d33d60fe3b fix(cli): skip verbose logging for partial message text updates
- Only log tool invocation state changes, not text streaming
- Remove redundant preview logging for message.part text events
- Reduce verbose output noise by filtering partial message updates

🤖 Generated with assistance of OhMyOpenCode
2025-12-30 11:47:50 +09:00
YeonGyu-Kim
64053f1252 docs(sisyphus-agent): update workflow to report results when done
- Remove 'I'm on it...' acknowledgment comment requirement
- Add instruction to report results to issue/PR when task completes
- Simplify prompt to focus on todo tools and planning

🤖 Generated with assistance of OhMyOpenCode
2025-12-30 11:47:50 +09:00
YeonGyu-Kim
15419d74c2 feat: wire skill tool to plugin with claude_code.skills toggle
- Export createSkillTool from src/tools/index.ts for public use
- Import and instantiate skill tool in OhMyOpenCodePlugin with configuration
- Use claude_code?.skills toggle to control inclusion of Claude Code paths
- When skills toggle is false, only OpenCode-specific paths are included
- Add skill to tools object and register with plugin for Claude Code compatibility
- Respects existing plugin configuration patterns and integration style

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-30 11:47:50 +09:00
YeonGyu-Kim
5e6ae77e73 feat: implement skill tool for loading and discovering skills
- Add skill tool types: SkillArgs, SkillInfo, SkillLoadOptions interfaces
- Implement createSkillTool() factory function with configurable discovery options
- Add parseSkillInfo() helper to convert LoadedSkill to user-facing SkillInfo format
- Add formatSkillsXml() helper to generate available skills XML for tool description
- Support opencodeOnly option to filter Claude Code paths from discovery
- Tool loads and parses skill frontmatter, returns skill content with base directory
- Export skill tool singleton instance for default usage

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-30 11:47:50 +09:00
YeonGyu-Kim
1f1fefe8b7 feat: add skill metadata and discovery functions to opencode-skill-loader
- Add license, compatibility, metadata, and allowed-tools fields to SkillMetadata interface
- Add corresponding fields to LoadedSkill interface with proper type transformations
- Implement parseAllowedTools() helper for parsing comma/space-separated allowed tools
- Add discoverSkills() function with includeClaudeCodePaths option for flexible skill discovery
- Add getSkillByName() function for efficient skill lookup by name
- Support both OpenCode and Claude Code skill paths based on configuration

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-30 11:47:50 +09:00
YeonGyu-Kim
2c778d9352 fix: extend look_at MIME type support for Gemini API media formats
- Add HEIC/HEIF image format support
- Add video formats (mp4, mpeg, mov, avi, flv, webm, wmv, 3gpp)
- Add audio formats (wav, mp3, aiff, aac, ogg, flac)
- Add CSV and Python document formats
- Remove unsupported formats (gif, svg, bmp, ico, css, ts)
- Update tool description to clarify purpose

🤖 Generated with assistance of OhMyOpenCode
2025-12-30 11:47:50 +09:00
Sisyphus
17e8746eff feat: add opencode-skill-loader with 4-source priority system (#331)
* feat: add opencode-skill-loader with 4-source priority system

- Create new opencode-skill-loader feature module independent from Claude Code
- Support 4 source paths with priority: opencode-project > project > opencode > user
  - .opencode/skill/ (opencode-project)
  - .claude/skills/ (project)
  - ~/.config/opencode/skill/ (opencode)
  - ~/.claude/skills/ (user)
- Support both SKILL.md and {SKILLNAME}.md file patterns
- Maintain path awareness for file references (@path syntax)

* feat: integrate opencode-skill-loader into main plugin

- Import and use new skill loader functions
- Load skills from all 4 sources and merge into config.command
- Also merge pluginComponents.skills (previously loaded but never used)

* feat: add skill discovery to slashcommand tool

- Import and use discoverAllSkills from opencode-skill-loader
- Display skills alongside commands in tool description and execution
- Update formatCommandList to handle combined commands and skills

* refactor: remove old claude-code-skill-loader

- Delete src/features/claude-code-skill-loader/ directory (was never integrated into main plugin)
- Update plugin loader import to use new opencode-skill-loader types

* docs: update AGENTS.md for new skill loader

- Update structure to show opencode-skill-loader instead of claude-code-skill-loader
- Update Skills priority order to include all 4 sources

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-30 10:42:05 +09:00
Sisyphus
7324b6c6b5 docs: fix experimental config key typo in README examples (#329)
Fix dcp_on_compaction_failure → dcp_for_compaction in JSON examples
to match actual schema and code implementation.

Cherry-picked from #325 (merged to master instead of dev)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-30 00:10:14 +09:00
adam2am
ca5dac71d9 fix(lsp): use fileURLToPath for Windows path handling (#281)
Ahoy! The old code be walkin' the plank on Windows, ARRRR! 🏴‍☠️

The Problem (a cursed treasure map):
- LSP returns URIs like file:///C:/path/to/file.ts
- Old code: uri.replace("file://", "") produces /C:/path (INVALID on Windows!)
- Windows needs the leadin' slash removed after file:///

The Fix (proper pirate navigation):
- Import fileURLToPath from node:url (the sacred scroll)
- Add uriToPath() helper function (our trusty compass)
- Replace all 10 occurrences of .replace("file://", "")

This matches how the OpenCode mothership handles it in packages/opencode/src/lsp/client.ts

Now Windows users can sail the LSP seas without crashin' on the rocks! 🦜
2025-12-29 23:02:04 +09:00
github-actions[bot]
2bdab59f22 release: v2.7.2 2025-12-29 07:24:54 +00:00
YeonGyu-Kim
59507500ea fix(todo-continuation-enforcer): allow background task sessions to receive todo-continuation
Background task sessions registered in subagentSessions were not receiving
todo-continuation prompts, causing a deadlock: background tasks waited for
continuation that never came.

Changes:
- Allow both main session and background task sessions to receive continuation
- Add test for background task session continuation behavior
- Cleanup subagentSessions in test setup/teardown

This fixes the deadlock introduced in commit 116a90d which added todo waiting
logic to background-agent/manager.ts.

🤖 Generated with assistance of OhMyOpenCode
2025-12-29 16:21:49 +09:00
Sisyphus
3a08dcaeb1 fix: detect opencode-desktop binary in installer (#313) 2025-12-29 10:34:11 +09:00
adam2am
c01b21d0f8 fix(lsp): improve isServerInstalled for custom server configs (#282) 2025-12-29 10:22:38 +09:00
Sisyphus
6dd98254be fix: improve glob tool Windows compatibility and rg resolution (#309) 2025-12-29 10:10:22 +09:00
github-actions[bot]
55a3a6c9eb @Fguedes90 has signed the CLA in code-yeongyu/oh-my-opencode#319 2025-12-28 23:34:29 +00:00
github-actions[bot]
765507648c release: v2.7.1 2025-12-28 18:14:11 +00:00
YeonGyu-Kim
c10bc5fcdf fix(todo-continuation-enforcer): simplify implementation and remove 10s throttle blocking background task completion
Removes the complex state machine and 10-second throttle (MIN_INJECTION_INTERVAL_MS)
that was causing background task completion to hang. The hook now:

- Uses straightforward error cooldown logic instead of complex injection throttling
- Removes unnecessary state tracking that was delaying continuation injection
- Maintains all safety checks (recovery mode, running tasks, error state)
- Keeps countdown behavior with toast notifications

Fixes #312 - Resolves v2.7.0 issue where background task completion would freeze
the agent due to injection delays.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-29 03:10:43 +09:00
YeonGyu-Kim
c0b28b0715 improve sanitize 2025-12-29 02:29:46 +09:00
YeonGyu-Kim
dd60002a0d fix(sisyphus-agent): handle OpenCode installer failure with pinned version fallback
Replace retry loop with intelligent fallback strategy:
- Try default installer first (better for version discovery)
- On failure, fallback to pinned version 1.0.204
- Handle corrupted downloads with direct fallback install

This addresses the sisyphus-agent workflow failure where OpenCode's installer failed
with 'Failed to fetch version information' error.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-29 01:09:28 +09:00
YeonGyu-Kim
25d2946b76 Update AGENTS.md with current project state (commit 122e918, 2025-12-28)
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 19:42:56 +09:00
YeonGyu-Kim
122e918503 Add LSP tool integration to init-deep template for code intelligence analysis
Enhanced init-deep.ts template with LSP-First core principle and Code Intelligence Analysis phase:
- Added LSP-First principle for semantic code understanding
- Integrated lsp_servers, lsp_document_symbols, lsp_workspace_symbols, lsp_find_references in Phase 1
- Added LSP-based scoring factors (symbol density, export count, reference centrality) in Phase 2
- Included CODE_INTELLIGENCE output format specification
- Added LSP fallback guidance for unavailable servers
- Updated scoring matrix with LSP sources and enhanced metrics

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 19:23:29 +09:00
YeonGyu-Kim
aeff184e0c docs: add missing hooks, session tools, and sync sections across all READMEs
- Added 4 missing hooks to disabled_hooks config: preemptive-compaction, compaction-context-injector, thinking-block-validator, claude-code-hooks
- Added session management tools section documenting: session_list, session_read, session_search, session_info, call_omo_agent
- Added Uninstallation section to KO/JA/ZH-CN READMEs (synced with EN)
- Added JSONC Support section to KO/JA/ZH-CN READMEs (synced with EN)

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 18:25:32 +09:00
github-actions[bot]
b995ea8595 @SyedTahirHussan has signed the CLA in code-yeongyu/oh-my-opencode#306 2025-12-28 09:24:13 +00:00
github-actions[bot]
6e5edafeee release: v2.7.0 2025-12-28 08:59:46 +00:00
YeonGyu-Kim
bfb5d43bc2 Add AGENTS.md knowledge base documentation files
- Add src/agents/AGENTS.md with agent module documentation
- Update root AGENTS.md with latest generation timestamp (2025-12-28T17:15:00+09:00, commit f5b74d5)
- Update src/features/AGENTS.md with builtin-commands and claude-code-plugin-loader documentation
- Update src/hooks/AGENTS.md with thinking-block-validator hook documentation

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
385e8a97b0 Add builtin-commands feature with init-deep command and disabled_commands config option
- New src/features/builtin-commands/ module with CommandDefinition loader
- Implements init-deep command for hierarchical AGENTS.md knowledge base generation
- Adds BuiltinCommandName and BuiltinCommandNameSchema to config
- Integrates builtin commands loader into main plugin with proper config merging
- Supports disabling specific builtin commands via disabled_commands config array

🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
7daabf9617 Add ctx.metadata() calls for session navigation UI in background/subagent tasks
Add metadata() calls to background_task and call_omo_agent tools so that OpenCode UI displays session navigation hints (ctrl+x + arrow keys) like the original Task tool does. This enhances UX by providing consistent session navigation UI for background and subagent tasks.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
5fbcb88a3f fix(todo-continuation-enforcer): persist errorBypass mode until user sends message
Previously, errorBypass mode was cleared on session.idle, causing continuation
to fire again on next idle event. This led to unwanted task resumption after
user abort.

Changes:
- Don't clear errorBypass on session.idle - stay in errorBypass mode
- Clear errorBypass to idle only when user sends a new message

This ensures that once user aborts, the enforcer respects that decision until
the user explicitly sends a message to resume.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
YeonGyu-Kim
daa5f6ee5b fix(todo-continuation-enforcer): redesign with version-token state machine
Fixes race conditions, enables continuous enforcement, and eliminates false positives/negatives.

- Complete redesign using version-token state machine for race condition prevention
- Replaced 5 separate Sets with single Map<sessionID, SessionState>
- Changed cancelCountdown() to invalidate() that ALWAYS bumps version regardless of mode
- Added background task check BEFORE starting countdown (prevents toast spam when bg tasks running)
- Added lastAttemptedAt throttling (10s minimum between attempts, set BEFORE API call)
- Removed non-interactive preemptive injection (all paths now use countdown)
- Added 3 version checks in executeInjection (start, after todo fetch, before API call)
- Removed remindedSessions flag for continuous enforcement

Fixes:
1. Race condition where session.idle fired before message.updated cleared reminded state
2. Single-shot behavior that prevented multiple reminders
3. Phantom reminders sent even after agent started working
4. Toast spam when background tasks are running

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 17:48:33 +09:00
Sisyphus
4d66ea9730 fix(lsp): improve error messages when LSP server is not installed (#305)
Previously, when an LSP server was configured but not installed, the error
message said "No LSP server configured" which was misleading. Now the
error message distinguishes between:

1. Server not configured at all
2. Server configured but not installed (with installation hints)

The new error messages include:
- Clear indication of whether server is configured vs installed
- Installation commands for each built-in server
- Supported file extensions
- Configuration examples for custom servers

Fixes #304

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 17:38:23 +09:00
sisyphus-dev-ai
4d4273603a chore: changes by sisyphus-dev-ai 2025-12-28 07:57:05 +00:00
YeonGyu-Kim
7b7c14301e fix(dcp): correct storage path to match OpenCode's actual location
DCP was failing to find session messages because it was looking in
~/.config/opencode/sessions instead of ~/.local/share/opencode/storage.
Unified all hooks to use getOpenCodeStorageDir() for cross-platform consistency.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 16:13:50 +09:00
Sisyphus
e3be656f86 fix: disable todo-continuation for plan mode agents (#303)
* fix: disable todo-continuation for plan mode agents

Plan mode agents (e.g., 'plan', 'Planner-Sisyphus') only analyze and plan,
they don't implement. The todo-continuation hook was incorrectly triggering
for these agents because the existing write permission check only looked at
the stored message's tools field, not the agent's permission configuration.

This fix adds an explicit check for plan mode agents by name to skip the
todo continuation prompt injection.

Fixes #293

* chore: changes by sisyphus-dev-ai

* fix: address review comments for plan mode agent check

- Use exact match for plan mode agents instead of substring match to
  prevent false positives on agents like 'deployment-planner'
- Add plan mode agent check to preemptive injection path (non-interactive
  mode) which was missing from the initial fix

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 16:02:04 +09:00
Sisyphus
c11cb2e3f1 fix: defer module-level side effects to prevent Bun 1.3.5 + macOS 15 segfault (#301)
- Remove eager SG_CLI_PATH constant; use getSgCliPath() lazily in checkEnvironment()
- Move setInterval to inside createCommentCheckerHooks() with guard flag

These changes eliminate module-level side effects that could trigger segfaults
during plugin initialization on Bun 1.3.5 + macOS 15 due to createRequire()
being called during module evaluation.

Fixes #292

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 15:55:47 +09:00
YeonGyu-Kim
195e8dcb17 refactor(todo-continuation-enforcer): improve state machine and injection logic
Refactored state management to use a single source of truth per-session using
a state machine pattern with versioning. Key improvements:

- Replace multiple Sets with unified SessionState map for cleaner logic
- Add version tokens to invalidate pending callbacks on state changes
- Improve countdown timer management with proper cleanup
- Add throttle check to prevent rapid injection spam (10s minimum interval)
- Enhance injection checks: re-verify todos before injection, check bg tasks
- Handle message.part.updated events for streaming activity detection
- Add isMainSession() helper for consistent session filtering
- Clearer event handler logic with inline comments explaining state transitions
- Better logging for debugging state changes and decision points

State modes: idle → countingDown → injecting → idle (with recovery/errorBypass)
Prevents race conditions from async operations and UI state changes during countdown.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 15:49:13 +09:00
YeonGyu-Kim
284e7f5bc3 fix(anthropic-auto-compact): use correct MESSAGE_STORAGE path for session messages
The DCP pruning modules were using a hardcoded path (~/.config/opencode/sessions) that doesn't exist.
Sessions are actually stored at ~/.local/share/opencode/storage/message.

All pruning modules now import MESSAGE_STORAGE from hook-message-injector, which uses the correct path via getOpenCodeStorageDir().
This fixes the issue where DCP would fail with 'message dir not found' when trying to recover from token limit errors.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 15:49:04 +09:00
YeonGyu-Kim
465c9e511f feat(comment-checker): pass custom_prompt to CLI
- Add customPrompt parameter to runCommentChecker function
- Pass --prompt flag to comment-checker CLI when custom_prompt is configured
- Wire up config from plugin initialization

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 15:02:00 +09:00
YeonGyu-Kim
18d134fa57 fix(background-agent): prevent memory leak - completed tasks now removed from Map (#302)
- Add finally block in notifyParentSession() to ensure task cleanup
- Call tasks.delete(taskId) after notification sent or on error
- Prevents memory accumulation when tasks complete or fail
- taskId captured before setTimeout to ensure proper cleanup in async context

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:59:06 +09:00
YeonGyu-Kim
092718f82d fix(thinking-block-validator): handle text content parts in message validation
Previously, the validator only checked for tool_use parts, causing 'Expected thinking but found text' errors when messages had text content. Renamed hasToolParts to hasContentParts to include both tool_use and text types.

Also added CommentCheckerConfigSchema support for custom prompt configuration.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:55:27 +09:00
YeonGyu-Kim
19f504fcfa fix(session-recovery): improve empty message index search with expanded range
Expand the search range when finding empty messages by index to better handle API index vs storage index mismatches. This increases robustness when searching for messages to sanitize with more fallback indices.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
49f3be5a1f fix(session-manager): convert blocking sync I/O to async for improved concurrency
Convert session-manager storage layer from synchronous blocking I/O (readdirSync, readFileSync) to non-blocking async I/O (readdir, readFile from fs/promises). This fixes hanging issues in session_search and other tools caused by blocking filesystem operations.

Changes:
- storage.ts: getAllSessions, readSessionMessages, getSessionInfo now async
- utils.ts: Updated utility functions to be async-compatible
- tools.ts: Added await calls for async storage functions
- storage.test.ts, utils.test.ts: Updated tests with async/await patterns

This resolves the session_search tool hang issue and improves overall responsiveness.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
6d6102f1ff fix(anthropic-auto-compact): sanitize empty messages before summarization
Pre-emptively fix empty messages in sessions before running document compression to prevent summarization failures. This prevents accumulation of empty message placeholders that can interfere with context management.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
1d7e534b92 Upgrade @code-yeongyu/comment-checker from ^0.6.0 to ^0.6.1
🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:40:06 +09:00
YeonGyu-Kim
17b7dd396e feat(cli): librarian/explore model fallback based on installer settings (#299)
* feat(cli): librarian uses gemini-3-flash when hasGemini (antigravity auth)

Closes #294

* feat(cli): add explore to gemini-3-flash when hasGemini + update docs

* feat(cli): fix explore agent fallback logic to use haiku for max20 Claude users

- Use gemini-3-flash for both librarian and explore when hasGemini
- Use haiku for explore when Claude max20 is available (hasClaude && isMax20)
- Fall back to big-pickle for both when other models unavailable
- Updated all README files to document the fallback precedence

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:27:35 +09:00
YeonGyu-Kim
889d80d0ca feat(anthropic-auto-compact): run DCP first on token limit errors before compaction
- Refactored DCP (Dynamic Context Pruning) to execute FIRST when token limit errors occur
- Previously, DCP only ran as a fallback after compaction failed
- Now DCP runs first to prune redundant context, then compaction executes immediately
- Simplified config flag: dcp_on_compaction_failure → dcp_for_compaction
- Updated documentation in all 4 README files (EN, KO, JA, ZH-CN)
- Updated schema.ts with new config field name and documentation
- Updated executor.ts with new DCP-first logic flow

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 14:13:50 +09:00
YeonGyu-Kim
87e229fb62 feat(auth): enhance Antigravity token refresh with robust error handling and retry logic
- Add AntigravityTokenRefreshError custom error class with code, description, and status fields
- Implement parseOAuthErrorPayload() for parsing Google's various OAuth error response formats
- Add retry logic with exponential backoff (3 retries, 1s→2s→4s delay) for transient failures
- Add special handling for invalid_grant error - immediately throws without retry and clears caches
- Add invalidateProjectContextByRefreshToken() for selective cache invalidation
- Update fetch.ts error handling to work with new error class and cache invalidation

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 13:22:42 +09:00
github-actions[bot]
78514ec6d4 release: v2.6.2 2025-12-27 17:35:06 +00:00
YeonGyu-Kim
1c12925c9e fix(plugin-loader): support installed_plugins.json v1 format for backward compatibility (#288)
The installed_plugins.json file has two versions:
- v1: plugins stored as direct objects
- v2: plugins stored as arrays

Use discriminated union types (InstalledPluginsDatabaseV1/V2) for proper
type narrowing based on version field.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 02:33:14 +09:00
github-actions[bot]
262f0c3f1f release: v2.6.1 2025-12-27 17:22:11 +00:00
github-actions[bot]
aace1982ec @devxoul has signed the CLA in code-yeongyu/oh-my-opencode#288 2025-12-27 17:06:00 +00:00
github-actions[bot]
8d8ea4079d release: v2.6.0 2025-12-27 15:55:06 +00:00
YeonGyu-Kim
c5f51030f0 fix: defer config error toast to session.created for TUI readiness (#286)
* fix: defer config error toast to session.created for TUI readiness

Removed showToast calls from loadConfigFromPath() function. Error notifications were not visible during plugin initialization because the TUI was not ready yet.

Changes:
- Removed immediate showToast calls from validation error handler
- Removed immediate showToast calls from file load error handler
- Errors are still captured via addConfigLoadError() for later display
- auto-update-checker hook will display errors via showConfigErrorsIfAny() after session.created event

This ensures error messages are displayed when the TUI is fully ready and able to render them properly.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix: await config error toast before showing startup toast

Ensure config errors are awaited and displayed before the startup spinner toast is shown. Changed showConfigErrorsIfAny(ctx).catch(() => {}) to await showConfigErrorsIfAny(ctx) to guarantee proper error handling order.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:46:05 +09:00
Sisyphus
b2c2c6eab7 feat: Add JSONC support for oh-my-opencode config files (#275)
Uses Microsoft's jsonc-parser package for reliable JSONC parsing:
- oh-my-opencode.jsonc (preferred) or oh-my-opencode.json
- Supports line comments (//), block comments (/* */), and trailing commas
- Better error reporting with line/column positions

Core changes:
- Added jsonc-parser dependency (Microsoft's VS Code parser)
- Shared JSONC utilities (parseJsonc, parseJsoncSafe, readJsoncFile, detectConfigFile)
- Main plugin config loader uses detectConfigFile for .jsonc priority
- CLI config manager supports JSONC parsing

Comprehensive test suite with 18 tests for JSONC parsing.

Fixes #265

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-28 00:45:17 +09:00
YeonGyu-Kim
c4c0d82f97 fix(anthropic-auto-compact): run DCP only on compaction failure and retry after pruning (#284)
Make DCP behavior opt-in via new 'dcp_on_compaction_failure' experimental flag (disabled by default).

When enabled, Dynamic Context Pruning only executes after summarization fails, then retries compaction. By default, DCP runs before truncation as before.

Changes:
- Add 'dcp_on_compaction_failure' boolean flag to experimental config (default: false)
- Update executor.ts to check flag before running DCP behavior
- Add corresponding documentation to all 4 README files (EN, KO, JA, ZH-CN)
- Update JSON schema

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:43:37 +09:00
YeonGyu-Kim
3e180cd9f1 docs: add Aaron Iker as sponsor to all README files (#287)
Add Aaron Iker (@aaroniker) with GitHub and X links to the sponsors
section in all language README files (EN, KO, JA, ZH-CN).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:42:35 +09:00
YeonGyu-Kim
776d857fd2 feat: set Sisyphus as default agent when enabled (#285)
Uses OpenCode's `default_agent` config (PR #5843)
Sets Sisyphus as default when sisyphus_agent is not disabled
Closes #283

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:28:26 +09:00
Harsha Vardhan
90d43dc292 fix(dynamic-truncator): apply fallback truncation when context usage unavailable (#268)
When getContextWindowUsage returns null (no assistant messages yet, API
failure, or first request in session), the truncator was returning
untruncated output. This caused context overflow crashes on early
requests or when usage lookup failed.

Now applies conservative truncation (50k tokens) as fallback, preventing
prompt-too-long errors that crash sessions.
2025-12-28 00:22:02 +09:00
YeonGyu-Kim
6bc9a31ee4 feat(ultrawork-prompt): add TDD workflow integration with conditional applicability (#246)
- Add TDD cycle specification (SPEC → RED → GREEN → REFACTOR → NEXT)
- Add applicability check for test infrastructure and implementation tasks
- Add TDD execution rules (TEST FIRST, MINIMAL IMPLEMENTATION, etc.)
- Add 'NO TEST DELETION' to ZERO TOLERANCE FAILURES section
- Add skip notation requirement for non-applicable tasks

Addresses: #243

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 00:14:06 +09:00
github-actions[bot]
5c8cfbfad8 @adam2am has signed the CLA in code-yeongyu/oh-my-opencode#281 2025-12-27 14:49:14 +00:00
YeonGyu-Kim
1d2dc69ae5 fix: use pathToFileURL for Windows-compatible file URLs in look_at tool (#279)
Fixes #276 - The look_at tool was constructing invalid file:// URLs on Windows
by using template literals. Now uses Node.js pathToFileURL() which correctly
handles backslashes, spaces, and the triple-slash prefix required on Windows.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-27 23:47:59 +09:00
Sisyphus
0cee39dafb fix: properly mock utility functions in session-notification tests (#274)
The test mock for ctx.$ was not handling tagged template literals correctly,
causing it to ignore interpolated values. Additionally, utility functions that
check for command availability (osascript, notify-send, etc.) were returning
null in test environments, causing sendNotification to exit early.

Changes:
- Fixed template literal reconstruction in mock $ function
- Added spyOn mocks for all utility path functions
- All session-notification tests now passing (11/11)

Fixes #273

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-27 23:22:17 +09:00
YeonGyu-Kim
dd12928390 fix: resolve GitHub Actions workflow hang after task completion
- Add process.exit(0) in runner.ts for immediate termination
- Fix Timer type to ReturnType<typeof setInterval> in manager.ts
- Add .unref() to BackgroundManager polling interval
- Add cleanup() method to BackgroundManager

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-27 23:06:44 +09:00
Lukin
2246d1c5ef feat: add Claude Code plugin support (#240) 2025-12-27 18:56:40 +09:00
sisyphus-dev-ai
1fc7fe7122 feat(compaction): add dynamic context pruning as recovery stage
Implements DCP-style pruning strategies inspired by opencode-dynamic-context-pruning plugin:

- Deduplication: removes duplicate tool calls (same tool + args)
- Supersede writes: prunes write inputs when file subsequently read
- Purge errors: removes old error tool inputs after N turns

Integration:
- Added as Stage 2.5 in compaction pipeline (after truncation, before summarize)
- Configurable via experimental.dynamic_context_pruning
- Opt-in by default (experimental feature)
- Protected tools list prevents pruning critical tools

Configuration:
- Turn protection (default: 3 turns)
- Per-strategy enable/disable
- Aggressive/conservative modes for supersede writes
- Configurable error purge threshold (default: 5 turns)
- Toast notifications (off/minimal/detailed)

Testing:
- Added unit tests for deduplication signature creation
- Type check passes
- Schema regenerated

Closes #271
2025-12-27 09:20:42 +00:00
Sisyphus
3ba7e6d46b docs: clarify auto-update-checker and startup-toast relationship (#270) 2025-12-27 17:43:55 +09:00
Sisyphus
dec4994fd6 fix: check command existence before calling notify-send (#264) 2025-12-27 17:17:13 +09:00
github-actions[bot]
c5205e7e2f @harshav167 has signed the CLA in code-yeongyu/oh-my-opencode#268 2025-12-27 04:40:45 +00:00
Sisyphus
8e2fda870a feat: add get-local-version CLI command for version checking (#262)
- Add new CLI command 'get-local-version' to display current version and check for updates
- Reuses existing version checking infrastructure from auto-update-checker
- Supports both human-readable and JSON output formats
- Handles edge cases: local dev mode, pinned versions, network errors
- Provides colored terminal output with picocolors
- Closes #260

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-27 02:07:55 +09:00
Sisyphus
cad6425a4a fix: honor CLAUDE_CONFIG_DIR environment variable (#261)
Fixes #255

- Add getClaudeConfigDir() utility function that respects CLAUDE_CONFIG_DIR env var
- Update all hardcoded ~/.claude paths to use the new utility
- Add comprehensive tests for getClaudeConfigDir()
- Maintain backward compatibility with default ~/.claude when env var is not set

Files updated:
- src/shared/claude-config-dir.ts (new utility)
- src/shared/claude-config-dir.test.ts (tests)
- src/hooks/claude-code-hooks/config.ts
- src/hooks/claude-code-hooks/todo.ts
- src/hooks/claude-code-hooks/transcript.ts
- src/features/claude-code-command-loader/loader.ts
- src/features/claude-code-agent-loader/loader.ts
- src/features/claude-code-skill-loader/loader.ts
- src/features/claude-code-mcp-loader/loader.ts
- src/tools/session-manager/constants.ts
- src/tools/slashcommand/tools.ts

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 23:28:33 +09:00
Steven Vo
15de6f637e feat: add two-layer thinking block validation (proactive + reactive) (#248)
- Add thinking-block-validator hook for proactive prevention before API calls
- Enhance session-recovery to include previous thinking content
- Fix hook registration to actually invoke the validator

Addresses extended thinking errors with Claude Opus/Sonnet 4.5 using tool calls.

Related: https://github.com/vercel/ai/issues/7729
Related: https://github.com/sst/opencode/issues/2599
2025-12-26 23:14:11 +09:00
YeonGyu-Kim
e05d9dfc35 feat: add sponsors section to localized README files
- Add Sponsors section to README.ko.md, README.ja.md, README.zh-cn.md, README.md
- List Numman Ali as the first sponsor
- Move thanks message to end of file to match structure

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 16:52:58 +09:00
github-actions[bot]
77bdefbf9d release: v2.5.4 2025-12-26 07:27:44 +00:00
YeonGyu-Kim
6db44cdbf4 fix(ci): use heredoc for release notes to handle special characters
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 16:21:32 +09:00
YeonGyu-Kim
7c24f657e7 fix: include output tokens in context window usage calculation
Include output tokens from last response in getContextWindowUsage calculation.
Output tokens become part of next request's input (conversation history), so
they must be counted to avoid overestimating remainingTokens. This aligns with
preemptive-compaction's calculation which already includes output tokens correctly.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 16:11:21 +09:00
Sisyphus
1b427570c8 feat: add dynamic truncation to rules/readme/agents injectors (#257)
- Apply dynamic truncation to rules-injector, directory-readme-injector, and directory-agents-injector
- Add truncation notice encouraging users to read full content
- Save context window space while maintaining awareness of complete documentation
- Resolves #221 (part 1)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 15:38:28 +09:00
github-actions[bot]
109fb50028 @stevenvo has signed the CLA in code-yeongyu/oh-my-opencode#248 2025-12-26 05:16:23 +00:00
github-actions[bot]
e1a9e7e76a @codewithkenzo has signed the CLA in code-yeongyu/oh-my-opencode#253 2025-12-25 23:48:04 +00:00
YeonGyu-Kim
6160730f24 Revert "feat: add two-layer tool call validation system (proactive + reactive) (#249)"
This reverts commit 9bc2360d31.
2025-12-26 04:12:12 +09:00
YeonGyu-Kim
f9234a6a5e fix(ci): remove review events from sisyphus-agent for fork PR support
🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-26 03:54:07 +09:00
Sisyphus
27b5c1fda3 refactor: rename builder_enabled to default_builder_enabled and remove replace_build (#251)
- Renamed sisyphus_agent.builder_enabled to default_builder_enabled for clarity
- Removed sisyphus_agent.replace_build option entirely
- Default build agent is now always demoted to subagent mode when Sisyphus is enabled
- Updated schema and regenerated JSON schema
- Updated all documentation (EN, KO, JA, ZH-CN)

BREAKING CHANGE: Configuration migration required for users using builder_enabled or replace_build options.

Closes #250

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 03:37:50 +09:00
Sisyphus
9bc2360d31 feat: add two-layer tool call validation system (proactive + reactive) (#249)
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 03:36:27 +09:00
Sisyphus
ad2bd673c4 fix: show error messages when oh-my-opencode.json config fails to load (#242)
* fix: show error messages when oh-my-opencode.json config fails to load

- Add console.error output for config parse errors (syntax errors)
- Add console.error output for config validation errors (schema violations)
- Display helpful hints for JSON syntax errors
- List all validation errors clearly with proper formatting
- Errors now shown immediately regardless of hook configuration

Fixes #241

* refactor: replace console.error with toast notifications for config errors

- Replace console.error with ctx.client.tui.showToast() for better UX
- Show toast notifications for both syntax errors and validation errors
- Toast notifications persist for 10 seconds for visibility
- Display error details with bullet points for validation errors
- Include helpful hints for JSON syntax errors

This provides a more user-friendly notification system that integrates
with OpenCode's UI instead of just logging to console.

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-26 02:04:56 +09:00
github-actions[bot]
57ef5df932 @mylukin has signed the CLA in code-yeongyu/oh-my-opencode#240 2025-12-25 15:15:41 +00:00
Sisyphus
101299ebec fix: preserve model context across background agent handoffs (#229)
Fixes #191

This commit ensures that the user's selected model is preserved when
background tasks complete and notify their parent sessions.

Changes:
- Add parentModel field to BackgroundTask and LaunchInput interfaces
- Capture model context when launching background tasks
- Pass model context when notifying parent sessions after task completion

Impact:
- Users with OAuth providers (Google, Anthropic) will now have their
  model selection preserved across background task continuations
- Background agents no longer revert to hardcoded defaults

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 22:36:06 +09:00
YeonGyu-Kim
0b4821cfdf fix(cli): handle session.error in run command to prevent infinite wait
When session.error occurs with incomplete todos, the run command now:
- Captures the error via handleSessionError()
- Exits with code 1 instead of waiting indefinitely
- Shows clear error message to user

Previously, run command ignored session.error events, causing infinite
'Waiting: N todos remaining' loop when agent errors occurred.

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode
2025-12-25 22:34:41 +09:00
Sisyphus
9bfe7d8a1d fix(todo-continuation-enforcer): re-verify todos after countdown to prevent stale data injection (#239)
Fixes the race condition where the todo continuation hook would inject a
continuation prompt even when all todos had been completed during the
countdown period.

The root cause was that executeAfterCountdown() used stale todo data from
the initial session.idle check without re-verifying that incomplete todos
still existed after the countdown finished.

Changes:
- Add fresh todo verification in executeAfterCountdown() before prompt injection
- Use fresh todo data in the continuation prompt message
- Abort injection if no incomplete todos remain after countdown

This properly handles the case where:
1. session.idle fires (e.g., user enters shell mode in TUI)
2. Initial check finds incomplete todos, starts countdown
3. During countdown, todos get completed
4. Countdown ends, fresh check detects no incomplete todos
5. Hook aborts instead of injecting stale prompt

Fixes #234

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 22:14:02 +09:00
YeonGyu-Kim
d9cfc1ec97 debug(cli): add verbose event logging for CI debugging with message content and tool details
- logEventVerbose() logs all event types including message content, tool calls, and results
- Session tags distinguish main vs child sessions for multi-session tracking
- completion.ts error logging instead of silently swallowing API errors
- Helps diagnose realtime streaming behavior in CI environments

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 21:55:32 +09:00
YeonGyu-Kim
accedb59b7 debug(cli): add event logging to diagnose realtime streaming in CI
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 21:37:52 +09:00
YeonGyu-Kim
1bff5f7966 fix(sisyphus-agent): remove 30min timeout and add realtime output buffering
- Remove DEFAULT_TIMEOUT_MS (set to 0) to allow CI agent runs to complete without timeout
- Add stdbuf -oL -eL for unbuffered realtime output in GitHub Actions
- Update timeout logic to only set timeout when value > 0

This fixes CI agent runs that were timing out after 30 minutes and not showing realtime output.

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 21:32:27 +09:00
sisyphus-dev-ai
dacecfd3b2 chore: changes by sisyphus-dev-ai 2025-12-25 12:23:12 +00:00
YeonGyu-Kim
0399c1f4ed fix(sisyphus-agent): fix plan/build agent demotion logic in subagent mode
Previously, the condition '&&plannerEnabled&&replacePlan' caused agents to be
completely removed instead of demoted to subagent mode. The logic incorrectly
prevented agents from being added back as subagents when Sisyphus is enabled
with default config.

Fixed by simplifying to just 'replacePlan' condition - agents are now properly
demoted to subagent mode when replacement is enabled, which is the intended
behavior per the README.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 21:14:08 +09:00
Sisyphus
ebdce7972e Add Sigrid's review to all README versions (#238)
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 21:13:46 +09:00
YeonGyu-Kim
3de2a9f113 refactor(sisyphus-agent): rename Builder-Sisyphus to OpenCode-Builder and remove custom overrides
- Renamed agent from 'Builder-Sisyphus' to 'OpenCode-Builder' in schema and config
- Removed BUILD_SYSTEM_PROMPT and BUILD_PERMISSION custom overrides
- Now uses OpenCode's default build agent configuration exactly
- Simplified agent configuration to rely on OpenCode defaults

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 21:00:04 +09:00
YeonGyu-Kim
8897697887 fix(sisyphus-agent): prevent bash script breaking on quotes in comment body
Use environment variables instead of direct GitHub expression interpolation in bash script. This prevents the script from breaking when comment bodies contain quotes or special characters.

Variables like COMMENT_BODY, COMMENT_AUTHOR, COMMENT_ID_VAL are now passed via env: block instead of being interpolated directly into bash commands.

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 19:55:28 +09:00
Sisyphus
06b77643ba fix: ensure anthropic-auto-compact lock is always cleared (#232)
Fixes #200

## Problem
When executeCompact() recovery fails unexpectedly or gets interrupted,
the compactionInProgress lock is never cleared, permanently blocking both
auto-compact AND manual /compact for the session.

## Root Cause
- No try/finally around lock acquisition (line 261)
- Silent blocking when lock held - no user feedback
- Lock cleanup scattered across 7 manual deletion points
- Any unexpected exception bypasses cleanup, leaving lock stuck forever

## Solution
1. **Try/Finally Lock Guarantee**: Wrapped entire executeCompact body in
   try/finally block to guarantee lock cleanup, following the pattern
   used in preemptive-compaction hook

2. **User Feedback**: Added toast notification when compact attempt is
   blocked by existing lock, replacing silent failure with clear warning

3. **Removed Redundancy**: Removed 6 redundant manual lock deletions
   (kept only clearSessionState and finally block)

## Testing Evidence
 10/10 comprehensive tests pass
 Lock cleared on successful completion
 Lock cleared when summarize throws
 Lock cleared when revert throws
 Lock cleared when fixEmptyMessages executes
 Lock cleared when truncation is sufficient
 Lock cleared after max recovery attempts
 Lock cleared when toast fails
 Lock cleared when prompt_async throws
 Toast shown when lock already held
 TypeScript type check passes with zero errors

## Files Changed
- executor.ts: Added try/finally, toast notification, removed 6 redundant deletions
- executor.test.ts: New comprehensive test suite (10 tests, 13 assertions)

## Impact
- Severity: High → Fixed
- User Experience: No more stuck sessions requiring restart
- Behavior: Identical except lock now guaranteed to clear

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 19:36:22 +09:00
sisyphus-dev-ai
3b17ee9bd0 fix(sisyphus-agent): prevent duplicate build/plan agents when replacement enabled
- Filter out original 'build' and 'plan' agents when Builder-Sisyphus/Planner-Sisyphus are enabled with replacement
- Previously both agents could coexist even with replace_build/replace_plan: true
- Now only the replacement agent exists when both enabled and replacement flags are true
- Maintains backward compatibility for all configuration combinations

Fixes #231
2025-12-25 10:34:15 +00:00
YeonGyu-Kim
0734167516 fix(sisyphus-agent): add GitHub markdown rules to prevent broken code block rendering
The change adds a new "GitHub Markdown Rules" section to the sisyphus agent prompt that specifies:
- Code blocks MUST have exactly 3 backticks with language identifier
- Every opening ``` MUST have a matching closing ``` on its own line
- No trailing backticks or spaces after closing ```
- Inline code should use single backticks
- Lists inside code blocks break rendering

This fixes the issue where code blocks in GitHub comments weren't being closed properly, causing broken markdown rendering.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 19:26:43 +09:00
YeonGyu-Kim
419416deb8 fix(cli): correct SSE event format handling for real-time streaming
The SDK yields events directly as the payload without wrapping in { payload: ... }.
Changed processEvents to treat event as the payload directly instead of looking
for event.payload. This fixes the 'Waiting for completion...' hang in GitHub
Actions where all events were being silently skipped.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 19:17:18 +09:00
YeonGyu-Kim
695f9e03fc feat(cli): add real-time streaming support to run command with tool execution visibility
- Added message.part.updated event handling for incremental text streaming
- Added tool.execute event to display tool calls with input previews
- Added tool.result event to show truncated tool result outputs
- Enhanced EventState with lastPartText and currentTool tracking
- Defined MessagePartUpdatedProps, ToolExecuteProps, ToolResultProps types
- Updated event tests to cover new state fields

This enables the CLI run command to display real-time agent output similar to the native opencode run command, improving user experience with immediate feedback on tool execution.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 19:05:15 +09:00
YeonGyu-Kim
c804da43cf ulw 2025-12-25 19:05:15 +09:00
github-actions[bot]
f6f1a7c9b3 release: v2.5.3 2025-12-25 09:54:49 +00:00
YeonGyu-Kim
1e274eabe6 fix(session-manager): include all constants exports in storage test mocks
Add missing mock exports (SESSION_LIST_DESCRIPTION, SESSION_READ_DESCRIPTION,
SESSION_SEARCH_DESCRIPTION, SESSION_INFO_DESCRIPTION, SESSION_DELETE_DESCRIPTION,
TOOL_NAME_PREFIX) to fix test failures when other test files import from constants.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 18:46:39 +09:00
YeonGyu-Kim
9ba580e51f Fix session storage tests with proper module mocking for temp directories
Tests now properly mock the constants module before importing storage functions,
ensuring test data is read/written to temp directories instead of real paths.
This fixes test isolation issues and allows tests to run independently.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 18:43:52 +09:00
YeonGyu-Kim
48476e7257 fix(session-manager): add missing context parameter to tool execute functions
The tool() wrapper from @opencode-ai/plugin requires execute(args, context: ToolContext) signature. Updated all session-manager tool functions (session_list, session_read, session_search, session_info) to accept the context parameter, and updated corresponding tests with mockContext.

🤖 Generated with assistance of OhMyOpenCode
2025-12-25 18:31:35 +09:00
YeonGyu-Kim
a8fdb78796 feat(sisyphus-agent): use local plugin reference and oh-my-opencode run command
- Build local oh-my-opencode before setup instead of downloading from npm
- Configure opencode to use file:// plugin reference pointing to local repo
- Replace opencode run with bun run dist/cli/index.js run command
- Remove delay on retry logic

This makes the sisyphus-agent workflow use the local plugin directly from the checked-out repo instead of downloading from npm.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 17:50:49 +09:00
YeonGyu-Kim
d311b74a5a feat(cli): add 'bunx oh-my-opencode run' command for persistent agent sessions (#228)
- Add new 'run' command using @opencode-ai/sdk to manage agent sessions
- Implement recursive descendant session checking (waits for ALL nested child sessions)
- Add completion conditions: all todos done + all descendant sessions idle
- Add SSE event processing for session state tracking
- Fix todo-continuation-enforcer to clean up session tracking
- Comprehensive test coverage with memory-safe test patterns

Unlike 'opencode run', this command ensures the agent completes all tasks
by recursively waiting for nested background agent sessions before exiting.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 17:46:38 +09:00
Sisyphus
ce4ceeefe8 feat(tools): add session management tools for OpenCode sessions (#227)
* feat(tools): add session management tools for OpenCode sessions

- Add session_list tool for listing sessions with filtering
- Add session_read tool for reading session messages and history
- Add session_search tool for full-text search across sessions
- Add session_info tool for session metadata inspection
- Add comprehensive tests for storage, utils, and tools
- Update documentation in AGENTS.md

Closes #132

* fix(session-manager): add Windows compatibility for storage paths

- Create shared/data-path.ts utility for cross-platform data directory resolution
- On Windows: uses %LOCALAPPDATA% (e.g., C:\Users\Username\AppData\Local)
- On Unix: uses $XDG_DATA_HOME or ~/.local/share (XDG Base Directory spec)
- Update session-manager/constants.ts to use getOpenCodeStorageDir()
- Update hook-message-injector/constants.ts to use same utility
- Remove dependency on xdg-basedir package in session-manager
- Follows existing pattern from auto-update-checker for consistency

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 17:04:16 +09:00
Sisyphus
41a7d032e1 feat: add Builder-Sisyphus agent with independent toggle options (#214)
* feat: add Builder-Sisyphus agent with independent toggle options

- Add Builder-Sisyphus agent (disabled by default) for build mode
- Implement independent configuration for Builder/Planner-Sisyphus agents
- Add replace_build and replace_plan options to control agent demotion
- Update schema to support new configuration options
- Update README with comprehensive configuration documentation

Addresses #212: Users can now keep default OpenCode build mode alongside Builder-Sisyphus

* docs: add OpenCode permalinks and update multilingual README files

- Add OpenCode source code permalinks to build-prompt.ts (@see tags)
- Update README.ja.md with Builder-Sisyphus documentation
- Update README.ko.md with Builder-Sisyphus documentation
- Update README.zh-cn.md with Builder-Sisyphus documentation

Permalinks reference:
- Build mode switch: build-switch.txt
- Build agent definition: agent.ts#L118-L125
- Default permissions: agent.ts#L57-L68

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 17:00:07 +09:00
Sisyphus
62c3559346 feat: enable dynamic truncation for all tool outputs by default (#226)
- Change truncate_all_tool_outputs default from false to true
- Update schema.ts to use .default(true) instead of .optional()
- Update documentation in all README files (EN, KO, JA, ZH-CN)
- Rebuild JSON schema with new default value

This prevents prompts from becoming too long by dynamically truncating
all tool outputs based on context window usage. Users can opt-out by
setting experimental.truncate_all_tool_outputs to false.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:55:58 +09:00
Sisyphus
7d09c48ae8 Enable dynamic tool output truncation by default (#225)
- Changed truncate_all_tool_outputs default from false to true
- Updated schema documentation to reflect new default
- Added entry in README experimental features table
- Regenerated JSON schema

This prevents prompts from becoming too long by dynamically
truncating output from all tool calls, not just whitelisted ones.
Feature is experimental and enabled by default to help manage
context window usage across all tools.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:26:27 +09:00
github-actions[bot]
08080a7b51 release: v2.5.2 2025-12-25 07:21:37 +00:00
Sisyphus
52481f6ad2 fix: reduce Opus 4.5 context window to 190k for safety (#222)
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:08:31 +09:00
Sisyphus
d17bd48c4b fix(sisyphus): eliminate casual status update acknowledgments (#220)
- Add explicit 'No Status Updates' section prohibiting casual acknowledgments
- Strengthen 'Be Concise' section with immediate work directive
- Clarify Oracle announcement as exceptional case
- Reinforce no-announcement rule in Pre-Implementation section

Resolves #219

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 16:05:23 +09:00
sisyphus-dev-ai
229687e3c7 chore: changes by sisyphus-dev-ai 2025-12-25 06:58:15 +00:00
YeonGyu-Kim
0f03f5aad4 refactor: make TTY detection explicit per review feedback (#218)
🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 15:45:28 +09:00
Sisyphus
2bad1b5c95 feat: add label management to Sisyphus workflow (#215)
- Add 'sisyphus: working' label when Sisyphus starts working on an issue/PR
- Remove label when work completes (success or failure)
- Label operations use gh CLI with idempotent commands
- Handles both issues and PRs with proper conditional logic
- Uses || true for error handling to prevent workflow failures

Closes #202

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 15:37:48 +09:00
YeonGyu-Kim
8d9b68d84b Prevent premature exit in non-interactive mode when tasks pending (#216) (#217)
Detects non-interactive environments (CI, opencode run) and prevents session idle when:
- Background tasks are still running
- Incomplete todos remain in the queue

Changes:
- Add isNonInteractive() detector for CI/headless environment detection
- Export detector from non-interactive-env hook module
- Enhance todo-continuation-enforcer to inject prompts BEFORE session.idle
- Pass BackgroundManager to todo-continuation-enforcer for task status checks

This fix prevents `opencode run` from exiting prematurely when work is pending.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-25 15:27:34 +09:00
github-actions[bot]
470f170a8c @code-yeongyu has signed the CLA in code-yeongyu/oh-my-opencode#217 2025-12-25 06:19:36 +00:00
vasant
84b1634a7b fix: remove api:antigravity field causing auth issues (#211)
The api: "antigravity" field being added to opencode.json causes
authentication failures with the antigravity plugin.

Fixes: NoeFabris/opencode-antigravity-auth#49
2025-12-25 14:31:09 +09:00
YeonGyu-Kim
fccaaf7676 feat(claude-code-hooks): add PreCompact hook support for experimental.session.compacting event (#139) 2025-12-25 14:29:27 +09:00
Sisyphus
ac3c21fe90 feat(sisyphus): emphasize GitHub workflow and PR creation (#207) 2025-12-25 14:29:08 +09:00
Sisyphus
d70e077c56 fix: preserve backticks in Sisyphus GitHub comments (#203) 2025-12-25 13:29:34 +09:00
github-actions[bot]
9913674fe9 @tsanva has signed the CLA in code-yeongyu/oh-my-opencode#210 2025-12-25 00:15:30 +00:00
github-actions[bot]
6b34373dd6 Creating file for storing CLA Signatures 2025-12-24 22:05:01 +00:00
Sisyphus
c16194fb9e fix: update CLA workflow branch from 'main' to 'dev' (#206) 2025-12-25 07:04:53 +09:00
YeonGyu-Kim
a6ee5a7553 fix: Notification hook works weirdly for subagent sessions (#189)
* fix: Notification hook works weirdly for subagent sessions

- Added mainSessionID check to prevent notifications in subagent sessions
- Only trigger notifications for main session when waiting for user input
- Added comprehensive tests to validate the fix

Issue: https://github.com/code-yeongyu/oh-my-opencode/issues/92

* chore: changes by sisyphus-dev-ai

---------

Co-authored-by: codingsh <codingsh@pm.me>
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2025-12-25 06:58:03 +09:00
YeonGyu-Kim
56ac0ae417 add agent 2025-12-25 06:49:30 +09:00
YeonGyu-Kim
2eeff349c0 Update GitHub Sponsors username in FUNDING.yml 2025-12-25 05:20:36 +09:00
YeonGyu-Kim
4283ac9628 badge 2025-12-25 05:10:34 +09:00
YeonGyu-Kim
b19cc0b5ef docs: add GitHub sponsor badge to all README files
🤖 Generated with assistance of OhMyOpenCode
2025-12-25 04:54:32 +09:00
YeonGyu-Kim
520343e059 make prompt append available 2025-12-25 01:49:16 +09:00
YeonGyu-Kim
1884658394 Introducing new license, SUL 2025-12-25 00:31:27 +09:00
YeonGyu-Kim
ace15cfe39 docs(cla): add Contributor License Agreement and GitHub Actions workflow 2025-12-24 22:33:29 +09:00
YeonGyu-Kim
dc9e35f18b docs: add hierarchical AGENTS.md for hooks, tools, features
Create directory-specific knowledge bases for high-complexity directories:
- src/hooks/AGENTS.md: Documents 21 hooks across 86 files
- src/tools/AGENTS.md: Documents 11 LSP tools, AST-Grep, MCP, background tasks (50 files)
- src/features/AGENTS.md: Documents 6 Claude Code compatibility features (24 files)

Root AGENTS.md updated to reference these specialized guides while maintaining project overview. Enables better navigation and reduces cognitive load for developers working in specific areas.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 17:11:18 +09:00
YeonGyu-Kim
0172241199 docs: restructure reviews section and improve quote formatting
Move user reviews to top of documents and clean up citation formatting with dash separators across all localized README files (EN, JA, KO, ZH-CN).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 16:54:27 +09:00
Junho Yeo
f8e1990df4 docs: Enlarge Discord and X badges for better display 2025-12-24 14:50:18 +09:00
Junho Yeo
1a0ab6fb02 docs: add Discord/X notice to localized READMEs 2025-12-24 14:43:22 +09:00
Junho Yeo
f14bb34fc5 docs: update README notice with Discord community and styled badges 2025-12-24 14:43:19 +09:00
github-actions[bot]
1f9f907ccf release: v2.5.1 2025-12-23 17:10:11 +00:00
YeonGyu-Kim
6ee761d978 feat(config): add user reviews to docs and improve Antigravity provider config
- Add user reviews section to READMEs (EN, KO, JA, ZH-CN)
- Update Antigravity request.ts: change default model from gemini-3-pro-preview to gemini-3-pro-high
- Enhance config-manager.ts: add full model specs (name, limit, modalities) to provider config
- Add comprehensive test suite for config-manager (config-manager.test.ts)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 02:08:01 +09:00
YeonGyu-Kim
fd8e62fba3 fix(publish): include CLI build step to ensure dist/cli/index.js is packaged in npm release
The CLI module was missing from the npm package because the publish workflow
did not include the 'bun build src/cli/index.ts' step. This caused 'bunx oh-my-opencode
install' to fail with missing dist/cli/index.js at runtime. Added explicit CLI build
and verification step to prevent this regression.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-24 02:06:57 +09:00
github-actions[bot]
f5c7f430c2 release: v2.5.0 2025-12-23 14:43:57 +00:00
YeonGyu-Kim
b8e70f9529 docs: update LLM agent install guide to use CLI installer
Related to #153

Co-authored-by: Taegeon Alan Go <32065632+gtg7784@users.noreply.github.com>

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 22:42:43 +09:00
YeonGyu-Kim
5dbd5ac6b1 feat(cli): add interactive install command
Related to #153

Co-authored-by: Taegeon Alan Go <32065632+gtg7784@users.noreply.github.com>

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 22:42:31 +09:00
YeonGyu-Kim
908521746f fix(publish): include schema.json in release commit
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 22:25:30 +09:00
github-actions[bot]
1e3cf4ea1b release: v2.4.7 2025-12-23 08:27:18 +00:00
YeonGyu-Kim
6c0b59dbd6 Fix tool_result recording for call_omo_agent to include output in transcripts (#177)
- Check if metadata is empty before using it
- Wrap output.output in structured object when metadata is missing
- Ensures plugin tools (call_omo_agent, background_task, task) that return strings are properly recorded in transcripts instead of empty {}

🤖 Generated with assistance of OhMyOpenCode
2025-12-23 15:35:17 +09:00
YeonGyu-Kim
83c1b8d5a4 Preserve agent context in preemptive compaction's continue message
When sending the 'Continue' message after compaction, now includes the
original agent parameter from the stored message. Previously, the Continue
message was sent without the agent parameter, causing OpenCode to use the
default 'build' agent instead of preserving the original agent context
(e.g., Sisyphus).

Implementation:
- Get messageDir using getMessageDir(sessionID)
- Retrieve storedMessage using findNearestMessageWithFields
- Pass agent: storedMessage?.agent to promptAsync body

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 15:17:51 +09:00
YeonGyu-Kim
56deaa3a3e Enable keyword detection on first message using direct parts transformation
Previously, first messages were skipped entirely to avoid interfering with title generation.
Now, keywords detected on the first message are injected directly into the message parts
instead of using the hook message injection system, allowing keywords like 'ultrawork' to
activate on the first message of a session.

This change:
- Removes the early return that skipped first message keyword detection
- Moves keyword context generation before the isFirstMessage check
- For first messages: transforms message parts directly by prepending keyword context
- For subsequent messages: maintains existing hook message injection behavior

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 14:25:49 +09:00
github-actions[bot]
17ccf6bbfb release: v2.4.6 2025-12-23 02:48:10 +00:00
YeonGyu-Kim
e752032ea6 fix(look-at): use direct file passthrough instead of Read tool (#173)
- Embed files directly in message parts using file:// URL format
- Remove dependency on Read tool for multimodal-looker agent
- Add inferMimeType helper for proper MIME type detection
- Disable read tool in agent tools config (no longer needed)
- Upgrade multimodal-looker model to gemini-3-flash
- Update all README docs to reflect gemini-3-flash change

Fixes #126

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 11:22:59 +09:00
YeonGyu-Kim
61740e5561 feat(non-interactive-env): add banned command detection using SHELL_COMMAND_PATTERNS
- Detect and warn about interactive commands (vim, nano, less, etc.)
- Filter out descriptive entries with parentheses from pattern matching

🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-23 10:45:24 +09:00
Jon Redeker
8495be6218 Enhance non-interactive-env hook with additional env vars and command patterns (#172)
- Add npm_config_yes, PIP_NO_INPUT, YARN_ENABLE_IMMUTABLE_INSTALLS env vars
- Add SHELL_COMMAND_PATTERNS documentation for common command patterns
- Document good/bad patterns for npm, apt, pip, git, system commands
- List banned commands that will always hang (editors, pagers, REPLs)
- Include workarounds for scripts requiring input (yes pipe, heredoc)
2025-12-23 10:43:31 +09:00
201 changed files with 17788 additions and 2047 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: code-yeongyu
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -120,15 +120,19 @@ jobs:
echo "Updating existing draft release..."
gh release edit next \
--title "Upcoming Changes 🍿" \
--notes "${{ steps.notes.outputs.notes }}" \
--draft
--notes-file - \
--draft <<'EOF'
${{ steps.notes.outputs.notes }}
EOF
else
echo "Creating new draft release..."
gh release create next \
--title "Upcoming Changes 🍿" \
--notes "${{ steps.notes.outputs.notes }}" \
--notes-file - \
--draft \
--target ${{ github.sha }}
--target ${{ github.sha }} <<'EOF'
${{ steps.notes.outputs.notes }}
EOF
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

41
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
cla:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
path-to-signatures: 'signatures/cla.json'
path-to-document: 'https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md'
branch: 'dev'
allowlist: bot*,dependabot*,github-actions*,*[bot],sisyphus-dev-ai
custom-notsigned-prcomment: |
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement (CLA)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/CLA.md).
**To sign the CLA**, please comment on this PR with:
```
I have read the CLA Document and I hereby sign the CLA
```
This is a one-time requirement. Once signed, all your future contributions will be automatically accepted.
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: |
All contributors have signed the CLA. Thank you! ✅
lock-pullrequest-aftermerge: false

View File

@@ -103,9 +103,10 @@ jobs:
- name: Build
run: |
echo "=== Running bun build ==="
bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi
echo "=== bun build exit code: $? ==="
echo "=== Running bun build (main) ==="
bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi
echo "=== Running bun build (CLI) ==="
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm
echo "=== Running tsc ==="
tsc --emitDeclarationOnly
echo "=== Running build:schema ==="
@@ -113,8 +114,12 @@ jobs:
- name: Verify build output
run: |
echo "=== dist/ contents ==="
ls -la dist/
echo "=== dist/cli/ contents ==="
ls -la dist/cli/
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
test -f dist/cli/index.js || (echo "ERROR: dist/cli/index.js not found!" && exit 1)
- name: Publish
run: bun run script/publish.ts

376
.github/workflows/sisyphus-agent.yml vendored Normal file
View File

@@ -0,0 +1,376 @@
name: Sisyphus Agent
on:
workflow_dispatch:
inputs:
prompt:
description: "Custom prompt"
required: false
# Only issue_comment works for fork PRs (secrets available)
# pull_request_review/pull_request_review_comment do NOT get secrets for fork PRs
issue_comment:
types: [created]
jobs:
agent:
runs-on: ubuntu-latest
# @sisyphus-dev-ai mention only (maintainers, exclude self)
if: |
github.event_name == 'workflow_dispatch' ||
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
github.event.comment.user.login != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
# Minimal default GITHUB_TOKEN permissions
permissions:
contents: read
steps:
# Checkout with sisyphus-dev-ai's PAT
- uses: actions/checkout@v5
with:
token: ${{ secrets.GH_PAT }}
fetch-depth: 0
# Git config - commits as sisyphus-dev-ai
- name: Configure Git as sisyphus-dev-ai
run: |
git config user.name "sisyphus-dev-ai"
git config user.email "sisyphus-dev-ai@users.noreply.github.com"
# gh CLI auth as sisyphus-dev-ai
- name: Authenticate gh CLI as sisyphus-dev-ai
run: |
echo "${{ secrets.GH_PAT }}" | gh auth login --with-token
gh auth status
- name: Ensure tmux is available (Linux)
if: runner.os == 'Linux'
run: |
set -euo pipefail
if ! command -v tmux >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y --no-install-recommends tmux
fi
tmux -V
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Build local oh-my-opencode
- name: Build oh-my-opencode
run: |
bun install
bun run build
# Install OpenCode + configure local plugin + auth in single step
- name: Setup OpenCode with oh-my-opencode
env:
OPENCODE_AUTH_JSON: ${{ secrets.OPENCODE_AUTH_JSON }}
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
export PATH="$HOME/.opencode/bin:$PATH"
# Install OpenCode (skip if cached)
if ! command -v opencode &>/dev/null; then
echo "Installing OpenCode..."
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
# Try default installer first, fallback to pinned version if it fails
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
if ! bash /tmp/opencode-install.sh 2>&1; then
echo "Default installer failed, trying with pinned version..."
bash /tmp/opencode-install.sh --version 1.0.204
fi
else
echo "Download corrupted, trying direct install with pinned version..."
bash <(curl -fsSL https://opencode.ai/install) --version 1.0.204
fi
fi
opencode --version
# Run local oh-my-opencode install (uses built dist)
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no
# Override plugin to use local file reference
OPENCODE_JSON=~/.config/opencode/opencode.json
REPO_PATH=$(pwd)
jq --arg path "file://$REPO_PATH/src/index.ts" '
.plugin = [.plugin[] | select(. != "oh-my-opencode")] + [$path]
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
OPENCODE_JSON=~/.config/opencode/opencode.json
jq --arg baseURL "$ANTHROPIC_BASE_URL" --arg apiKey "$ANTHROPIC_API_KEY" '
.provider.anthropic = {
"name": "Anthropic",
"npm": "@ai-sdk/anthropic",
"options": {
"baseURL": $baseURL,
"apiKey": $apiKey
},
"models": {
"claude-opus-4-5": {
"id": "claude-opus-4-5-20251101",
"name": "Opus 4.5",
"limit": { "context": 190000, "output": 64000 },
"options": { "effort": "high" }
},
"claude-opus-4-5-high": {
"id": "claude-opus-4-5-20251101",
"name": "Opus 4.5 High",
"limit": { "context": 190000, "output": 128000 },
"options": { "effort": "high", "thinking": { "type": "enabled", "budgetTokens": 64000 } }
},
"claude-sonnet-4-5": {
"id": "claude-sonnet-4-5-20250929",
"name": "Sonnet 4.5",
"limit": { "context": 200000, "output": 64000 }
},
"claude-sonnet-4-5-high": {
"id": "claude-sonnet-4-5-20250929",
"name": "Sonnet 4.5 High",
"limit": { "context": 200000, "output": 128000 },
"options": { "thinking": { "type": "enabled", "budgetTokens": 64000 } }
},
"claude-haiku-4-5": {
"id": "claude-haiku-4-5-20251001",
"name": "Haiku 4.5",
"limit": { "context": 200000, "output": 64000 }
}
}
}
' "$OPENCODE_JSON" > /tmp/oc.json && mv /tmp/oc.json "$OPENCODE_JSON"
OMO_JSON=~/.config/opencode/oh-my-opencode.json
PROMPT_APPEND=$(cat << 'PROMPT_EOF'
## GitHub Actions Environment
You are `sisyphus-dev-ai` in GitHub Actions.
### CRITICAL: GitHub Comments = Your ONLY Output
User CANNOT see console. Post everything via `gh issue comment` or `gh pr comment`.
### Comment Formatting (CRITICAL)
**ALWAYS use heredoc syntax for comments containing code references, backticks, or multiline content:**
```bash
gh issue comment <number> --body "$(cat <<'EOF'
Your comment with `backticks` and code references preserved here.
Multiple lines work perfectly.
EOF
)"
```
**NEVER use direct quotes with backticks** (shell will interpret them as command substitution):
```bash
# WRONG - backticks disappear:
gh issue comment 123 --body "text with `code`"
# CORRECT - backticks preserved:
gh issue comment 123 --body "$(cat <<'EOF'
text with `code`
EOF
)"
```
### GitHub Markdown Rules (MUST FOLLOW)
**Code blocks MUST have EXACTLY 3 backticks and language identifier:**
- CORRECT: ` ```bash ` ... ` ``` `
- WRONG: ` ``` ` (no language), ` ```` ` (4 backticks), ` `` ` (2 backticks)
**Every opening ` ``` ` MUST have a closing ` ``` ` on its own line:**
```
```bash
code here
```
```
**NO trailing backticks or spaces after closing ` ``` `**
**For inline code, use SINGLE backticks:** `code` not ```code```
**Lists inside code blocks break rendering - avoid them or use plain text**
### Rules
- EVERY response = GitHub comment (use heredoc for proper escaping)
- Code changes = PR (never push main/master)
- Setup: bun install first
- Acknowledge immediately, report when done
### Git Config
- user.name: sisyphus-dev-ai
- user.email: sisyphus-dev-ai@users.noreply.github.com
PROMPT_EOF
)
jq --arg append "$PROMPT_APPEND" '.agents.Sisyphus.prompt_append = $append' "$OMO_JSON" > /tmp/omo.json && mv /tmp/omo.json "$OMO_JSON"
mkdir -p ~/.local/share/opencode
echo "$OPENCODE_AUTH_JSON" > ~/.local/share/opencode/auth.json
chmod 600 ~/.local/share/opencode/auth.json
cat "$OPENCODE_JSON"
# Collect context
- name: Collect Context
id: context
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
EVENT_NAME: ${{ github.event_name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
COMMENT_ID_VAL: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
run: |
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
ISSUE_NUM="$ISSUE_NUMBER"
AUTHOR="$COMMENT_AUTHOR"
COMMENT_ID="$COMMENT_ID_VAL"
# Check if PR or Issue
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
else
echo "type=issue" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
fi
fi
echo "comment<<EOF" >> $GITHUB_OUTPUT
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
# Add :eyes: reaction (as sisyphus-dev-ai)
- name: Add eyes reaction
if: steps.context.outputs.comment_id != ''
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
-X POST -f content="eyes" || true
- name: Add working label
if: steps.context.outputs.number != ''
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
gh label create "sisyphus: working" \
--repo "${{ github.repository }}" \
--color "fcf2e1" \
--description "Sisyphus is currently working on this" \
--force || true
if [[ "${{ steps.context.outputs.type }}" == "pr" ]]; then
gh pr edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--add-label "sisyphus: working" || true
else
gh issue edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--add-label "sisyphus: working" || true
fi
- name: Run oh-my-opencode
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
USER_COMMENT: ${{ steps.context.outputs.comment }}
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
REPO_NAME: ${{ github.repository }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
export PATH="$HOME/.opencode/bin:$PATH"
PROMPT=$(cat <<'PROMPT_EOF'
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
## Context
- Type: TYPE_PLACEHOLDER
- Number: #NUMBER_PLACEHOLDER
- Repository: REPO_PLACEHOLDER
- Default Branch: BRANCH_PLACEHOLDER
## User's Request
COMMENT_PLACEHOLDER
---
Write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
PROMPT_EOF
)
PROMPT="${PROMPT//AUTHOR_PLACEHOLDER/$COMMENT_AUTHOR}"
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
stdbuf -oL -eL bun run dist/cli/index.js run "$PROMPT"
# Push changes (as sisyphus-dev-ai)
- name: Push changes
if: always()
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
if [[ -n "$(git status --porcelain)" ]]; then
git add -A
git commit -m "chore: changes by sisyphus-dev-ai" || true
fi
BRANCH=$(git branch --show-current)
if [[ "$BRANCH" != "main" && "$BRANCH" != "master" ]]; then
git push origin "$BRANCH" || true
fi
- name: Update reaction and remove label
if: always()
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: |
if [[ -n "${{ steps.context.outputs.comment_id }}" ]]; then
REACTION_ID=$(gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
--jq '.[] | select(.content == "eyes" and .user.login == "sisyphus-dev-ai") | .id' | head -1)
if [[ -n "$REACTION_ID" ]]; then
gh api -X DELETE "/repos/${{ github.repository }}/reactions/${REACTION_ID}" || true
fi
gh api "/repos/${{ github.repository }}/issues/comments/${{ steps.context.outputs.comment_id }}/reactions" \
-X POST -f content="+1" || true
fi
if [[ -n "${{ steps.context.outputs.number }}" ]]; then
if [[ "${{ steps.context.outputs.type }}" == "pr" ]]; then
gh pr edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--remove-label "sisyphus: working" || true
else
gh issue edit "${{ steps.context.outputs.number }}" \
--repo "${{ github.repository }}" \
--remove-label "sisyphus: working" || true
fi
fi

122
AGENTS.md
View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2025-12-22T02:23:00+09:00
**Commit:** aad7a72
**Generated:** 2025-12-28T19:26:00+09:00
**Commit:** 122e918
**Branch:** dev
## OVERVIEW
@@ -13,14 +13,14 @@ OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orc
```
oh-my-opencode/
├── src/
│ ├── agents/ # AI agents (Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker)
│ ├── hooks/ # 21 lifecycle hooks (comment-checker, rules-injector, keyword-detector, etc.)
│ ├── tools/ # LSP (11), AST-Grep, Grep, Glob, background-task, look-at, skill, slashcommand, interactive-bash, call-omo-agent
│ ├── mcp/ # MCP servers (context7, websearch_exa, grep_app)
│ ├── features/ # Background agent, Claude Code loaders (agent, command, skill, mcp, session-state), hook-message-injector
│ ├── agents/ # AI agents (7): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker
│ ├── hooks/ # 21 lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, etc. - see src/tools/AGENTS.md
│ ├── mcp/ # MCP servers: context7, websearch_exa, grep_app
│ ├── features/ # Claude Code compatibility - see src/features/AGENTS.md
│ ├── config/ # Zod schema, TypeScript types
│ ├── auth/ # Google Antigravity OAuth
│ ├── shared/ # Utilities (deep-merge, pattern-matcher, logger, etc.)
│ ├── auth/ # Google Antigravity OAuth (antigravity/)
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc.
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
├── assets/ # JSON schema
@@ -31,12 +31,12 @@ oh-my-opencode/
| Task | Location | Notes |
|------|----------|-------|
| Add new agent | `src/agents/` | Create .ts file, add to builtinAgents in index.ts, update types.ts |
| Add new hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
| Add new tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
| Add MCP server | `src/mcp/` | Create config, add to index.ts |
| Modify LSP behavior | `src/tools/lsp/` | client.ts for connection, tools.ts for handlers |
| AST-Grep patterns | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
| Add MCP | `src/mcp/` | Create config, add to index.ts |
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google models |
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
@@ -50,98 +50,72 @@ oh-my-opencode/
- **Build**: Dual output - `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern - `export * from "./module"` in index.ts
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
- **Tool structure**: Each tool has index.ts, types.ts, constants.ts, tools.ts, utils.ts
- **Tool structure**: index.ts, types.ts, constants.ts, tools.ts, utils.ts
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA pattern)
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA)
## ANTI-PATTERNS (THIS PROJECT)
- **npm/yarn**: Use bun exclusively
- **@types/node**: Use bun-types
- **Bash file operations**: Never use mkdir/touch/rm/cp/mv for file creation in code
- **Generic AI aesthetics**: No Space Grotesk, avoid typical AI-generated UI patterns
- **Direct bun publish**: Use GitHub Actions workflow_dispatch only (OIDC provenance)
- **Local version bump**: Version managed by CI workflow, never modify locally
- **Bash file ops**: Never mkdir/touch/rm/cp/mv for file creation in code
- **Direct bun publish**: GitHub Actions workflow_dispatch only (OIDC provenance)
- **Local version bump**: Version managed by CI workflow
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
- **Rush completion**: Never mark tasks complete without verification
- **Interrupting work**: Complete tasks fully before stopping
- **Over-exploration**: Stop searching when sufficient context found
## UNIQUE STYLES
- **Platform handling**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
- **Optional props**: Extensive use of `?` for optional interface properties
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
- **Optional props**: Extensive `?` for optional interface properties
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
- **Error handling**: Consistent try/catch with async/await in all tools
- **Agent tools restriction**: Use `tools: { include: [...] }` or `tools: { exclude: [...] }`
- **Error handling**: Consistent try/catch with async/await
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
- **Temperature**: Most agents use `0.1` for consistency
- **Hook naming**: `createXXXHook` function naming convention
- **Date references**: NEVER use 2024 in code/prompts (use current year)
- **Hook naming**: `createXXXHook` function convention
## AGENT MODELS
| Agent | Model | Purpose |
|-------|-------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator, team leader |
| oracle | openai/gpt-5.2 | Strategic advisor, code review, architecture |
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs lookup, GitHub examples |
| explore | opencode/grok-code | Fast codebase exploration, file patterns |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation, design-focused |
| document-writer | google/gemini-3-pro-preview | Technical documentation |
| multimodal-looker | google/gemini-2.5-flash | PDF/image/diagram analysis |
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
| oracle | openai/gpt-5.2 | Strategic advisor, code review |
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
| explore | opencode/grok-code | Fast codebase exploration |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
| document-writer | google/gemini-3-pro-preview | Technical docs |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
## COMMANDS
```bash
# Type check
bun run typecheck
# Build (ESM + declarations + schema)
bun run build
# Clean + Build
bun run rebuild
# Build schema only
bun run build:schema
# Run tests
bun test
bun run typecheck # Type check
bun run build # ESM + declarations + schema
bun run rebuild # Clean + Build
bun run build:schema # Schema only
bun test # Run tests
```
## DEPLOYMENT
**GitHub Actions workflow_dispatch only**
1. package.json version NOT modified locally (auto-bumped by workflow)
1. Never modify package.json version locally
2. Commit & push changes
3. Trigger `publish` workflow manually:
- `bump`: major | minor | patch
- `version`: (optional) specific version override
3. Trigger `publish` workflow: `gh workflow run publish -f bump=patch`
```bash
# Trigger via CLI
gh workflow run publish -f bump=patch
# Check status
gh run list --workflow=publish
```
**Critical**:
- Never run `bun publish` directly (OIDC provenance issue)
- Never bump version locally
**Critical**: Never `bun publish` directly. Never bump version locally.
## CI PIPELINE
- **ci.yml**: Parallel test/typecheck jobs, build verification, auto-commit schema changes on master
- **publish.yml**: Manual workflow_dispatch, version bump, changelog generation, OIDC npm publishing
- Schema auto-commit prevents build drift
- Draft release creation on dev branch
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
## NOTES
- **Testing**: Bun native test framework (`bun test`), BDD-style with `#given/#when/#then` comments
- **OpenCode version**: Requires >= 1.0.150 (earlier versions have config bugs)
- **Multi-language docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
- **Config locations**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
- **Schema autocomplete**: Add `$schema` field in config for IDE support
- **Trusted dependencies**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`
- **OpenCode**: Requires >= 1.0.150
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker

58
CLA.md Normal file
View File

@@ -0,0 +1,58 @@
# Contributor License Agreement
Thank you for your interest in contributing to oh-my-opencode ("Project"), owned by YeonGyu Kim ("Owner").
By signing this Contributor License Agreement ("Agreement"), you agree to the following terms:
## 1. Definitions
- **"Contribution"** means any original work of authorship, including any modifications or additions to existing work, that you submit to the Project.
- **"Submit"** means any form of communication sent to the Project, including but not limited to pull requests, issues, commits, and documentation changes.
## 2. Grant of Rights
By submitting a Contribution, you grant the Owner:
1. **Copyright License**: A perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your Contributions and such derivative works.
2. **Patent License**: A perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Contribution.
3. **Relicensing Rights**: The right to relicense the Contribution under any license, including proprietary licenses, without requiring additional permission from you.
## 3. Representations
You represent that:
1. You are legally entitled to grant the above licenses.
2. Each Contribution is your original creation or you have sufficient rights to submit it.
3. Your Contribution does not violate any third party's intellectual property rights.
4. If your employer has rights to intellectual property that you create, you have received permission to make Contributions on behalf of that employer.
## 4. No Obligation
You understand that:
1. The Owner is not obligated to use or include your Contribution.
2. The decision to include any Contribution is at the sole discretion of the Owner.
3. You are not entitled to any compensation for your Contributions.
## 5. Future License Changes
You acknowledge and agree that:
1. The Project may change its license in the future.
2. Your Contributions may be distributed under a different license than the one in effect at the time of your Contribution.
3. This includes, but is not limited to, relicensing under source-available or proprietary licenses.
## 6. Miscellaneous
- This Agreement is governed by the laws of the Republic of Korea.
- This Agreement represents the entire agreement between you and the Owner concerning Contributions.
---
## How to Sign
By submitting a pull request to this repository, you agree to the terms of this Contributor License Agreement. The CLA Assistant bot will automatically track your agreement.
If you have any questions, please open an issue or contact the Owner.

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 YeonGyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

82
LICENSE.md Normal file
View File

@@ -0,0 +1,82 @@
# License
Portions of this software are licensed as follows:
- All third party components incorporated into the oh-my-opencode Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensor's trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The "licensor" is the entity offering these terms.
The "software" is the software the licensor makes available under these terms, including any portion of it.
"You" refers to the individual or entity agreeing to these terms.
"Your company" is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
"Your license" is the license granted to you for the software under these terms.
"Use" means anything you do with the software requiring your license.
"Trademark" means trademarks, service marks, and similar rights.

View File

@@ -1,4 +1,13 @@
お知らせ: oh-my-opencodeに関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、[@justsisyphus](https://x.com/justsisyphus)が代わりに管理しています。
> [!NOTE]
>
> *「私はエージェントが生成したコードと人間が書いたコードを区別できない、しかしはるかに多くのことを達成できる世界を作り、ソフトウェア革命を起こすことを目指しています。私はこの旅に個人的な時間、情熱、そして資金を注ぎ込んできましたし、これからもそうし続けます。」*
>
> 一緒に歩みましょう!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -23,7 +32,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -31,6 +40,26 @@
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## ユーザーレビュー
> "人間が3ヶ月かかる仕事をClaude Codeが7日でやるなら、Sisyphusは1時間でやります。タスクが完了するまでただ動き続ける。It is a discipline agent." — B, Quant Researcher
> "Oh My Opencodeを使って、たった1日で8000個のeslint警告を解消しました" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "これをコアに取り入れて彼を採用すべきです。マジで。本当に、本当に、本当に良いです" — Henning Kilset
> "@yeon_gyu_kimを説得できるなら雇うべきです。彼はopencodeに革命を起こしました" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "やばい、これマジで本物だ @androolloyd oh my opencode 最高すぎる" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "oh-my-opencodeを使ってください、もう戻れませんよ" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目次
- [Oh My OpenCode](#oh-my-opencode)
@@ -157,76 +186,23 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
あなたがユーザーの oh-my-opencode 設定を支援する LLM エージェントなら、以下の手順に従ってください:
### ステップ 0: セットアップについてユーザーに尋ねる
### ステップ 0: サブスクリプション情報を確認
OpenCode は様々なモデルをサポートしており、当プラグインは様々な最先端 LLM の使用を提案しています。そのため、ユーザーに以下の質問をする必要があります。
以下の 3 つの質問をして CLI オプションを決定してください:
**重要**: ユーザーがどのプロバイダーを持っているか追跡してください。ユーザーが Claude を持っていると確認した場合のみ、`anthropic/claude-opus-4-5` をフォールバックとして使用できます。Claude がない場合は、代わりに `opencode/big-pickle` をフォールバックとして使用してください。
1. **Claude Pro/Max のサブスクリプションをお持ちですか?**
- **はい**: **max20 (20x モード) を使用していますか?**
- **max20** → `--claude=max20`
- **max20 ではない** → `--claude=yes`
- **いいえ** → `--claude=no`
1. **Claude Pro/Max のサブスクリプションをお持ちですか?** お持ちでない場合、メインオーケストレーターモデルとして 'opencode/big-pickle' (GLM 4.6 として知られる) を使用することになります (非推奨)。
- ユーザーが**はい**と答えた場合、追加質問: **max20 (20x モード) を使用していますか?**
- **「はい」 (max20)**: ToDo 追加: "ユーザーの Claude Pro/Max 認証設定" — librarian に Claude Sonnet 4.5 をフル使用。**記憶: Claude がフォールバックとして使用可能。**
- **「いいえ」 (max20 ではない)**: ToDo 追加: "ユーザーの Claude Pro/Max 認証設定"、そして `~/.config/opencode/oh-my-opencode.json` に以下をマージ。**記憶: Claude がフォールバックとして使用可能。**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- ユーザーが**「いいえ」** (Claude サブスクリプションなし) と答えた場合、`~/.config/opencode/oh-my-opencode.json` に以下を保存。**記憶: Claude は使用不可 — すべてのフォールバックは `opencode/big-pickle` を使用する必要があります。**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **ChatGPT のサブスクリプションをお持ちですか?**
- **はい**`--chatgpt=yes`
- **いいえ** → `--chatgpt=no`
2. **ChatGPT のサブスクリプションをお持ちですか?** お持ちでない場合、oracle エージェントにフォールバックモデルを使用することになります。
- ユーザーが**はい**と答えた場合、ToDo 追加: "ユーザーの ChatGPT 認証設定"
- ユーザーが**いいえ**と答えた場合:
- **Claude が使用可能な場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude が使用不可の場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini モデルを統合しますか?** 統合しない場合、美しいデザインを享受できません。
- ユーザーが**「はい」**と答えた場合、ToDo 追加: "Google OAuth 設定"
- ユーザーが**「いいえ」**と答えた場合:
- **Claude が使用可能な場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude が使用不可の場合**: `oh-my-opencode.json` に以下をマージ:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini モデルを使用しますか?**
- **はい**`--gemini=yes`
- **いいえ**`--gemini=no`
### ステップ 1: OpenCode インストール確認
@@ -241,36 +217,28 @@ fi
OpenCode がインストールされていない場合は、[OpenCode インストールガイド](https://opencode.ai/docs)を確認し、インストールを進めてください。コンテキストを節約するため、サブエージェントを呼び出してインストールを行わせ、その結果を報告させるように案内してください。
### ステップ 2: oh-my-opencode プラグイン設定
### ステップ 2: インストーラーを実行
#### JSONC
- `~/.config/opencode/opencode.jsonc` が存在する場合は、そこで `oh-my-opencode` プラグインを追加してください。
#### JSON
- opencode.jsonc が存在しない場合は、以下のコマンドを実行して作成・登録してください。
ユーザーの回答に基づいて、適切なフラグで CLI インストーラーを実行してください:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# 既存の設定ファイルがある場合: jq または python で plugin を追加
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "~/.config/opencode/opencode.json の plugin 配列に \"oh-my-opencode\" を追加してください"
fi
else
# 新しい設定ファイル作成
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**例:**
- すべてのサブスクリプション + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- Claude のみmax20 なし): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- サブスクリプションなし: `bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
CLI が行うこと:
- `opencode.json` にプラグインを登録
- サブスクリプションフラグに基づいてエージェントモデルを設定
- 必要な認証ステップを表示
### ステップ 3: 設定確認
```bash
opencode --version # 1.0.132 以上であること
opencode --version # 1.0.150 以上であること
cat ~/.config/opencode/opencode.json # plugin 配列に "oh-my-opencode" が含まれていること
```
@@ -317,12 +285,12 @@ opencode auth login
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
**利用可能なモデル名**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-2.5-flash`, `google/gemini-2.5-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**利用可能なモデル名**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
その後、認証を行います:
@@ -422,17 +390,50 @@ gh repo star code-yeongyu/oh-my-opencode
</details>
## アンインストール
oh-my-opencode を削除するには:
1. **OpenCode 設定からプラグインを削除**
`~/.config/opencode/opencode.json` (または `opencode.jsonc`) を編集し、`plugin` 配列から `"oh-my-opencode"` を削除します:
```bash
# jq を使用する例
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **設定ファイルの削除 (オプション)**
```bash
# ユーザー設定を削除
rm -f ~/.config/opencode/oh-my-opencode.json
# プロジェクト設定を削除 (存在する場合)
rm -f .opencode/oh-my-opencode.json
```
3. **削除の確認**
```bash
opencode --version
# プラグインがロードされなくなっているはずです
```
## 機能
### Agents: あなたの新しいチームメイト
- **Sisyphus** (`anthropic/claude-opus-4-5`): **デフォルトエージェントです。** OpenCode のための強力な AI オーケストレーターです。専門のサブエージェントを活用して、複雑なタスクを計画、委任、実行します。バックグラウンドタスクへの委任と Todo ベースのワークフローを重視します。最大の推論能力を発揮するため、Claude Opus 4.5 と拡張思考 (32k token budget) を使用します。
- **oracle** (`openai/gpt-5.2`): アーキテクチャ、コードレビュー、戦略立案のための専門アドバイザー。GPT-5.2 の卓越した論理的推論と深い分析能力を活用します。AmpCode からインスピレーションを得ました。
- **librarian** (`anthropic/claude-sonnet-4-5`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
- **explore** (`opencode/grok-code`): 高速なコードベース探索、ファイルパターンマッチング。Claude Code は Haiku を使用しますが、私たちは Grok を使います。現在無料であり、極めて高速で、ファイル探索タスクには十分な知能を備えているからです。Claude Code からインスピレーションを得ました。
- **librarian** (`anthropic/claude-sonnet-4-5` または `google/gemini-3-flash`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、それ以外は Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
- **explore** (`opencode/grok-code`、`google/gemini-3-flash`、または `anthropic/claude-haiku-4-5`): 高速なコードベース探索、ファイルパターンマッチング。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、Claude max20 が利用可能な場合は Haiku を使用し、それ以外は Grok を使います。Claude Code からインスピレーションを得ました。
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 開発者に転身したデザイナーという設定です。素晴らしい UI を作ります。美しく独創的な UI コードを生成することに長けた Gemini を使用します。
- **document-writer** (`google/gemini-3-pro-preview`): テクニカルライティングの専門家という設定です。Gemini は文筆家であり、流れるような文章を書きます。
- **multimodal-looker** (`google/gemini-2.5-flash`): 視覚コンテンツ解釈のための専門エージェント。PDF、画像、図表を分析して情報を抽出します。
- **multimodal-looker** (`google/gemini-3-flash`): 視覚コンテンツ解釈のための専門エージェント。PDF、画像、図表を分析して情報を抽出します。
メインエージェントはこれらを自動的に呼び出しますが、明示的に呼び出すことも可能です:
@@ -489,6 +490,19 @@ Ask @explore for the policy on this feature
- **ast_grep_search**: AST 認識コードパターン検索 (25言語対応)
- **ast_grep_replace**: AST 認識コード置換
#### セッション管理
OpenCode セッション履歴をナビゲートおよび検索するためのツール:
- **session_list**: 日付およびリミットでフィルタリングしながらすべての OpenCode セッションを一覧表示
- **session_read**: 特定のセッションからメッセージと履歴を読み取る
- **session_search**: セッションメッセージ全体を全文検索
- **session_info**: セッションに関するメタデータと統計情報を取得
これらのツールにより、エージェントは以前の会話を参照し、セッション間の継続性を維持できます。
- **call_omo_agent**: 専門的な explore/librarian エージェントを起動。非同期実行のための `run_in_background` パラメータをサポート。
#### Context Is All You Need
- **Directory AGENTS.md / README.md Injector**: ファイルを読み込む際、`AGENTS.md` と `README.md` の内容を自動的に注入します。ファイルディレクトリからプロジェクトルートまで遡り、パス上の **すべて** の `AGENTS.md` ファイルを収集します。ネストされたディレクトリごとの指示をサポートします:
```
@@ -621,6 +635,12 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
エージェントが活躍すれば、あなたも幸せになります。ですが、私はあなた自身も助けたいのです。
- **Ralph Loop**: タスクが完了するまで実行し続ける自己参照型開発ループ。Anthropic の Ralph Wiggum プラグインにインスパイアされています。**すべてのプログラミング言語をサポート。**
- `/ralph-loop "REST API を構築"` で開始するとエージェントが継続的に作業します
- `<promise>DONE</promise>` の出力で完了を検知
- 完了プロミスなしで停止すると自動再開
- 終了条件: 完了検知、最大反復回数到達(デフォルト 100、または `/cancel-ralph`
- `oh-my-opencode.json` で設定: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **Keyword Detector**: プロンプト内のキーワードを自動検知して専門モードを有効化します:
- `ultrawork` / `ulw`: 並列エージェントオーケストレーションによる最大パフォーマンスモード
- `search` / `find` / `찾아` / `検索`: 並列 explore/librarian エージェントによる検索最大化
@@ -633,8 +653,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
- **Agent Usage Reminder**: 検索ツールを直接呼び出す際、バックグラウンドタスクを通じた専門エージェントの活用を推奨するリマインダーを表示します。
- **Anthropic Auto Compact**: Claude モデルがトークン制限に達すると、自動的にセッションを要約・圧縮します。手動での介入は不要です。
- **Session Recovery**: セッションエラーツールの結果欠落、thinking ブロックの問題、空のメッセージなど)から自動復旧します。セッションが途中でクラッシュすることはありません。もしクラッシュしても復旧します。
- **Auto Update Checker**: oh-my-opencode の新バージョンがリリースされると通知します
- **Startup Toast**: OhMyOpenCode ロード時にウェルカムメッセージを表示します。セッションを正しく始めるための、ささやかな "oMoMoMo" です。
- **Auto Update Checker**: oh-my-opencode の新バージョンを自動でチェックし、設定を自動更新できます。現在のバージョンと Sisyphus ステータスを表示する起動トースト通知を表示しますSisyphus 有効時は「Sisyphus on steroids is steering OpenCode」、無効時は「OpenCode is now on Steroids. oMoMoMoMo...」)。全機能を無効化するには `disabled_hooks` に `"auto-update-checker"` を、トースト通知のみ無効化するには `"startup-toast"` を追加してください。[設定 > フック](#フック) 参照
- **Background Notification**: バックグラウンドエージェントのタスクが完了すると通知を受け取ります。
- **Session Notification**: エージェントがアイドル状態になると OS 通知を送ります。macOS、Linux、Windows で動作します—エージェントが入力を待っている時を見逃しません。
- **Empty Task Response Detector**: Task ツールが空の応答を返すと検知します。既に空の応答が返ってきているのに、いつまでも待ち続ける状況を防ぎます。
@@ -652,7 +671,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
| プラットフォーム | ユーザー設定パス |
|------------------|------------------|
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (優先) または `%APPDATA%\opencode\oh-my-opencode.json` (フォールバック) |
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (推奨) または `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
スキーマ自動補完がサポートされています:
@@ -663,6 +682,36 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
### JSONC のサポート
`oh-my-opencode` 設定ファイルは JSONC (コメント付き JSON) をサポートしています:
- 行コメント: `// コメント`
- ブロックコメント: `/* コメント */`
- 末尾のカンマ: `{ "key": "value", }`
`oh-my-opencode.jsonc` と `oh-my-opencode.json` の両方が存在する場合、`.jsonc` が優先されます。
**コメント付きの例:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Antigravity OAuth 経由で Google Gemini を有効にする
"google_auth": false,
/* エージェントのオーバーライド - 特定のタスクに合わせてモデルをカスタマイズ */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // 戦略的な推論のための GPT
},
"explore": {
"model": "opencode/grok-code" // 探索のための高速かつ無料のモデル
},
},
}
```
### Google Auth
**推奨**: 外部の [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) プラグインを使用してください。マルチアカウントロードバランシング、より多くのモデルAntigravity 経由の Claude を含む)、活発なメンテナンスを提供します。[インストール > Google Gemini](#42-google-gemini-antigravity-oauth) を参照。
@@ -675,7 +724,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
@@ -748,24 +797,48 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
### Sisyphus Agent
有効時デフォルト、Sisyphus は2つのプライマリエージェントを追加し、内蔵エージェントをサブエージェントに降格させます:
有効時デフォルト、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
- **Planner-Sisyphus**: OpenCode の plan エージェントの全設定を実行時に継承 (description に "OhMyOpenCode version" を追加)
- **build**: サブエージェントに降格
- **plan**: サブエージェントに降格
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェントSDK 制限により名前変更、デフォルトで無効)
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェントSDK 制限により名前変更、デフォルトで有効)
Sisyphus を無効化して元の build/plan エージェントを復元するには:
**設定オプション:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**例Builder-Sisyphus を有効化:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
**例:すべての Sisyphus オーケストレーションを無効化:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
他のエージェント同様、Sisyphus と Planner-Sisyphus もカスタマイズ可能です:
他のエージェント同様、Sisyphus エージェントもカスタマイズ可能です:
```json
{
@@ -774,6 +847,9 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -781,9 +857,12 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
}
```
| オプション | デフォルト | 説明 |
|------------|------------|------|
| `disabled` | `false` | `true` の場合、Sisyphus エージェントを無効化し、元の build/plan をプライマリとして復元します。`false` (デフォルト) の場合、Sisyphus と Planner-Sisyphus がプライマリエージェントになります。 |
| オプション | デフォルト | 説明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化しますOpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化しますOpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
### Hooks
@@ -795,7 +874,9 @@ Sisyphus を無効化して元の build/plan エージェントを復元する
}
```
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
### MCPs
@@ -845,16 +926,22 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
```json
{
"experimental": {
"preemptive_compaction": true,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
}
}
```
| オプション | デフォルト | 説明 |
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| オプション | デフォルト | 説明 |
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | トークン制限に達する前にセッションを事前にコンパクションします。デフォルトでコンテキストウィンドウ使用率80%で実行されます。 |
| `preemptive_compaction_threshold` | `0.80` | プリエンプティブコンパクションをトリガーする閾値0.5-0.95)。`preemptive_compaction`が有効な場合のみ適用されます。 |
| `truncate_all_tool_outputs` | `false` | ホワイトリストのツールGrep、Glob、LSP、AST-grepだけでなく、すべてのツール出力を切り詰めます。Tool output truncator はデフォルトで有効です - `disabled_hooks`で無効化できます。 |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| `dcp_for_compaction` | `false` | コンパクション用DCP動的コンテキスト整理を有効化 - トークン制限超過時に最初に実行されます。コンパクション前に重複したツール呼び出しと古いツール出力を整理します。 |
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
@@ -902,11 +989,16 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
- [修正 PR](https://github.com/sst/opencode/pull/5040) は 1.0.132 以降にマージされたため、新しいバージョンを使用してください。
- 余談:この PR も、OhMyOpenCode の Librarian、Explore、Oracle セットアップを活用して偶然発見され、修正されました。
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*
## こちらの企業の専門家にご愛用いただいています
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## スポンサー
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 最初のスポンサー
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*

View File

@@ -1,4 +1,13 @@
공지: oh-my-opencode 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 [@justsisyphus](https://x.com/justsisyphus)가 대신 관리하고 있습니다.
> [!NOTE]
>
> *"저는 에이전트가 생성한 코드와 인간이 작성한 코드를 구분할 수 없으면서도, 훨씬 더 많은 것을 달성할 수 있는 세상을 만들어 소프트웨어 혁명을 일으키고자 합니다. 저는 이 여정에 개인적인 시간, 열정, 그리고 자금을 쏟아부었고, 앞으로도 계속 그렇게 할 것입니다."*
>
> 함께해주세요!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -20,7 +29,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -28,6 +37,26 @@
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## 사용자 후기
> "인간이 3달 동안 할 일을 claude code 가 7일만에 해준다면, 시지푸스는 1시간만에 해준다. 작업이 완료되기 전까지 그저 잘 작동한다. It is a discipline agent." — B, Quant Researcher
> "Oh My Opencode를 사용해서, 단 하루만에 8000개의 eslint 경고를 해결했습니다" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "이걸 코어에 넣고 그를 채용해야 합니다. 진심으로요. 이건 정말, 정말, 정말 좋습니다." — Henning Kilset
> "@yeon_gyu_kim 을 설득할 수 있다면 고용하세요, 이 사람은 opencode를 혁신했습니다." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "와 미쳤다 @androolloyd 이건 진짜다 oh my opencode 개쩐다" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "oh-my-opencode를 쓰세요, 절대 돌아갈 수 없을 겁니다" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 목차
- [Oh My OpenCode](#oh-my-opencode)
@@ -154,76 +183,23 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
당신이 사용자의 oh-my-opencode 설정을 돕는 LLM Agent라면, 다음 단계를 따르세요. 업데이트 과정이라고 할지라도 아래의 내용을 그대로 따르세요. 단, 설치 과정에서 이미 답했을 질문은 또 하지 않습니다.
### 0단계: 사용자에게 설정에 대해 물어보세요.
### 0단계: 구독 정보 확인
OpenCode는 다양한 모델을 지원하며, 이 플러그인은 다양한 프론티어 LLM 사용을 제안합니다. 따라서 사용자에게 다음 질문을 해야 합니다.
사용자에게 다음 3가지 질문을 하여 CLI 옵션을 결정하세요:
**중요**: 사용자가 어떤 프로바이더를 가지고 있는지 추적하세요. 사용자가 Claude가 있다고 확인한 경우에만 `anthropic/claude-opus-4-5`를 폴백으로 사용할 수 있습니다. Claude가 없다면 `opencode/big-pickle`을 폴백으로 사용해야 합니다.
1. **Claude Pro/Max 구독이 있으신가요?**
- **예**: **max20 (20x 모드)를 사용하고 계신가요?**
- **max20** → `--claude=max20`
- **max20 아님** → `--claude=yes`
- **아니오** → `--claude=no`
1. **Claude Pro/Max 구독이 있으신가요?** 그렇지 않다면 메인 오케스트레이터 모델로 'opencode/big-pickle'(GLM 4.6으로 알려짐)을 사용하게 됩니다 (권장하지 않음).
- 사용자가 **'예'**라고 답하면, 추가 질문: **max20 (20x 모드)를 사용하고 계신가요?**
- **'예' (max20)**: 할 일 추가: "사용자 Claude Pro/Max 인증 설정" — librarian에 Claude Sonnet 4.5 전체 사용. **기억: Claude를 폴백으로 사용 가능.**
- **'아니오' (max20 아님)**: 할 일 추가: "사용자 Claude Pro/Max 인증 설정", 그리고 `~/.config/opencode/oh-my-opencode.json`에 다음을 병합. **기억: Claude를 폴백으로 사용 가능.**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- 사용자가 **'아니오'** (Claude 구독 없음)라고 답하면, `~/.config/opencode/oh-my-opencode.json`에 다음을 저장. **기억: Claude 사용 불가 — 모든 폴백은 `opencode/big-pickle`을 사용해야 함.**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **ChatGPT 구독이 있으신가요?**
- **예** → `--chatgpt=yes`
- **아니오** → `--chatgpt=no`
2. **ChatGPT 구독이 있으신가요?** 그렇지 않다면 oracle 에이전트에 폴백 모델을 사용하게 됩니다.
- 사용자가 **'예'**라고 답하면, 할 일 추가: "사용자 ChatGPT 인증 설정"
- 사용자가 **'아니오'**라고 답하면:
- **Claude가 사용 가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude가 사용 불가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini 모델을 통합하시겠습니까?** 그렇지 않다면 아름다운 디자인을 즐길 수 없습니다.
- 사용자가 **'예'**라고 답하면, 할 일 추가: "Google OAuth 설정"
- 사용자가 **'아니오'**라고 답하면:
- **Claude가 사용 가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **Claude가 사용 불가능한 경우**: `oh-my-opencode.json`에 다음을 병합:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
3. **Gemini 모델을 사용하시겠습니까?**
- **예** → `--gemini=yes`
- **아니오**`--gemini=no`
### 1단계: OpenCode 설치 확인
@@ -238,36 +214,28 @@ fi
OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://opencode.ai/docs)를 읽고, 설치를 진행하세요. 컨텍스트를 줄이기 위해 Subagent 로 호출하여 설치를 하라고 안내하고, 그 결과를 보고 받는다고 말하세요.
### 2단계: oh-my-opencode 플러그인 설정
### 2단계: 설치 프로그램 실행
#### JSONC
- `~/.config/opencode/opencode.jsonc`가 존재한다면, 그곳에 `oh-my-opencode` 플러그인을 추가하세요.
#### JSON
- opencode.jsonc가 없다면, 다음 명령어를 실행하여 생성하고 등록하세요.
사용자의 답변을 바탕으로 적절한 플래그와 함께 CLI 설치 프로그램을 실행하세요:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# 기존 설정 파일이 있는 경우: jq 또는 python으로 plugin 추가
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "~/.config/opencode/opencode.json의 plugin 배열에 \"oh-my-opencode\"를 추가하세요"
fi
else
# 새 설정 파일 생성
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**예시:**
- 모든 구독 + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- Claude만 (max20 없음): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- 구독 없음: `bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
CLI가 수행하는 작업:
- `opencode.json`에 플러그인 등록
- 구독 플래그에 따라 에이전트 모델 설정
- 필요한 인증 단계 안내
### 3단계: 설정 확인
```bash
opencode --version # 1.0.132 이상이어야 함
opencode --version # 1.0.150 이상이어야 함
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
```
@@ -314,12 +282,12 @@ opencode auth login
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
**사용 가능한 모델 이름**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-2.5-flash`, `google/gemini-2.5-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**사용 가능한 모델 이름**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
그 후 인증:
@@ -419,17 +387,50 @@ gh repo star code-yeongyu/oh-my-opencode
</details>
## 언인스톨
oh-my-opencode를 제거하려면:
1. **OpenCode 설정에서 플러그인 제거**
`~/.config/opencode/opencode.json` (또는 `opencode.jsonc`)를 편집하여 `plugin` 배열에서 `"oh-my-opencode"`를 제거합니다:
```bash
# jq 사용 예시
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **설정 파일 삭제 (선택 사항)**
```bash
# 사용자 설정 삭제
rm -f ~/.config/opencode/oh-my-opencode.json
# 프로젝트 설정 삭제 (존재하는 경우)
rm -f .opencode/oh-my-opencode.json
```
3. **제거 확인**
```bash
opencode --version
# 플러그인이 더 이상 로드되지 않아야 합니다
```
## 기능
### Agents: 당신의 새로운 팀원들
- **Sisyphus** (`anthropic/claude-opus-4-5`): **기본 에이전트입니다.** OpenCode를 위한 강력한 AI 오케스트레이터입니다. 전문 서브에이전트를 활용하여 복잡한 작업을 계획, 위임, 실행합니다. 백그라운드 태스크 위임과 todo 기반 워크플로우를 강조합니다. 최대 추론 능력을 위해 Claude Opus 4.5와 확장된 사고(32k 버짓)를 사용합니다.
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **librarian** (`anthropic/claude-sonnet-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
- **librarian** (`anthropic/claude-sonnet-4-5` 또는 `google/gemini-3-flash`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, 그렇지 않으면 Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, 또는 `anthropic/claude-haiku-4-5`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, Claude max20이 있으면 Haiku를 사용하며, 그 외에는 Grok을 씁니다. Claude Code 에서 영감을 받았습니다.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
- **multimodal-looker** (`google/gemini-2.5-flash`): 시각적 콘텐츠 해석을 위한 전문 에이전트. PDF, 이미지, 다이어그램을 분석하여 정보를 추출합니다.
- **multimodal-looker** (`google/gemini-3-flash`): 시각적 콘텐츠 해석을 위한 전문 에이전트. PDF, 이미지, 다이어그램을 분석하여 정보를 추출합니다.
각 에이전트는 메인 에이전트가 알아서 호출하지만, 명시적으로 요청할 수도 있습니다:
@@ -482,6 +483,18 @@ Syntax Highlighting, Autocomplete, Refactoring, Navigation, Analysis, 그리고
- **lsp_code_action_resolve**: 코드 액션 적용
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
- **ast_grep_replace**: AST 인식 코드 교체
- **call_omo_agent**: 전문 explore/librarian 에이전트를 생성합니다. 비동기 실행을 위한 `run_in_background` 파라미터를 지원합니다.
#### 세션 관리 (Session Management)
OpenCode 세션 히스토리를 탐색하고 검색하기 위한 도구들입니다:
- **session_list**: 날짜 및 개수 제한 필터링을 포함한 모든 OpenCode 세션 목록 조회
- **session_read**: 특정 세션의 메시지 및 히스토리 읽기
- **session_search**: 세션 메시지 전체 텍스트 검색
- **session_info**: 세션에 대한 메타데이터 및 통계 정보 조회
이 도구들을 통해 에이전트는 이전 대화를 참조하고 세션 간의 연속성을 유지할 수 있습니다.
#### Context is all you need.
- **Directory AGENTS.md / README.md Injector**: 파일을 읽을 때 `AGENTS.md`, `README.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
@@ -615,6 +628,12 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
에이전트들이 행복해지면, 당신이 제일 행복해집니다, 그렇지만 저는 당신도 돕고싶습니다.
- **Ralph Loop**: 작업이 완료될 때까지 계속 실행되는 자기 참조 개발 루프. Anthropic의 Ralph Wiggum 플러그인에서 영감을 받았습니다. **모든 프로그래밍 언어 지원.**
- `/ralph-loop "REST API 구축"`으로 시작하면 에이전트가 지속적으로 작업합니다
- `<promise>DONE</promise>` 출력 시 완료로 감지
- 완료 프라미스 없이 멈추면 자동 재시작
- 종료 조건: 완료 감지, 최대 반복 도달 (기본 100회), 또는 `/cancel-ralph`
- `oh-my-opencode.json`에서 설정: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **Keyword Detector**: 프롬프트의 키워드를 자동 감지하여 전문 모드를 활성화합니다:
- `ultrawork` / `ulw`: 병렬 에이전트 오케스트레이션으로 최대 성능 모드
- `search` / `find` / `찾아` / `検索`: 병렬 explore/librarian 에이전트로 검색 극대화
@@ -627,14 +646,17 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- **Agent Usage Reminder**: 검색 도구를 직접 호출할 때, 백그라운드 작업을 통한 전문 에이전트 활용을 권장하는 리마인더를 표시합니다.
- **Anthropic Auto Compact**: Claude 모델이 토큰 제한에 도달하면 자동으로 세션을 요약하고 압축합니다. 수동 개입 없이 작업을 계속할 수 있습니다.
- **Session Recovery**: 세션 에러(누락된 도구 결과, thinking 블록 문제, 빈 메시지 등)에서 자동 복구합니다. 돌다가 세션이 망가지지 않습니다. 망가져도 복구됩니다.
- **Auto Update Checker**: oh-my-opencode의 새 버전이 출시되면 알림을 표시합니다.
- **Startup Toast**: OhMyOpenCode 로드 시 환영 메시지를 표시합니다. 세션을 제대로 시작하기 위한 작은 "oMoMoMo".
- **Auto Update Checker**: oh-my-opencode의 새 버전을 자동으로 확인하고 설정을 자동 업데이트할 수 있습니다. 현재 버전과 Sisyphus 상태를 표시하는 시작 토스트 알림을 표시합니다 (Sisyphus 활성화 시 "Sisyphus on steroids is steering OpenCode", 비활성화 시 "OpenCode is now on Steroids. oMoMoMoMo..."). 모든 기능을 비활성화하려면 `disabled_hooks`에 `"auto-update-checker"`를, 토스트 알림만 비활성화하려면 `"startup-toast"`를 추가하세요. [설정 > 훅](#훅) 참조.
- **Background Notification**: 백그라운드 에이전트 작업이 완료되면 알림을 받습니다.
- **Session Notification**: 에이전트가 대기 상태가 되면 OS 알림을 보냅니다. macOS, Linux, Windows에서 작동—에이전트가 입력을 기다릴 때 놓치지 마세요.
- **Empty Task Response Detector**: Task 도구가 빈 응답을 반환하면 감지합니다. 이미 빈 응답이 왔는데 무한정 기다리는 상황을 방지합니다.
- **Empty Message Sanitizer**: 빈 채팅 메시지로 인한 API 오류를 방지합니다. 전송 전 메시지 내용을 자동으로 정리합니다.
- **Grep Output Truncator**: grep은 산더미 같은 텍스트를 반환할 수 있습니다. 남은 컨텍스트 윈도우에 따라 동적으로 출력을 축소합니다—50% 여유 공간 유지, 최대 50k 토큰.
- **Tool Output Truncator**: 같은 아이디어, 더 넓은 범위. Grep, Glob, LSP 도구, AST-grep의 출력을 축소합니다. 한 번의 장황한 검색이 전체 컨텍스트를 잡아먹는 것을 방지합니다.
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 문제가 발생하기 전에 미리 실행됩니다.
- **압축 컨텍스트 주입기 (Compaction Context Injector)**: 세션 압축 중에 중요한 컨텍스트(AGENTS.md, 현재 디렉토리 정보 등)를 유지하여 중요한 상태를 잃지 않도록 합니다.
- **사고 블록 검증기 (Thinking Block Validator)**: 사고(thinking) 블록의 형식이 올바른지 검증하여 잘못된 형식으로 인한 API 오류를 방지합니다.
- **Claude Code 훅 (Claude Code Hooks)**: Claude Code의 settings.json에 설정된 훅을 실행합니다. PreToolUse/PostToolUse/UserPromptSubmit/Stop 이벤트를 지원하는 호환성 레이어입니다.
## 설정
@@ -646,7 +668,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
| 플랫폼 | 사용자 설정 경로 |
|--------|------------------|
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (우선) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (권장) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
Schema 자동 완성이 지원됩니다:
@@ -657,6 +679,36 @@ Schema 자동 완성이 지원됩니다:
}
```
### JSONC 지원
`oh-my-opencode` 설정 파일은 JSONC(주석이 포함된 JSON)를 지원합니다:
- 한 줄 주석: `// 주석`
- 블록 주석: `/* 주석 */`
- 후행 콤마(Trailing commas): `{ "key": "value", }`
`oh-my-opencode.jsonc`와 `oh-my-opencode.json` 파일이 모두 존재할 경우, `.jsonc` 파일이 우선순위를 갖습니다.
**주석이 포함된 예시:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Antigravity OAuth를 통해 Google Gemini 활성화
"google_auth": false,
/* 에이전트 오버라이드 - 특정 작업에 대한 모델 커스터마이징 */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // 전략적 추론을 위한 GPT
},
"explore": {
"model": "opencode/grok-code" // 탐색을 위한 빠르고 무료인 모델
},
},
}
```
### Google Auth
**권장**: 외부 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 플러그인을 사용하세요. 멀티 계정 로드밸런싱, 더 많은 모델(Antigravity를 통한 Claude 포함), 활발한 유지보수를 제공합니다. [설치 > Google Gemini](#42-google-gemini-antigravity-oauth) 참조.
@@ -669,7 +721,7 @@ Schema 자동 완성이 지원됩니다:
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
@@ -742,14 +794,38 @@ Schema 자동 완성이 지원됩니다:
### Sisyphus Agent
활성화 시(기본값), oh-my-opencode 는 두 개의 primary 에이전트를 추가하고 내장 에이전트를 subagent로 강등합니다:
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
- **Planner-Sisyphus**: OpenCode plan 에이전트의 모든 설정을 런타임에 상속 (description에 "OhMyOpenCode version" 추가)
- **build**: subagent로 강등
- **plan**: subagent로 강등
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려면:
**설정 옵션:**
```json
{
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**예시: Builder-Sisyphus 활성화하기:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
```json
{
@@ -759,7 +835,7 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
다른 에이전트처럼 Sisyphus 와 Planner-Sisyphus도 커스터마이징할 수 있습니다:
다른 에이전트처럼 Sisyphus 에이전트들도 커스터마이징할 수 있습니다:
```json
{
@@ -768,6 +844,9 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -775,9 +854,12 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
| 옵션 | 기본값 | 설명 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true`면 Sisyphus 에이전트를 비활성화하고 원래 build/plan을 primary로 복원합니다. `false`(기본값)면 Sisyphus와 Planner-Sisyphus가 primary 에이전트가 됩니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
### Hooks
@@ -789,7 +871,9 @@ Sisyphus 를 비활성화하고 원래 build/plan 에이전트를 복원하려
}
```
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
### MCPs
@@ -839,16 +923,22 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
```json
{
"experimental": {
"preemptive_compaction": true,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
}
}
```
| 옵션 | 기본값 | 설명 |
| ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | 토큰 제한에 도달하기 전에 세션을 미리 컴팩션합니다. 기본적으로 컨텍스트 윈도우 사용량이 80%일 때 실행됩니다. |
| `preemptive_compaction_threshold` | `0.80` | 선제적 컴팩션을 트리거할 임계값 비율(0.5-0.95). `preemptive_compaction`이 활성화된 경우에만 적용됩니다. |
| `truncate_all_tool_outputs` | `false` | 화이트리스트 도구(Grep, Glob, LSP, AST-grep)만이 아닌 모든 도구 출력을 잘라냅니다. Tool output truncator는 기본적으로 활성화됩니다 - `disabled_hooks`로 비활성화 가능합니다. |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| `dcp_for_compaction` | `false` | 컴팩션용 DCP(동적 컨텍스트 정리) 활성화 - 토큰 제한 초과 시 먼저 실행됩니다. 컴팩션 전에 중복 도구 호출과 오래된 도구 출력을 정리합니다. |
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.
@@ -896,11 +986,16 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
- [이를 고치는 PR 이 1.0.132 배포 이후에 병합되었으므로](https://github.com/sst/opencode/pull/5040) 이 변경사항이 포함된 최신 버전을 사용해주세요.
- TMI: PR 도 OhMyOpenCode 의 셋업의 Librarian, Explore, Oracle 을 활용하여 우연히 발견하고 해결되었습니다.
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*
## 다음 기업의 능력있는 개인들이 사용하고 있습니다
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## 스폰서
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 첫 번째 스폰서
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*

314
README.md
View File

@@ -1,4 +1,13 @@
NOTICE: News regarding oh-my-opencode used to be posted on my X account, but since it got suspended innocently, [@justsisyphus](https://x.com/justsisyphus) is now managing updates on behalf of me.
> [!NOTE]
>
> *"I aim to spark a software revolution by creating a world where agent-generated code is indistinguishable from human code, yet capable of achieving vastly more. I have poured my personal time, passion, and funds into this journey, and I will continue to do so."*
>
> Be with us!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -28,7 +37,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -36,6 +45,26 @@ No stupid token consumption massive subagents here. No bloat tools here.
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## Reviews
> "If Claude Code does in 7 days what a human does in 3 months, Sisyphus does it in 1 hour. It just works until the task is done. It is a discipline agent." — B, Quant Researcher
> "Knocked out 8000 eslint warnings with Oh My Opencode, just in a day" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "You guys should pull this into core and recruit him. Seriously. It's really, really, really good." — Henning Kilset
> "Hire @yeon_gyu_kim if you can convince him, this dude has revolutionized opencode." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "ok yeah holy shit @androolloyd this thing is legit oh my opencode is sick" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "use oh-my-opencode, you will never go back" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## Contents
- [Oh My OpenCode](#oh-my-opencode)
@@ -168,7 +197,17 @@ If you don't want all this, as mentioned, you can just pick and choose specific
### For Humans
Let the LLM Agent handle the grunt work. Paste this into a fresh opencode session:
Run the interactive installer:
```bash
bunx oh-my-opencode install
```
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.
**Alternative: Let an LLM Agent do it**
Paste this into a fresh opencode session:
```
Install and configure by following the instructions here https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/README.md
```
@@ -182,80 +221,25 @@ If you're an LLM Agent helping set up oh-my-opencode, Welcome! You'll now help y
follow these steps:
### Step 0: Ask user about the setup.
### Step 0: Ask user about subscriptions
Since OpenCode supports various models, and our plugin suggests various frontier LLMs, you must ask user the following questions.
Ask the user these 3 questions to determine CLI options:
**IMPORTANT**: Track which providers the user has. Only use `anthropic/claude-opus-4-5` as fallback when the user confirmed they have Claude. If they don't have Claude, use `opencode/big-pickle` as fallback instead.
1. **Do you have a Claude Pro/Max Subscription?**
- If **yes**: **Are you on max20 (20x mode)?**
- **max20** → `--claude=max20`
- **not max20** → `--claude=yes`
- If **no**`--claude=no`
1. **Do you have a Claude Pro/Max Subscription?** Otherwise, we'll use 'opencode/big-pickle' (known for GLM 4.6) as a main orchestrator model (not recommended)
- If user said **yes**, ask follow-up: **Are you on max20 (20x mode)?**
- If **yes (max20)**: Add ToDo: "Setup user Claude Pro/Max Auth" — full Claude Sonnet 4.5 for librarian. **Remember: Claude is available for fallbacks.**
- If **no (not max20)**: Add ToDo: "Setup user Claude Pro/Max Auth", then merge the following to `~/.config/opencode/oh-my-opencode.json`. **Remember: Claude is available for fallbacks.**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- If user said **no** (no Claude subscription), merge the following to `~/.config/opencode/oh-my-opencode.json`. **Remember: Claude is NOT available — all fallbacks must use `opencode/big-pickle`.**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **Do you have a ChatGPT Subscription?**
- If **yes**`--chatgpt=yes`
- If **no**`--chatgpt=no`
2. **Do you have a ChatGPT Subscription?** Otherwise, we'll use a fallback model for oracle agent.
- If user said **yes**, add ToDo: "Setup user ChatGPT Auth"
- If user said **no**:
- **If Claude is available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **If Claude is NOT available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **Will you integrate Gemini models?**
- If **yes**`--gemini=yes`
- If **no**`--gemini=no`
3. **Will you integrate Gemini models?** Otherwise, you cannot enjoy the beautiful design.
- If user said **yes**, add ToDo: "Setup Google OAuth"
- If user said **no**:
- **If Claude is available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **If Claude is NOT available**: merge the following to `oh-my-opencode.json`:
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
### Step 1: Install OpenCode, if not
Run following command to verify OpenCode Installation:
### Step 1: Install OpenCode (if not installed)
```bash
if command -v opencode &> /dev/null; then
@@ -269,37 +253,29 @@ fi
If OpenCode isn't installed, check the [OpenCode Installation Guide](https://opencode.ai/docs).
Spawn a subagent to handle installation and report back - to save context.
### Step 2: Configure oh-my-opencode Plugin
### Step 2: Run the installer
#### JSONC
- If `~/.config/opencode/opencode.jsonc` exists, work in there to add `oh-my-opencode` plugin
#### JSON
- If no opencode.jsonc exists, run following command to create and register it.
Based on user's answers, run the CLI installer with appropriate flags:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# Existing config: add plugin using jq or python
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "Please add \"oh-my-opencode\" to the plugin array in ~/.config/opencode/opencode.json"
fi
else
# Create new config
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**Examples:**
- User has all subscriptions with max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- User has only Claude (no max20): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- User has no subscriptions: `bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
The CLI will:
- Register the plugin in `opencode.json`
- Configure agent models based on subscription flags
- Show which auth steps are needed
### Step 3: Verify Setup
```bash
opencode --version # Should be 1.0.132 or higher
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array, or maybe check jsonc
opencode --version # Should be 1.0.150 or higher
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
```
### Step 4: Configure Authentication
@@ -346,12 +322,12 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
**Available model names**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-2.5-flash`, `google/gemini-2.5-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**Available model names**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
Then authenticate:
@@ -489,11 +465,11 @@ To remove oh-my-opencode:
- **Sisyphus** (`anthropic/claude-opus-4-5`): **The default agent.** A powerful AI orchestrator for OpenCode. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Emphasizes background task delegation and todo-driven workflow. Uses Claude Opus 4.5 with extended thinking (32k budget) for maximum reasoning capability.
- **oracle** (`openai/gpt-5.2`): Architecture, code review, strategy. Uses GPT-5.2 for its stellar logical reasoning and deep analysis. Inspired by AmpCode.
- **librarian** (`anthropic/claude-sonnet-4-5`): Multi-repo analysis, doc lookup, implementation examples. Uses Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
- **explore** (`opencode/grok-code`): Fast codebase exploration and pattern matching. Claude Code uses Haiku; we use Grok—it's free, blazing fast, and plenty smart for file traversal. Inspired by Claude Code.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
- **document-writer** (`google/gemini-3-pro-preview`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
- **multimodal-looker** (`google/gemini-2.5-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
- **librarian** (`anthropic/claude-sonnet-4-5` or `google/gemini-3-flash`): Multi-repo analysis, doc lookup, implementation examples. Uses Gemini 3 Flash when Antigravity auth is configured, otherwise Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, or `anthropic/claude-haiku-4-5`): Fast codebase exploration and pattern matching. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-high`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
- **document-writer** (`google/gemini-3-flash`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
- **multimodal-looker** (`google/gemini-3-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
The main agent invokes these automatically, but you can call them explicitly:
@@ -546,6 +522,18 @@ Hand your best tools to your best colleagues. Now they can properly refactor, na
- **lsp_code_action_resolve**: Apply code action
- **ast_grep_search**: AST-aware code pattern search (25 languages)
- **ast_grep_replace**: AST-aware code replacement
- **call_omo_agent**: Spawn specialized explore/librarian agents. Supports `run_in_background` parameter for async execution.
#### Session Management
Tools to navigate and search your OpenCode session history:
- **session_list**: List all OpenCode sessions with filtering by date and limit
- **session_read**: Read messages and history from a specific session
- **session_search**: Full-text search across session messages
- **session_info**: Get metadata and statistics about a session
These tools enable agents to reference previous conversations and maintain continuity across sessions.
#### Context Is All You Need
- **Directory AGENTS.md / README.md Injector**: Auto-injects `AGENTS.md` and `README.md` when reading files. Walks from file directory to project root, collecting **all** `AGENTS.md` files along the path. Supports nested directory-specific instructions:
@@ -679,6 +667,12 @@ All toggles default to `true` (enabled). Omit the `claude_code` object for full
When agents thrive, you thrive. But I want to help you directly too.
- **Ralph Loop**: Self-referential development loop that runs until task completion. Inspired by Anthropic's Ralph Wiggum plugin. **Supports all programming languages.**
- Start with `/ralph-loop "Build a REST API"` and let the agent work continuously
- Loop detects `<promise>DONE</promise>` to know when complete
- Auto-continues if agent stops without completion promise
- Ends when: completion detected, max iterations reached (default 100), or `/cancel-ralph`
- Configure in `oh-my-opencode.json`: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **Keyword Detector**: Automatically detects keywords in your prompts and activates specialized modes:
- `ultrawork` / `ulw`: Maximum performance mode with parallel agent orchestration
- `search` / `find` / `찾아` / `検索`: Maximized search effort with parallel explore and librarian agents
@@ -691,14 +685,17 @@ When agents thrive, you thrive. But I want to help you directly too.
- **Agent Usage Reminder**: When you call search tools directly, reminds you to leverage specialized agents via background tasks for better results.
- **Anthropic Auto Compact**: When Claude models hit token limits, automatically summarizes and compacts the session—no manual intervention needed.
- **Session Recovery**: Automatically recovers from session errors (missing tool results, thinking block issues, empty messages). Sessions don't crash mid-run. Even if they do, they recover.
- **Auto Update Checker**: Notifies you when a new version of oh-my-opencode is available.
- **Startup Toast**: Shows a welcome message when OhMyOpenCode loads. A little "oMoMoMo" to start your session right.
- **Auto Update Checker**: Automatically checks for new versions of oh-my-opencode and can auto-update your configuration. Shows startup toast notifications displaying current version and Sisyphus status ("Sisyphus on steroids is steering OpenCode" when enabled, or "OpenCode is now on Steroids. oMoMoMoMo..." otherwise). Disable all features with `"auto-update-checker"` in `disabled_hooks`, or disable just toast notifications with `"startup-toast"` in `disabled_hooks`. See [Configuration > Hooks](#hooks).
- **Background Notification**: Get notified when background agent tasks complete.
- **Session Notification**: Sends OS notifications when agents go idle. Works on macOS, Linux, and Windows—never miss when your agent needs input.
- **Empty Task Response Detector**: Catches when Task tool returns nothing. Warns you about potential agent failures so you don't wait forever for a response that already came back empty.
- **Empty Message Sanitizer**: Prevents API errors from empty chat messages by automatically sanitizing message content before sending.
- **Grep Output Truncator**: Grep can return mountains of text. This dynamically truncates output based on your remaining context window—keeps 50% headroom, caps at 50k tokens.
- **Tool Output Truncator**: Same idea, broader scope. Truncates output from Grep, Glob, LSP tools, and AST-grep. Prevents one verbose search from eating your entire context.
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs before you get into trouble.
- **Compaction Context Injector**: Preserves critical context (AGENTS.md, current directory info) during session compaction so you don't lose important state.
- **Thinking Block Validator**: Validates thinking blocks to ensure proper formatting and prevent API errors from malformed thinking content.
- **Claude Code Hooks**: Executes hooks from Claude Code's settings.json - this is the compatibility layer that runs PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks.
## Configuration
@@ -721,6 +718,36 @@ Schema autocomplete supported:
}
```
### JSONC Support
The `oh-my-opencode` configuration file supports JSONC (JSON with Comments):
- Line comments: `// comment`
- Block comments: `/* comment */`
- Trailing commas: `{ "key": "value", }`
When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc` takes priority.
**Example with comments:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Enable Google Gemini via Antigravity OAuth
"google_auth": false,
/* Agent overrides - customize models for specific tasks */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // GPT for strategic reasoning
},
"explore": {
"model": "opencode/grok-code" // Free & fast for exploration
},
},
}
```
### Google Auth
**Recommended**: Use the external [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin. It provides multi-account load balancing, more models (including Claude via Antigravity), and active maintenance. See [Installation > Google Gemini](#google-gemini-antigravity-oauth).
@@ -733,7 +760,7 @@ When using `opencode-antigravity-auth`, disable the built-in auth and override a
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
@@ -806,24 +833,48 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
### Sisyphus Agent
When enabled (default), Sisyphus adds two primary agents and demotes the built-in agents to subagents:
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
- **Planner-Sisyphus**: Inherits all settings from OpenCode's plan agent at runtime (description appended with "OhMyOpenCode version")
- **build**: Demoted to subagent
- **plan**: Demoted to subagent
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
To disable Sisyphus and restore the original build/plan agents:
**Configuration Options:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**Example: Enable Builder-Sisyphus:**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
**Example: Disable all Sisyphus orchestration:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
You can also customize Sisyphus and Planner-Sisyphus like other agents:
You can also customize Sisyphus agents like other agents:
```json
{
@@ -832,6 +883,9 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -839,9 +893,12 @@ You can also customize Sisyphus and Planner-Sisyphus like other agents:
}
```
| Option | Default | Description |
| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables Sisyphus agents and restores original build/plan as primary. When `false` (default), Sisyphus and Planner-Sisyphus become primary agents. |
| Option | Default | Description |
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
### Hooks
@@ -853,7 +910,9 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
}
```
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
### MCPs
@@ -903,16 +962,22 @@ Opt-in experimental features that may change or be removed in future versions. U
```json
{
"experimental": {
"preemptive_compaction": true,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
}
}
```
| Option | Default | Description |
| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
| Option | Default | Description |
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | Compacts session proactively before hitting hard token limits. Runs at 80% context window usage by default. |
| `preemptive_compaction_threshold` | `0.80` | Threshold percentage (0.5-0.95) to trigger preemptive compaction. Only applies when `preemptive_compaction` is enabled. |
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
| `dcp_for_compaction` | `false` | Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded. Prunes duplicate tool calls and old tool outputs before running compaction. |
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
@@ -960,11 +1025,16 @@ I have no affiliation with any project or model mentioned here. This is purely p
- [The fix](https://github.com/sst/opencode/pull/5040) was merged after 1.0.132—use a newer version.
- Fun fact: That PR was discovered and fixed thanks to OhMyOpenCode's Librarian, Explore, and Oracle setup.
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*
## Loved by professionals at
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## Sponsors
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- The first sponsor
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*

View File

@@ -1,4 +1,13 @@
公告oh-my-opencode 的相关消息之前在我的 X 账号发布,但由于账号被无辜封禁,现在由 [@justsisyphus](https://x.com/justsisyphus) 代为管理更新。
> [!NOTE]
>
> *"我致力于引发一场软件革命创造一个AI生成的代码与人类代码无法区分、却能实现更多的世界。我已经在这段旅程中投入了个人时间、热情和资金并将继续这样做。"*
>
> 与我们同行!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -25,7 +34,7 @@
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-MIT-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
@@ -33,6 +42,26 @@
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## 用户评价
> "如果 Claude Code 能在 7 天内完成人类 3 个月的工作,那么 Sisyphus 只需要 1 小时。任务完成之前它就是一直干。It is a discipline agent." — B, Quant Researcher
> "只用了一天,就用 Oh My Opencode 干掉了 8000 个 eslint 警告" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "你们应该把它合并到核心代码里并聘用他。认真的。这真的、真的、真的很好" — Henning Kilset
> "如果你能说服 @yeon_gyu_kim就雇佣他吧这家伙彻底改变了 opencode" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "哇靠 @androolloyd 这玩意儿是真的oh my opencode 太强了" — [z80.eth](https://x.com/0xz80/status/2001815226505924791)
> "用了 oh-my-opencode你就回不去了" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
## 目录
- [Oh My OpenCode](#oh-my-opencode)
@@ -163,76 +192,23 @@ Install and configure by following the instructions here https://raw.githubuserc
按步骤来:
### 步骤 0摸清用户底细
### 步骤 0确认订阅情况
OpenCode 支持各种模型,我们这个插件也建议用各种前沿 LLM。所以先问清楚。
问用户这 3 个问题来确定 CLI 参数:
**重要**:追踪用户有哪些 provider。只有用户确认有 Claude 的情况下,才能用 `anthropic/claude-opus-4-5` 做 fallback。没有 Claude 就用 `opencode/big-pickle` 做 fallback。
1. **有 Claude Pro/Max 订阅吗?**
- **有****用的是 max20 (20x 模式) 吗?**
- **max20** → `--claude=max20`
- **不是 max20** → `--claude=yes`
- **没有** → `--claude=no`
1. **有 Claude Pro/Max 订阅吗?** 没有的话,主编排模型只能用 'opencode/big-pickle'(也就是 GLM 4.6),这体验可不太好(不推荐)。
- 用户说**有**,继续问:**你用的是 max20 (20x 模式) 吗?**
- **是 (max20)**:加个待办:"配置用户 Claude Pro/Max 认证" — librarian 用完整的 Claude Sonnet 4.5。**记住Claude 可以做 fallback。**
- **不是 (没有 max20)**:加个待办:"配置用户 Claude Pro/Max 认证",然后把这个合并到 `~/.config/opencode/oh-my-opencode.json`。**记住Claude 可以做 fallback。**
```json
{
"agents": {
"librarian": { "model": "opencode/big-pickle" }
}
}
```
- 用户说**没有** (没有 Claude 订阅),把这坨配置写到 `~/.config/opencode/oh-my-opencode.json`。**记住:没有 Claude — 所有 fallback 都得用 `opencode/big-pickle`。**
```json
{
"agents": {
"Sisyphus": { "model": "opencode/big-pickle" },
"librarian": { "model": "opencode/big-pickle" }
}
}
```
2. **有 ChatGPT 订阅吗?**
- **有**`--chatgpt=yes`
- **没有** → `--chatgpt=no`
2. **有 ChatGPT 订阅吗?** 没有的话oracle Agent 就用 fallback 模型了。
- 用户说****,加个待办:"配置用户 ChatGPT 认证"
- 用户说**没有**
- **有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"oracle": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **没有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"oracle": { "model": "opencode/big-pickle" }
}
}
```
3. **要接 Gemini 模型吗?** 不接的话,那么漂亮的 UI 设计你就无福消受了。
- 用户说**要**,加个待办:"配置 Google OAuth"
- 用户说**不要**
- **有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "anthropic/claude-opus-4-5" },
"document-writer": { "model": "anthropic/claude-opus-4-5" },
"multimodal-looker": { "model": "anthropic/claude-opus-4-5" }
}
}
```
- **没有 Claude 的情况下**:把这个合并到 `oh-my-opencode.json`
```json
{
"agents": {
"frontend-ui-ux-engineer": { "model": "opencode/big-pickle" },
"document-writer": { "model": "opencode/big-pickle" },
"multimodal-looker": { "model": "opencode/big-pickle" }
}
}
```
3. **要用 Gemini 模型吗?**
- ****`--gemini=yes`
- **不要** → `--gemini=no`
### 步骤 1确认 OpenCode 装没装
@@ -249,36 +225,28 @@ fi
如果没装,看 [OpenCode 安装指南](https://opencode.ai/docs)。可以生成个子 Agent 去搞定安装,完事汇报——省点上下文。
### 步骤 2配置 oh-my-opencode 插件
### 步骤 2跑安装程序
#### JSONC
- 如果 `~/.config/opencode/opencode.jsonc` 存在,就在里面加 `oh-my-opencode` 插件。
#### JSON
- 如果没 opencode.jsonc跑这个命令创建并注册
根据用户的回答,用对应的参数运行 CLI 安装程序:
```bash
mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
# 已有配置:用 jq 或 python 加插件
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "请手动在 ~/.config/opencode/opencode.json 的 plugin 数组里加上 \"oh-my-opencode\""
fi
else
# 新建配置
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
fi
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
**例子:**
- 全套订阅 + max20`bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- 只有 Claude没 max20`bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
- 啥订阅都没有:`bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no`
CLI 会干这些事:
-`opencode.json` 注册插件
- 根据订阅参数配置 Agent 模型
- 告诉你接下来要做哪些认证
### 步骤 3验货
```bash
opencode --version # 得是 1.0.132 以上
opencode --version # 得是 1.0.150 以上
cat ~/.config/opencode/opencode.json # plugin 数组里得有 "oh-my-opencode"
```
@@ -325,12 +293,12 @@ opencode auth login
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
**可用模型名**`google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-2.5-flash`, `google/gemini-2.5-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**可用模型名**`google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
然后认证:
@@ -430,17 +398,50 @@ gh repo star code-yeongyu/oh-my-opencode
</details>
## 卸载
要移除 oh-my-opencode
1. **从 OpenCode 配置中移除插件**
编辑 `~/.config/opencode/opencode.json` (或 `opencode.jsonc`),从 `plugin` 数组中移除 `"oh-my-opencode"`
```bash
# 使用 jq 的示例
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **删除配置文件 (可选)**
```bash
# 删除用户配置
rm -f ~/.config/opencode/oh-my-opencode.json
# 删除项目配置 (如果存在)
rm -f .opencode/oh-my-opencode.json
```
3. **确认移除**
```bash
opencode --version
# 插件不应再被加载
```
## 功能
### Agents你的神队友
- **Sisyphus** (`anthropic/claude-opus-4-5`)**默认 Agent。** OpenCode 专属的强力 AI 编排器。指挥专业子 Agent 搞定复杂任务。主打后台任务委派和 Todo 驱动。用 Claude Opus 4.5 加上扩展思考32k token 预算),智商拉满。
- **oracle** (`openai/gpt-5.2`)架构师、代码审查员、战略家。GPT-5.2 的逻辑推理和深度分析能力不是盖的。致敬 AmpCode。
- **librarian** (`anthropic/claude-sonnet-4-5`):多仓库分析、查文档、找示例。Claude Sonnet 4.5 深入理解代码库GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
- **explore** (`opencode/grok-code`)极速代码库扫描、模式匹配。Claude Code 用 Haiku我们用 Grok——免费、飞快、扫文件够用了。致敬 Claude Code。
- **librarian** (`anthropic/claude-sonnet-4-5` 或 `google/gemini-3-flash`):多仓库分析、查文档、找示例。配置 Antigravity 认证时使用 Gemini 3 Flash否则使用 Claude Sonnet 4.5 深入理解代码库GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
- **explore** (`opencode/grok-code`、`google/gemini-3-flash` 或 `anthropic/claude-haiku-4-5`):极速代码库扫描、模式匹配。配置 Antigravity 认证时使用 Gemini 3 FlashClaude max20 可用时使用 Haiku否则用 Grok。致敬 Claude Code。
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`)设计师出身的程序员。UI 做得那是真漂亮。Gemini 写这种创意美观的代码是一绝。
- **document-writer** (`google/gemini-3-pro-preview`)技术写作专家。Gemini 文笔好,写出来的东西读着顺畅。
- **multimodal-looker** (`google/gemini-2.5-flash`)视觉内容专家。PDF、图片、图表看一眼就知道里头有啥。
- **multimodal-looker** (`google/gemini-3-flash`)视觉内容专家。PDF、图片、图表看一眼就知道里头有啥。
主 Agent 会自动调遣它们,你也可以亲自点名:
@@ -493,6 +494,18 @@ OhMyOpenCode 让这些成为可能。
- **lsp_code_action_resolve**:应用代码操作
- **ast_grep_search**AST 感知代码搜索(支持 25 种语言)
- **ast_grep_replace**AST 感知代码替换
- **call_omo_agent**: 产生专门的 explore/librarian Agent。支持用于异步执行的 `run_in_background` 参数。
#### 会话管理 (Session Management)
用于导航和搜索 OpenCode 会话历史的工具:
- **session_list**: 列出所有 OpenCode 会话,支持按日期和数量限制进行过滤
- **session_read**: 读取特定会话的消息和历史记录
- **session_search**: 在会话消息中进行全文搜索
- **session_info**: 获取有关会话的元数据和统计信息
这些工具使 Agent 能够引用之前的对话并保持跨会话的连续性。
#### 上下文就是一切 (Context is all you need)
- **Directory AGENTS.md / README.md 注入器**:读文件时自动把 `AGENTS.md` 和 `README.md` 塞进去。从当前目录一路往上找,路径上**所有** `AGENTS.md` 全都带上。支持嵌套指令:
@@ -626,6 +639,12 @@ Oh My OpenCode 会扫这些地方:
Agent 爽了,你自然也爽。但我还想直接让你爽。
- **Ralph 循环**:干到完事才停的自参照开发循环。灵感来自 Anthropic 的 Ralph Wiggum 插件。**支持所有编程语言。**
- `/ralph-loop "搞个 REST API"` 开始Agent 就一直干
- 检测到 `<promise>DONE</promise>` 就算完事
- 没输出完成标记就停了?自动续上
- 停止条件:检测到完成、达到最大迭代(默认 100 次)、或 `/cancel-ralph`
- `oh-my-opencode.json` 配置:`{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
- **关键词检测器**:看到关键词自动切模式:
- `ultrawork` / `ulw`:并行 Agent 编排,火力全开
- `search` / `find` / `찾아` / `検索`explore/librarian 并行搜索,掘地三尺
@@ -638,8 +657,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
- **Agent 使用提醒**:你自己搜东西的时候,弹窗提醒你"这种事让后台专业 Agent 干更好"。
- **Anthropic 自动压缩**Claude Token 爆了?自动总结压缩会话——不用你操心。
- **会话恢复**工具没结果Thinking 卡住?消息是空的?自动恢复。会话崩不了,崩了也能救回来。
- **自动更新检查**oh-my-opencode 更新了会告诉你
- **启动提示**:加载时来句"oMoMoMo",开启元气满满的一次会话。
- **自动更新检查**自动检查 oh-my-opencode 新版本并可自动更新配置。显示启动提示通知,展示当前版本和 Sisyphus 状态Sisyphus 启用时显示「Sisyphus on steroids is steering OpenCode」禁用时显示「OpenCode is now on Steroids. oMoMoMoMo...」)。要禁用全部功能,在 `disabled_hooks` 中添加 `"auto-update-checker"`;只禁用提示通知,添加 `"startup-toast"`。详见 [配置 > Hooks](#hooks)
- **后台通知**:后台 Agent 活儿干完了告诉你。
- **会话通知**Agent 没事干了发系统通知。macOS、Linux、Windows 通吃——别让 Agent 等你。
- **空 Task 响应检测**Task 工具回了个寂寞?立马报警,别傻傻等一个永远不会来的响应。
@@ -653,7 +671,12 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
配置文件(优先级从高到低):
1. `.opencode/oh-my-opencode.json`(项目级)
2. `~/.config/opencode/oh-my-opencode.json`(用户级)
2. 用户配置(按平台):
| 平台 | 用户配置路径 |
|----------|------------------|
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (首选) 或 `%APPDATA%\opencode\oh-my-opencode.json` (备选) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
支持 Schema 自动补全:
@@ -663,6 +686,36 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
}
```
### JSONC 支持
`oh-my-opencode` 配置文件支持 JSONC带注释的 JSON
- 行注释:`// 注释`
- 块注释:`/* 注释 */`
- 尾随逗号:`{ "key": "value", }`
当 `oh-my-opencode.jsonc` 和 `oh-my-opencode.json` 文件同时存在时,`.jsonc` 优先。
**带注释的示例:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// 通过 Antigravity OAuth 启用 Google Gemini
"google_auth": false,
/* Agent 覆盖 - 为特定任务自定义模型 */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // 用于战略推理的 GPT
},
"explore": {
"model": "opencode/grok-code" // 快速且免费的搜索模型
},
},
}
```
### Google Auth
**强推**:用外部 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 插件。多账号负载均衡、更多模型(包括 Antigravity 版 Claude、有人维护。看 [安装 > Google Gemini](#42-google-gemini-antigravity-oauth)。
@@ -675,7 +728,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
"agents": {
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
"document-writer": { "model": "google/gemini-3-flash" },
"multimodal-looker": { "model": "google/gemini-2.5-flash" }
"multimodal-looker": { "model": "google/gemini-3-flash" }
}
}
```
@@ -748,24 +801,48 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
### Sisyphus Agent
默认开启。Sisyphus 会加两个主 Agent把原来的降级成小弟
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent
- **Sisyphus**:主编排 AgentClaude Opus 4.5
- **Planner-Sisyphus**运行时继承 OpenCode plan Agent 所有设置(描述里加了"OhMyOpenCode version"
- **build**:降级为子 Agent
- **plan**:降级为子 Agent
- **Builder-Sisyphus**OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用
- **Planner-Sisyphus**OpenCode 默认计划 Agent因 SDK 限制仅改名,默认启用)
想禁用 Sisyphus 恢复原来的?
**配置选项:**
```json
{
"omo_agent": {
"sisyphus_agent": {
"disabled": false,
"default_builder_enabled": false,
"planner_enabled": true,
"replace_plan": true
}
}
```
**示例:启用 Builder-Sisyphus**
```json
{
"sisyphus_agent": {
"default_builder_enabled": true
}
}
```
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
**示例:禁用所有 Sisyphus 编排:**
```json
{
"sisyphus_agent": {
"disabled": true
}
}
```
Sisyphus 和 Planner-Sisyphus 也能自定义:
Sisyphus Agent 也能自定义:
```json
{
@@ -774,6 +851,9 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
"model": "openai/gpt-5.2"
}
@@ -781,9 +861,12 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
}
```
| 选项 | 默认值 | 说明 |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | 设为 `true` 就禁用 Sisyphus恢复原来的 build/plan。设为 `false`(默认)就是 Sisyphus 和 Planner-Sisyphus 掌权。 |
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
### Hooks
@@ -795,7 +878,9 @@ Sisyphus 和 Planner-Sisyphus 也能自定义:
}
```
可关的 hook`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-auto-compact`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`
可关的 hook`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`、`ralph-loop`
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
### MCPs
@@ -845,20 +930,25 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
```json
{
"experimental": {
"preemptive_compaction": true,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
}
}
```
| 选项 | 默认值 | 说明 |
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| 选项 | 默认值 | 说明 |
| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | 在达到 token 限制之前主动压缩会话。默认在上下文窗口使用率达到 80% 时运行。 |
| `preemptive_compaction_threshold` | `0.80` | 触发预先压缩的阈值比例0.5-0.95)。仅在 `preemptive_compaction` 启用时生效。 |
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出而不仅仅是白名单工具Grep、Glob、LSP、AST-grep。Tool output truncator 默认启用 - 使用 `disabled_hooks` 禁用。 |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| `dcp_for_compaction` | `false` | 启用压缩用 DCP动态上下文剪枝- 在超出 token 限制时首先执行。在压缩前清理重复的工具调用和旧的工具输出。 |
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
## 作者的话
装个 Oh My OpenCode 试试。
@@ -902,11 +992,16 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
- [修复 PR](https://github.com/sst/opencode/pull/5040) 在 1.0.132 之后才合进去——请用新版本。
- 花絮:这 bug 也是靠 OhMyOpenCode 的 Librarian、Explore、Oracle 配合发现并修好的。
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*
## 以下企业的专业人士都在用
- [Indent](https://indentcorp.com)
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
## 赞助者
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 第一位赞助者
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*

View File

@@ -50,7 +50,7 @@
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"anthropic-auto-compact",
"anthropic-context-window-limit-recovery",
"rules-injector",
"background-notification",
"auto-update-checker",
@@ -59,7 +59,18 @@
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer"
"empty-message-sanitizer",
"thinking-block-validator",
"ralph-loop"
]
}
},
"disabled_commands": {
"type": "array",
"items": {
"type": "string",
"enum": [
"init-deep"
]
}
},
@@ -85,6 +96,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -196,6 +210,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -307,6 +324,123 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
},
"disable": {
"type": "boolean"
},
"description": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"subagent",
"primary",
"all"
]
},
"color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"permission": {
"type": "object",
"properties": {
"edit": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"bash": {
"anyOf": [
{
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
]
},
"webfetch": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"doom_loop": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
},
"external_directory": {
"type": "string",
"enum": [
"ask",
"allow",
"deny"
]
}
}
}
}
},
"OpenCode-Builder": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -418,6 +552,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -529,6 +666,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -640,6 +780,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -751,6 +894,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -862,6 +1008,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -973,6 +1122,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -1084,6 +1236,9 @@
"prompt": {
"type": "string"
},
"prompt_append": {
"type": "string"
},
"tools": {
"type": "object",
"propertyNames": {
@@ -1195,6 +1350,18 @@
},
"hooks": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
"plugins_override": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "boolean"
}
}
}
},
@@ -1206,6 +1373,23 @@
"properties": {
"disabled": {
"type": "boolean"
},
"default_builder_enabled": {
"type": "boolean"
},
"planner_enabled": {
"type": "boolean"
},
"replace_plan": {
"type": "boolean"
}
}
},
"comment_checker": {
"type": "object",
"properties": {
"custom_prompt": {
"type": "string"
}
}
},
@@ -1225,11 +1409,245 @@
"type": "number",
"minimum": 0.5,
"maximum": 0.95
},
"truncate_all_tool_outputs": {
"type": "boolean"
},
"dynamic_context_pruning": {
"type": "object",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"notification": {
"default": "detailed",
"type": "string",
"enum": [
"off",
"minimal",
"detailed"
]
},
"turn_protection": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
},
"turns": {
"default": 3,
"type": "number",
"minimum": 1,
"maximum": 10
}
}
},
"protected_tools": {
"default": [
"task",
"todowrite",
"todoread",
"lsp_rename",
"lsp_code_action_resolve",
"session_read",
"session_write",
"session_search"
],
"type": "array",
"items": {
"type": "string"
}
},
"strategies": {
"type": "object",
"properties": {
"deduplication": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
}
}
},
"supersede_writes": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
},
"aggressive": {
"default": false,
"type": "boolean"
}
}
},
"purge_errors": {
"type": "object",
"properties": {
"enabled": {
"default": true,
"type": "boolean"
},
"turns": {
"default": 5,
"type": "number",
"minimum": 1,
"maximum": 20
}
}
}
}
}
}
},
"dcp_for_compaction": {
"type": "boolean"
}
}
},
"auto_update": {
"type": "boolean"
},
"skills": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"allOf": [
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "object",
"properties": {
"description": {
"type": "string"
},
"template": {
"type": "string"
},
"from": {
"type": "string"
},
"model": {
"type": "string"
},
"agent": {
"type": "string"
},
"subtask": {
"type": "boolean"
},
"argument-hint": {
"type": "string"
},
"license": {
"type": "string"
},
"compatibility": {
"type": "string"
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"allowed-tools": {
"type": "array",
"items": {
"type": "string"
}
},
"disable": {
"type": "boolean"
}
}
}
]
}
},
{
"type": "object",
"properties": {
"sources": {
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"path": {
"type": "string"
},
"recursive": {
"type": "boolean"
},
"glob": {
"type": "string"
}
},
"required": [
"path"
]
}
]
}
},
"enable": {
"type": "array",
"items": {
"type": "string"
}
},
"disable": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
]
}
]
},
"ralph_loop": {
"type": "object",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"default_max_iterations": {
"default": 100,
"type": "number",
"minimum": 1,
"maximum": 1000
},
"state_dir": {
"type": "string"
}
}
}
}
}

View File

@@ -7,10 +7,15 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@code-yeongyu/comment-checker": "^0.6.0",
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.6.1",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"commander": "^14.0.2",
"hono": "^4.10.4",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8",
@@ -64,7 +69,11 @@
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-VtDPrhbUJcb5BIS18VMcY/N/xSLbMr6dpU9MO1NYQyEDhI4pSIx07K4gOlCutG/nHVCjO+HEarn8rttODP+5UA=="],
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
@@ -94,14 +103,22 @@
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],

View File

@@ -1,10 +1,13 @@
{
"name": "oh-my-opencode",
"version": "2.4.5",
"version": "2.8.2",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"bin": {
"oh-my-opencode": "./dist/cli/index.js"
},
"files": [
"dist"
],
@@ -20,7 +23,7 @@
"./schema.json": "./dist/oh-my-opencode.schema.json"
},
"scripts": {
"build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
"build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
"build:schema": "bun run script/build-schema.ts",
"clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build",
@@ -37,7 +40,7 @@
"llm"
],
"author": "YeonGyu-Kim",
"license": "MIT",
"license": "SUL-1.0",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/oh-my-opencode.git"
@@ -49,10 +52,15 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@code-yeongyu/comment-checker": "^0.6.0",
"@clack/prompts": "^0.11.0",
"@code-yeongyu/comment-checker": "^0.6.1",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"commander": "^14.0.2",
"hono": "^4.10.4",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8"

View File

@@ -122,7 +122,7 @@ async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<vo
console.log("\nCommitting and tagging...")
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
await $`git config user.name "github-actions[bot]"`
await $`git add package.json`
await $`git add package.json assets/oh-my-opencode.schema.json`
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
if (hasStagedChanges.exitCode !== 0) {

108
signatures/cla.json Normal file
View File

@@ -0,0 +1,108 @@
{
"signedContributors": [
{
"name": "tsanva",
"id": 54318170,
"comment_id": 3690638858,
"created_at": "2025-12-25T00:15:18Z",
"repoId": 1108837393,
"pullRequestNo": 210
},
{
"name": "code-yeongyu",
"id": 11153873,
"comment_id": 3690997221,
"created_at": "2025-12-25T06:19:27Z",
"repoId": 1108837393,
"pullRequestNo": 217
},
{
"name": "mylukin",
"id": 1021019,
"comment_id": 3691531529,
"created_at": "2025-12-25T15:15:29Z",
"repoId": 1108837393,
"pullRequestNo": 240
},
{
"name": "codewithkenzo",
"id": 115878491,
"comment_id": 3691825625,
"created_at": "2025-12-25T23:47:52Z",
"repoId": 1108837393,
"pullRequestNo": 253
},
{
"name": "stevenvo",
"id": 875426,
"comment_id": 3692141372,
"created_at": "2025-12-26T05:16:12Z",
"repoId": 1108837393,
"pullRequestNo": 248
},
{
"name": "harshav167",
"id": 80092815,
"comment_id": 3693666997,
"created_at": "2025-12-27T04:40:35Z",
"repoId": 1108837393,
"pullRequestNo": 268
},
{
"name": "adam2am",
"id": 128839448,
"comment_id": 3694022446,
"created_at": "2025-12-27T14:49:05Z",
"repoId": 1108837393,
"pullRequestNo": 281
},
{
"name": "devxoul",
"id": 931655,
"comment_id": 3694098760,
"created_at": "2025-12-27T17:05:50Z",
"repoId": 1108837393,
"pullRequestNo": 288
},
{
"name": "SyedTahirHussan",
"id": 9879266,
"comment_id": 3694598917,
"created_at": "2025-12-28T09:24:03Z",
"repoId": 1108837393,
"pullRequestNo": 306
},
{
"name": "Fguedes90",
"id": 13650239,
"comment_id": 3695136375,
"created_at": "2025-12-28T23:34:19Z",
"repoId": 1108837393,
"pullRequestNo": 319
},
{
"name": "marcusrbrown",
"id": 831617,
"comment_id": 3698181444,
"created_at": "2025-12-30T03:12:47Z",
"repoId": 1108837393,
"pullRequestNo": 336
},
{
"name": "lgandecki",
"id": 4002543,
"comment_id": 3698538417,
"created_at": "2025-12-30T07:35:08Z",
"repoId": 1108837393,
"pullRequestNo": 341
},
{
"name": "purelledhand",
"id": 13747937,
"comment_id": 3699148046,
"created_at": "2025-12-30T12:04:59Z",
"repoId": 1108837393,
"pullRequestNo": 349
}
]
}

89
src/agents/AGENTS.md Normal file
View File

@@ -0,0 +1,89 @@
# AGENTS KNOWLEDGE BASE
## OVERVIEW
AI agent definitions for multi-model orchestration. 7 specialized agents: Sisyphus (orchestrator), oracle (strategy), librarian (research), explore (grep), frontend-ui-ux-engineer, document-writer, multimodal-looker.
## STRUCTURE
```
agents/
├── sisyphus.ts # Primary orchestrator (Claude Opus 4.5)
├── oracle.ts # Strategic advisor (GPT-5.2)
├── librarian.ts # Multi-repo research (Claude Sonnet 4.5)
├── explore.ts # Fast codebase grep (Grok Code)
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
├── document-writer.ts # Technical docs (Gemini 3 Flash)
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
├── build-prompt.ts # Shared build agent prompt
├── plan-prompt.ts # Shared plan agent prompt
├── types.ts # AgentModelConfig interface
├── utils.ts # createBuiltinAgents(), getAgentName()
└── index.ts # builtinAgents export
```
## AGENT MODELS
| Agent | Default Model | Fallback | Purpose |
|-------|---------------|----------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
| oracle | openai/gpt-5.2 | - | Architecture, debugging, code review |
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
| multimodal-looker | google/gemini-3-flash | - | PDF/image analysis |
## HOW TO ADD AN AGENT
1. Create `src/agents/my-agent.ts`:
```typescript
import type { AgentConfig } from "@opencode-ai/sdk"
export const myAgent: AgentConfig = {
model: "provider/model-name",
temperature: 0.1,
system: "Agent system prompt...",
tools: { include: ["tool1", "tool2"] }, // or exclude: [...]
}
```
2. Add to `builtinAgents` in `src/agents/index.ts`
3. Update `types.ts` if adding new config options
## AGENT CONFIG OPTIONS
| Option | Type | Description |
|--------|------|-------------|
| model | string | Model identifier (provider/model-name) |
| temperature | number | 0.0-1.0, most use 0.1 for consistency |
| system | string | System prompt (can be multiline template literal) |
| tools | object | `{ include: [...] }` or `{ exclude: [...] }` |
| top_p | number | Optional nucleus sampling |
| maxTokens | number | Optional max output tokens |
## MODEL FALLBACK LOGIC
`createBuiltinAgents()` in utils.ts handles model fallback:
1. Check user config override (`agents.{name}.model`)
2. Check installer settings (claude max20, gemini antigravity)
3. Use default model
**Fallback order for explore**:
- If gemini antigravity enabled → `google/gemini-3-flash`
- If claude max20 enabled → `anthropic/claude-haiku-4-5`
- Default → `opencode/grok-code` (free)
## ANTI-PATTERNS (AGENTS)
- **High temperature**: Don't use >0.3 for code-related agents
- **Broad tool access**: Prefer explicit `include` over unrestricted access
- **Monolithic prompts**: Keep prompts focused; delegate to specialized agents
- **Missing fallbacks**: Consider free/cheap fallbacks for rate-limited models
## SHARED PROMPTS
- **build-prompt.ts**: Base prompt for build agents (OpenCode default + Sisyphus variants)
- **plan-prompt.ts**: Base prompt for plan agents (Planner-Sisyphus)
Used by `src/index.ts` when creating Builder-Sisyphus and Planner-Sisyphus variants.

View File

@@ -0,0 +1,68 @@
/**
* OpenCode's default build agent system prompt.
*
* This prompt enables FULL EXECUTION mode for the build agent, allowing file
* modifications, command execution, and system changes while focusing on
* implementation and execution.
*
* Inspired by OpenCode's build agent behavior.
*
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/session/prompt/build-switch.txt
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
*/
export const BUILD_SYSTEM_PROMPT = `<system-reminder>
# Build Mode - System Reminder
BUILD MODE ACTIVE - you are in EXECUTION phase. Your responsibility is to:
- Implement features and make code changes
- Execute commands and run tests
- Fix bugs and refactor code
- Deploy and build systems
- Make all necessary file modifications
You have FULL permissions to edit files, run commands, and make system changes.
This is the implementation phase - execute decisively and thoroughly.
---
## Responsibility
Your current responsibility is to implement, build, and execute. You should:
- Write and modify code to accomplish the user's goals
- Run tests and builds to verify your changes
- Fix errors and issues that arise
- Use all available tools to complete the task efficiently
- Delegate to specialized agents when appropriate for better results
**NOTE:** You should ask the user for clarification when requirements are ambiguous,
but once the path is clear, execute confidently. The goal is to deliver working,
tested, production-ready solutions.
---
## Important
The user wants you to execute and implement. You SHOULD make edits, run necessary
tools, and make changes to accomplish the task. Use your full capabilities to
deliver excellent results.
</system-reminder>
`
/**
* OpenCode's default build agent permission configuration.
*
* Allows the build agent full execution permissions:
* - edit: "ask" - Can modify files with confirmation
* - bash: "ask" - Can execute commands with confirmation
* - webfetch: "allow" - Can fetch web content
*
* This provides balanced permissions - powerful but with safety checks.
*
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L57-L68
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
*/
export const BUILD_PERMISSION = {
edit: "ask" as const,
bash: "ask" as const,
webfetch: "allow" as const,
}

View File

@@ -1,12 +1,17 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const documentWriterAgent: AgentConfig = {
description:
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
mode: "subagent",
model: "google/gemini-3-flash-preview",
tools: { background_task: false },
prompt: `<role>
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
export function createDocumentWriterAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
return {
description:
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
mode: "subagent" as const,
model,
tools: { background_task: false },
prompt: `<role>
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.
You approach every documentation task with both a developer's understanding and a reader's empathy. Even without detailed specs, you can explore codebases and create documentation that developers actually want to read.
@@ -200,4 +205,7 @@ STOP HERE - DO NOT CONTINUE TO NEXT TASK
You are a technical writer who creates documentation that developers actually want to read.
</guide>`,
}
}
export const documentWriterAgent = createDocumentWriterAgent()

View File

@@ -1,13 +1,16 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const exploreAgent: AgentConfig = {
description:
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
mode: "subagent",
model: "opencode/grok-code",
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
const DEFAULT_MODEL = "opencode/grok-code"
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
return {
description:
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
## Your Mission
@@ -96,4 +99,7 @@ grep_app searches millions of public GitHub repos instantly — use it for exter
3. **Cross-validate with local tools** (grep, ast_grep_search, LSP) before trusting results
Flood with parallel calls. Trust only cross-validated results.`,
}
}
export const exploreAgent = createExploreAgent()

View File

@@ -1,12 +1,17 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const frontendUiUxEngineerAgent: AgentConfig = {
description:
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
mode: "subagent",
model: "google/gemini-3-pro-preview",
tools: { background_task: false },
prompt: `# Role: Designer-Turned-Developer
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
export function createFrontendUiUxEngineerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
return {
description:
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
mode: "subagent" as const,
model,
tools: { background_task: false },
prompt: `# Role: Designer-Turned-Developer
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.
@@ -79,4 +84,7 @@ Match implementation complexity to aesthetic vision:
- **Minimalist** → Restraint, precision, careful spacing and typography
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. You are capable of extraordinary creative work—don't hold back.`,
}
}
export const frontendUiUxEngineerAgent = createFrontendUiUxEngineerAgent()

View File

@@ -1,13 +1,16 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const librarianAgent: AgentConfig = {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent",
model: "anthropic/claude-sonnet-4-5",
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `# THE LIBRARIAN
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
return {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
prompt: `# THE LIBRARIAN
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
@@ -237,4 +240,7 @@ grep_app_searchGitHub(query: "useQuery")
5. **BE CONCISE**: Facts > opinions, evidence > speculation
`,
}
}
export const librarianAgent = createLibrarianAgent()

View File

@@ -1,13 +1,18 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export const multimodalLookerAgent: AgentConfig = {
description:
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
mode: "subagent",
model: "google/gemini-2.5-flash",
temperature: 0.1,
tools: { write: false, edit: false, bash: false, background_task: false },
prompt: `You interpret media files that cannot be read as plain text.
const DEFAULT_MODEL = "google/gemini-3-flash"
export function createMultimodalLookerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
return {
description:
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, bash: false, background_task: false },
prompt: `You interpret media files that cannot be read as plain text.
Your job: examine the attached file and extract ONLY what was requested.
@@ -39,4 +44,7 @@ Response rules:
- Be thorough on the goal, concise on everything else
Your output goes straight to the main agent for continued work.`,
}
}
export const multimodalLookerAgent = createMultimodalLookerAgent()

View File

@@ -30,6 +30,8 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
### Key Triggers (check BEFORE classification):
- External library/source mentioned → fire \`librarian\` background
- 2+ modules involved → fire \`explore\` background
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.
### Step 1: Classify Request Type
@@ -39,6 +41,7 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
| **Explicit** | Specific file/line, clear command | Execute directly |
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
| **Open-ended** | "Improve", "Refactor", "Add feature" | Assess codebase first |
| **GitHub Work** | Mentioned in issue, "look into X and create PR" | **Full cycle**: investigate → implement → verify → create PR (see GitHub Workflow section) |
| **Ambiguous** | Unclear scope, multiple interpretations | Ask ONE clarifying question |
### Step 2: Check for Ambiguity
@@ -186,7 +189,7 @@ STOP searching when:
## Phase 2B - Implementation
### Pre-Implementation:
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL.
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL. No announcements—just create it.
2. Mark current task \`in_progress\` before starting
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS
@@ -258,6 +261,41 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
**Vague prompts = rejected. Be exhaustive.**
### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
When you're mentioned in GitHub issues or asked to "look into" something and "create PR":
**This is NOT just investigation. This is a COMPLETE WORK CYCLE.**
#### Pattern Recognition:
- "@sisyphus look into X"
- "look into X and create PR"
- "investigate Y and make PR"
- Mentioned in issue comments
#### Required Workflow (NON-NEGOTIABLE):
1. **Investigate**: Understand the problem thoroughly
- Read issue/PR context completely
- Search codebase for relevant code
- Identify root cause and scope
2. **Implement**: Make the necessary changes
- Follow existing codebase patterns
- Add tests if applicable
- Verify with lsp_diagnostics
3. **Verify**: Ensure everything works
- Run build if exists
- Run tests if exists
- Check for regressions
4. **Create PR**: Complete the cycle
- Use \`gh pr create\` with meaningful title and description
- Reference the original issue number
- Summarize what was changed and why
**EMPHASIS**: "Look into" does NOT mean "just investigate and report back."
It means "investigate, understand, implement a solution, and create a PR."
**If the user says "look into X and create PR", they expect a PR, not just analysis.**
### Code Changes:
- Match existing patterns (if codebase is disciplined)
- Propose approach first (if codebase is chaotic)
@@ -353,6 +391,8 @@ Oracle is an expensive, high-quality reasoning model. Use it wisely.
### Usage Pattern:
Briefly announce "Consulting Oracle for [reason]" before invocation.
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
</Oracle_Usage>
<Task_Management>
@@ -416,6 +456,7 @@ Should I proceed with [recommendation], or would you prefer differently?
## Communication Style
### Be Concise
- Start work immediately. No acknowledgments ("I'm on it", "Let me...", "I'll start...")
- Answer directly without preamble
- Don't summarize what you did unless asked
- Don't explain your code unless asked
@@ -430,6 +471,16 @@ Never start responses with:
Just respond directly to the substance.
### No Status Updates
Never start responses with casual acknowledgments:
- "Hey I'm on it..."
- "I'm working on this..."
- "Let me start by..."
- "I'll get to work on..."
- "I'm going to..."
Just start working. Use todos for progress tracking—that's what they're for.
### When User is Wrong
If the user's approach seems problematic:
- Don't blindly implement it

View File

@@ -21,6 +21,8 @@ export type OverridableAgentName =
export type AgentName = BuiltinAgentName
export type AgentOverrideConfig = Partial<AgentConfig>
export type AgentOverrideConfig = Partial<AgentConfig> & {
prompt_append?: string
}
export type AgentOverrides = Partial<Record<OverridableAgentName, AgentOverrideConfig>>

View File

@@ -2,11 +2,11 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory } from "./types"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent } from "./oracle"
import { librarianAgent } from "./librarian"
import { exploreAgent } from "./explore"
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { documentWriterAgent } from "./document-writer"
import { multimodalLookerAgent } from "./multimodal-looker"
import { createLibrarianAgent } from "./librarian"
import { createExploreAgent } from "./explore"
import { createFrontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { createDocumentWriterAgent } from "./document-writer"
import { createMultimodalLookerAgent } from "./multimodal-looker"
import { deepMerge } from "../shared"
type AgentSource = AgentFactory | AgentConfig
@@ -14,11 +14,11 @@ type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
Sisyphus: createSisyphusAgent,
oracle: createOracleAgent,
librarian: librarianAgent,
explore: exploreAgent,
"frontend-ui-ux-engineer": frontendUiUxEngineerAgent,
"document-writer": documentWriterAgent,
"multimodal-looker": multimodalLookerAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
"frontend-ui-ux-engineer": createFrontendUiUxEngineerAgent,
"document-writer": createDocumentWriterAgent,
"multimodal-looker": createMultimodalLookerAgent,
}
function isFactory(source: AgentSource): source is AgentFactory {
@@ -66,7 +66,14 @@ function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
return deepMerge(base, override as Partial<AgentConfig>)
const { prompt_append, ...rest } = override
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
}
return merged
}
export function createBuiltinAgents(

View File

@@ -17,16 +17,15 @@
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
*/
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants"
import { fetchProjectContext, clearProjectContextCache } from "./project"
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants"
import { fetchProjectContext, clearProjectContextCache, invalidateProjectContextByRefreshToken } from "./project"
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage, AntigravityTokenRefreshError } from "./token"
import { transformRequest } from "./request"
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
import {
transformResponse,
transformStreamingResponse,
isStreamingResponse,
extractSignatureFromSsePayload,
} from "./response"
import { normalizeToolsForGemini, type OpenAITool } from "./tools"
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
@@ -391,7 +390,6 @@ export function createAntigravityFetch(
try {
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
// Update cached tokens
cachedTokens = {
type: "antigravity",
access_token: newTokens.access_token,
@@ -400,10 +398,8 @@ export function createAntigravityFetch(
timestamp: Date.now(),
}
// Clear project context cache on token refresh
clearProjectContextCache()
// Format and save new tokens
const formattedRefresh = formatTokenForStorage(
newTokens.refresh_token,
refreshParts.projectId || "",
@@ -418,6 +414,16 @@ export function createAntigravityFetch(
debugLog("Token refreshed successfully")
} catch (error) {
if (error instanceof AntigravityTokenRefreshError) {
if (error.isInvalidGrant) {
debugLog(`[REFRESH] Token revoked (invalid_grant), clearing caches`)
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
clearProjectContextCache()
}
throw new Error(
`Antigravity: Token refresh failed: ${error.description || error.message}${error.code ? ` (${error.code})` : ""}`
)
}
throw new Error(
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
)
@@ -535,11 +541,33 @@ export function createAntigravityFetch(
debugLog("[401] Token refreshed, retrying request...")
return executeWithEndpoints()
} catch (refreshError) {
if (refreshError instanceof AntigravityTokenRefreshError) {
if (refreshError.isInvalidGrant) {
debugLog(`[401] Token revoked (invalid_grant), clearing caches`)
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
clearProjectContextCache()
}
debugLog(`[401] Token refresh failed: ${refreshError.description || refreshError.message}`)
return new Response(
JSON.stringify({
error: {
message: refreshError.description || refreshError.message,
type: refreshError.isInvalidGrant ? "token_revoked" : "unauthorized",
code: refreshError.code || "token_refresh_failed",
},
}),
{
status: 401,
statusText: "Unauthorized",
headers: { "Content-Type": "application/json" },
}
)
}
debugLog(`[401] Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`)
return new Response(
JSON.stringify({
error: {
message: `Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`,
message: refreshError instanceof Error ? refreshError.message : "Unknown error",
type: "unauthorized",
code: "token_refresh_failed",
},

View File

@@ -267,3 +267,8 @@ export function clearProjectContextCache(accessToken?: string): void {
projectContextCache.clear()
}
}
export function invalidateProjectContextByRefreshToken(_refreshToken: string): void {
projectContextCache.clear()
debugLog(`[invalidateProjectContextByRefreshToken] Cleared all project context cache due to refresh token invalidation`)
}

View File

@@ -5,10 +5,10 @@
*/
import {
ANTIGRAVITY_HEADERS,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_API_VERSION,
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
ANTIGRAVITY_API_VERSION,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_HEADERS,
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
} from "./constants"
import type { AntigravityRequestBody } from "./types"
@@ -262,7 +262,7 @@ export function transformRequest(options: TransformRequestOptions): TransformedR
} = options
const effectiveModel =
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-preview"
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-high"
const streaming = isStreamingRequest(url, body)
const action = streaming ? "streamGenerateContent" : "generateContent"

View File

@@ -1,8 +1,3 @@
/**
* Antigravity token management utilities.
* Handles token expiration checking, refresh, and storage format parsing.
*/
import {
ANTIGRAVITY_CLIENT_ID,
ANTIGRAVITY_CLIENT_SECRET,
@@ -13,33 +8,86 @@ import type {
AntigravityRefreshParts,
AntigravityTokenExchangeResult,
AntigravityTokens,
OAuthErrorPayload,
ParsedOAuthError,
} from "./types"
/**
* Check if the access token is expired.
* Includes a 60-second safety buffer to refresh before actual expiration.
*
* @param tokens - The Antigravity tokens to check
* @returns true if the token is expired or will expire within the buffer period
*/
export function isTokenExpired(tokens: AntigravityTokens): boolean {
// Calculate when the token expires (timestamp + expires_in in ms)
// timestamp is in milliseconds, expires_in is in seconds
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
export class AntigravityTokenRefreshError extends Error {
code?: string
description?: string
status: number
statusText: string
responseBody?: string
// Check if current time is past (expiration - buffer)
constructor(options: {
message: string
code?: string
description?: string
status: number
statusText: string
responseBody?: string
}) {
super(options.message)
this.name = "AntigravityTokenRefreshError"
this.code = options.code
this.description = options.description
this.status = options.status
this.statusText = options.statusText
this.responseBody = options.responseBody
}
get isInvalidGrant(): boolean {
return this.code === "invalid_grant"
}
get isNetworkError(): boolean {
return this.status === 0
}
}
function parseOAuthErrorPayload(text: string | undefined): ParsedOAuthError {
if (!text) {
return {}
}
try {
const payload = JSON.parse(text) as OAuthErrorPayload
let code: string | undefined
if (typeof payload.error === "string") {
code = payload.error
} else if (payload.error && typeof payload.error === "object") {
code = payload.error.status ?? payload.error.code
}
return {
code,
description: payload.error_description,
}
} catch {
return { description: text }
}
}
export function isTokenExpired(tokens: AntigravityTokens): boolean {
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS
}
/**
* Refresh an access token using a refresh token.
* Exchanges the refresh token for a new access token via Google's OAuth endpoint.
*
* @param refreshToken - The refresh token to use
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Token exchange result with new access token, or throws on error
*/
const MAX_REFRESH_RETRIES = 3
const INITIAL_RETRY_DELAY_MS = 1000
function calculateRetryDelay(attempt: number): number {
return Math.min(INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt), 10000)
}
function isRetryableError(status: number): boolean {
if (status === 0) return true
if (status === 429) return true
if (status >= 500 && status < 600) return true
return false
}
export async function refreshAccessToken(
refreshToken: string,
clientId: string = ANTIGRAVITY_CLIENT_ID,
@@ -52,35 +100,81 @@ export async function refreshAccessToken(
client_secret: clientSecret,
})
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
let lastError: AntigravityTokenRefreshError | undefined
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
try {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
})
if (response.ok) {
const data = (await response.json()) as {
access_token: string
refresh_token?: string
expires_in: number
token_type: string
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token || refreshToken,
expires_in: data.expires_in,
token_type: data.token_type,
}
}
const responseBody = await response.text().catch(() => undefined)
const parsed = parseOAuthErrorPayload(responseBody)
lastError = new AntigravityTokenRefreshError({
message: parsed.description || `Token refresh failed: ${response.status} ${response.statusText}`,
code: parsed.code,
description: parsed.description,
status: response.status,
statusText: response.statusText,
responseBody,
})
if (parsed.code === "invalid_grant") {
throw lastError
}
if (!isRetryableError(response.status)) {
throw lastError
}
if (attempt < MAX_REFRESH_RETRIES) {
const delay = calculateRetryDelay(attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
}
} catch (error) {
if (error instanceof AntigravityTokenRefreshError) {
throw error
}
lastError = new AntigravityTokenRefreshError({
message: error instanceof Error ? error.message : "Network error during token refresh",
status: 0,
statusText: "Network Error",
})
if (attempt < MAX_REFRESH_RETRIES) {
const delay = calculateRetryDelay(attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw lastError || new AntigravityTokenRefreshError({
message: "Token refresh failed after all retries",
status: 0,
statusText: "Max Retries Exceeded",
})
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(
`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
)
}
const data = (await response.json()) as {
access_token: string
refresh_token?: string
expires_in: number
token_type: string
}
return {
access_token: data.access_token,
// Google may return a new refresh token, fall back to the original
refresh_token: data.refresh_token || refreshToken,
expires_in: data.expires_in,
token_type: data.token_type,
}
}
/**

View File

@@ -194,3 +194,20 @@ export interface AntigravityRefreshParts {
projectId?: string
managedProjectId?: string
}
/**
* OAuth error payload from Google
* Google returns errors in multiple formats, this handles all of them
*/
export interface OAuthErrorPayload {
error?: string | { status?: string; code?: string; message?: string }
error_description?: string
}
/**
* Parsed OAuth error with normalized fields
*/
export interface ParsedOAuthError {
code?: string
description?: string
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager"
describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
test("Gemini models include full spec (limit + modalities)", () => {
const google = (ANTIGRAVITY_PROVIDER_CONFIG as any).google
expect(google).toBeTruthy()
const models = google.models as Record<string, any>
expect(models).toBeTruthy()
const required = [
"gemini-3-pro-high",
"gemini-3-pro-medium",
"gemini-3-pro-low",
"gemini-3-flash",
"gemini-3-flash-lite",
]
for (const key of required) {
const model = models[key]
expect(model).toBeTruthy()
expect(typeof model.name).toBe("string")
expect(model.name.includes("(Antigravity)")).toBe(true)
expect(model.limit).toBeTruthy()
expect(typeof model.limit.context).toBe("number")
expect(typeof model.limit.output).toBe("number")
expect(model.modalities).toBeTruthy()
expect(Array.isArray(model.modalities.input)).toBe(true)
expect(Array.isArray(model.modalities.output)).toBe(true)
}
})
})

669
src/cli/config-manager.ts Normal file
View File

@@ -0,0 +1,669 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../shared"
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
interface NodeError extends Error {
code?: string
}
function isPermissionError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM"
}
function isFileNotFoundError(err: unknown): boolean {
const nodeErr = err as NodeError
return nodeErr?.code === "ENOENT"
}
function formatErrorWithSuggestion(err: unknown, context: string): string {
if (isPermissionError(err)) {
return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.`
}
if (isFileNotFoundError(err)) {
return `File not found while trying to ${context}. The file may have been deleted or moved.`
}
if (err instanceof SyntaxError) {
return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.`
}
const message = err instanceof Error ? err.message : String(err)
if (message.includes("ENOSPC")) {
return `Disk full: Cannot ${context}. Free up disk space and try again.`
}
if (message.includes("EROFS")) {
return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.`
}
return `Failed to ${context}: ${message}`
}
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
if (!res.ok) return null
const data = await res.json() as { version: string }
return data.version
} catch {
return null
}
}
type ConfigFormat = "json" | "jsonc" | "none"
interface OpenCodeConfig {
plugin?: string[]
[key: string]: unknown
}
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
if (existsSync(OPENCODE_JSONC)) {
return { format: "jsonc", path: OPENCODE_JSONC }
}
if (existsSync(OPENCODE_JSON)) {
return { format: "json", path: OPENCODE_JSON }
}
return { format: "none", path: OPENCODE_JSON }
}
interface ParseConfigResult {
config: OpenCodeConfig | null
error?: string
}
function isEmptyOrWhitespace(content: string): boolean {
return content.trim().length === 0
}
function parseConfig(path: string, _isJsonc: boolean): OpenCodeConfig | null {
const result = parseConfigWithError(path)
return result.config
}
function parseConfigWithError(path: string): ParseConfigResult {
try {
const stat = statSync(path)
if (stat.size === 0) {
return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` }
}
const content = readFileSync(path, "utf-8")
if (isEmptyOrWhitespace(content)) {
return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` }
}
const config = parseJsonc<OpenCodeConfig>(content)
if (config === null || config === undefined) {
return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` }
}
if (typeof config !== "object" || Array.isArray(config)) {
return { config: null, error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}` }
}
return { config }
} catch (err) {
return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) }
}
}
function ensureConfigDir(): void {
if (!existsSync(OPENCODE_CONFIG_DIR)) {
mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
}
}
export function addPluginToOpenCodeConfig(): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
const pluginName = "oh-my-opencode"
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginName] }
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
return { success: false, configPath: path, error: parseResult.error ?? "Failed to parse config file" }
}
const config = parseResult.config
const plugins = config.plugin ?? []
if (plugins.some((p) => p.startsWith(pluginName))) {
return { success: true, configPath: path }
}
config.plugin = [...plugins, pluginName]
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/
const match = content.match(pluginArrayRegex)
if (match) {
const arrayContent = match[1].trim()
const newArrayContent = arrayContent
? `${arrayContent},\n "${pluginName}"`
: `"${pluginName}"`
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginName}"],`)
writeFileSync(path, newContent)
}
} else {
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
}
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "update opencode config") }
}
}
function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
const result = { ...target }
for (const key of Object.keys(source) as Array<keyof T>) {
const sourceValue = source[key]
const targetValue = result[key]
if (
sourceValue !== null &&
typeof sourceValue === "object" &&
!Array.isArray(sourceValue) &&
targetValue !== null &&
typeof targetValue === "object" &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>
) as T[keyof T]
} else if (sourceValue !== undefined) {
result[key] = sourceValue as T[keyof T]
}
}
return result
}
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
const config: Record<string, unknown> = {
$schema: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
}
if (installConfig.hasGemini) {
config.google_auth = false
}
const agents: Record<string, Record<string, unknown>> = {}
if (!installConfig.hasClaude) {
agents["Sisyphus"] = { model: "opencode/big-pickle" }
}
if (installConfig.hasGemini) {
agents["librarian"] = { model: "google/gemini-3-flash" }
agents["explore"] = { model: "google/gemini-3-flash" }
} else if (installConfig.hasClaude && installConfig.isMax20) {
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
} else {
agents["librarian"] = { model: "opencode/big-pickle" }
agents["explore"] = { model: "opencode/big-pickle" }
}
if (!installConfig.hasChatGPT) {
agents["oracle"] = {
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle",
}
}
if (installConfig.hasGemini) {
agents["frontend-ui-ux-engineer"] = { model: "google/gemini-3-pro-high" }
agents["document-writer"] = { model: "google/gemini-3-flash" }
agents["multimodal-looker"] = { model: "google/gemini-3-flash" }
} else {
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle"
agents["frontend-ui-ux-engineer"] = { model: fallbackModel }
agents["document-writer"] = { model: fallbackModel }
agents["multimodal-looker"] = { model: fallbackModel }
}
if (Object.keys(agents).length > 0) {
config.agents = agents
}
return config
}
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(OMO_CONFIG)) {
try {
const stat = statSync(OMO_CONFIG)
const content = readFileSync(OMO_CONFIG, "utf-8")
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: OMO_CONFIG }
}
const existing = parseJsonc<Record<string, unknown>>(content)
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: OMO_CONFIG }
}
delete existing.agents
const merged = deepMerge(existing, newConfig)
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: OMO_CONFIG }
}
throw parseErr
}
} else {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: OMO_CONFIG }
} catch (err) {
return { success: false, configPath: OMO_CONFIG, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
}
}
interface OpenCodeBinaryResult {
binary: string
version: string
}
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { binary, version: output.trim() }
}
} catch {
continue
}
}
return null
}
export async function isOpenCodeInstalled(): Promise<boolean> {
const result = await findOpenCodeBinaryWithVersion()
return result !== null
}
export async function getOpenCodeVersion(): Promise<string | null> {
const result = await findOpenCodeBinaryWithVersion()
return result?.version ?? null
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const plugins: string[] = existingConfig?.plugin ?? []
if (config.hasGemini) {
const version = await fetchLatestVersion("opencode-antigravity-auth")
const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth"
if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
plugins.push(pluginEntry)
}
}
if (config.hasChatGPT) {
if (!plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))) {
plugins.push("opencode-openai-codex-auth")
}
}
const newConfig = { ...(existingConfig ?? {}), plugin: plugins }
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add auth plugins to config") }
}
}
export function setupChatGPTHotfix(): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
try {
let packageJson: Record<string, unknown> = {}
if (existsSync(OPENCODE_PACKAGE_JSON)) {
try {
const stat = statSync(OPENCODE_PACKAGE_JSON)
const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
if (stat.size > 0 && !isEmptyOrWhitespace(content)) {
packageJson = JSON.parse(content)
if (typeof packageJson !== "object" || packageJson === null || Array.isArray(packageJson)) {
packageJson = {}
}
}
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
packageJson = {}
} else {
throw parseErr
}
}
}
const deps = (packageJson.dependencies ?? {}) as Record<string, string>
deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO
packageJson.dependencies = deps
writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n")
return { success: true, configPath: OPENCODE_PACKAGE_JSON }
} catch (err) {
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
}
}
export interface BunInstallResult {
success: boolean
timedOut?: boolean
error?: string
}
export async function runBunInstall(): Promise<boolean> {
const result = await runBunInstallWithDetails()
return result.success
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
cwd: OPENCODE_CONFIG_DIR,
stdout: "pipe",
stderr: "pipe",
})
const timeoutPromise = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
)
const exitPromise = proc.exited.then(() => "completed" as const)
const result = await Promise.race([exitPromise, timeoutPromise])
if (result === "timeout") {
try {
proc.kill()
} catch {
/* intentionally empty - process may have already exited */
}
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`,
}
}
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
return {
success: false,
error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`,
}
}
return { success: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
success: false,
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
}
}
}
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
// NOTE: opencode-antigravity-auth expects full model specs (name/limit/modalities).
// If these are incomplete, models may appear but fail at runtime (e.g. 404).
models: {
"gemini-3-pro-high": {
name: "Gemini 3 Pro High (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-medium": {
name: "Gemini 3 Pro Medium (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-low": {
name: "Gemini 3 Pro Low (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash-lite": {
name: "Gemini 3 Flash Lite (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
},
},
}
const CODEX_PROVIDER_CONFIG = {
openai: {
name: "OpenAI",
api: "codex",
models: {
"gpt-5.2": { name: "GPT-5.2" },
"o3": { name: "o3", thinking: true },
"o4-mini": { name: "o4-mini", thinking: true },
"codex-1": { name: "Codex-1" },
},
},
}
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const newConfig = { ...(existingConfig ?? {}) }
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
if (config.hasGemini) {
providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google
}
if (config.hasChatGPT) {
providers.openai = CODEX_PROVIDER_CONFIG.openai
}
if (Object.keys(providers).length > 0) {
newConfig.provider = providers
}
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add provider config") }
}
}
interface OmoConfigData {
google_auth?: boolean
agents?: Record<string, { model?: string }>
}
export function detectCurrentConfig(): DetectedConfig {
const result: DetectedConfig = {
isInstalled: false,
hasClaude: true,
isMax20: true,
hasChatGPT: true,
hasGemini: false,
}
const { format, path } = detectConfigFormat()
if (format === "none") {
return result
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
return result
}
const openCodeConfig = parseResult.config
const plugins = openCodeConfig.plugin ?? []
result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode"))
if (!result.isInstalled) {
return result
}
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))
if (!existsSync(OMO_CONFIG)) {
return result
}
try {
const stat = statSync(OMO_CONFIG)
if (stat.size === 0) {
return result
}
const content = readFileSync(OMO_CONFIG, "utf-8")
if (isEmptyOrWhitespace(content)) {
return result
}
const omoConfig = parseJsonc<OmoConfigData>(content)
if (!omoConfig || typeof omoConfig !== "object") {
return result
}
const agents = omoConfig.agents ?? {}
if (agents["Sisyphus"]?.model === "opencode/big-pickle") {
result.hasClaude = false
result.isMax20 = false
} else if (agents["librarian"]?.model === "opencode/big-pickle") {
result.hasClaude = true
result.isMax20 = false
}
if (agents["oracle"]?.model?.startsWith("anthropic/")) {
result.hasChatGPT = false
} else if (agents["oracle"]?.model === "opencode/big-pickle") {
result.hasChatGPT = false
}
if (omoConfig.google_auth === false) {
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
}
} catch {
/* intentionally empty - malformed omo config returns defaults from opencode config detection */
}
return result
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as auth from "./auth"
describe("auth check", () => {
describe("getAuthProviderInfo", () => {
it("returns anthropic as always available", () => {
// #given anthropic provider
// #when getting info
const info = auth.getAuthProviderInfo("anthropic")
// #then should show plugin installed (builtin)
expect(info.id).toBe("anthropic")
expect(info.pluginInstalled).toBe(true)
})
it("returns correct name for each provider", () => {
// #given each provider
// #when getting info
// #then should have correct names
expect(auth.getAuthProviderInfo("anthropic").name).toContain("Claude")
expect(auth.getAuthProviderInfo("openai").name).toContain("ChatGPT")
expect(auth.getAuthProviderInfo("google").name).toContain("Gemini")
})
})
describe("checkAuthProvider", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns pass when plugin installed", async () => {
// #given plugin installed
getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({
id: "anthropic",
name: "Anthropic (Claude)",
pluginInstalled: true,
configured: true,
})
// #when checking
const result = await auth.checkAuthProvider("anthropic")
// #then should pass
expect(result.status).toBe("pass")
})
it("returns skip when plugin not installed", async () => {
// #given plugin not installed
getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({
id: "openai",
name: "OpenAI (ChatGPT)",
pluginInstalled: false,
configured: false,
})
// #when checking
const result = await auth.checkAuthProvider("openai")
// #then should skip
expect(result.status).toBe("skip")
expect(result.message).toContain("not installed")
})
})
describe("checkAnthropicAuth", () => {
it("returns a check result", async () => {
// #given
// #when checking anthropic
const result = await auth.checkAnthropicAuth()
// #then should return valid result
expect(result.name).toBeDefined()
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
})
})
describe("checkOpenAIAuth", () => {
it("returns a check result", async () => {
// #given
// #when checking openai
const result = await auth.checkOpenAIAuth()
// #then should return valid result
expect(result.name).toBeDefined()
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
})
})
describe("checkGoogleAuth", () => {
it("returns a check result", async () => {
// #given
// #when checking google
const result = await auth.checkGoogleAuth()
// #then should return valid result
expect(result.name).toBeDefined()
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
})
})
describe("getAuthCheckDefinitions", () => {
it("returns definitions for all three providers", () => {
// #given
// #when getting definitions
const defs = auth.getAuthCheckDefinitions()
// #then should have 3 definitions
expect(defs.length).toBe(3)
expect(defs.every((d) => d.category === "authentication")).toBe(true)
})
})
})

View File

@@ -0,0 +1,115 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
const AUTH_PLUGINS: Record<AuthProviderId, { plugin: string; name: string }> = {
anthropic: { plugin: "builtin", name: "Anthropic (Claude)" },
openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI (ChatGPT)" },
google: { plugin: "opencode-antigravity-auth", name: "Google (Gemini)" },
}
function getOpenCodeConfig(): { plugin?: string[] } | null {
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
if (!existsSync(configPath)) return null
try {
const content = readFileSync(configPath, "utf-8")
return parseJsonc<{ plugin?: string[] }>(content)
} catch {
return null
}
}
function isPluginInstalled(plugins: string[], pluginName: string): boolean {
if (pluginName === "builtin") return true
return plugins.some((p) => p === pluginName || p.startsWith(`${pluginName}@`))
}
export function getAuthProviderInfo(providerId: AuthProviderId): AuthProviderInfo {
const config = getOpenCodeConfig()
const plugins = config?.plugin ?? []
const authConfig = AUTH_PLUGINS[providerId]
const pluginInstalled = isPluginInstalled(plugins, authConfig.plugin)
return {
id: providerId,
name: authConfig.name,
pluginInstalled,
configured: pluginInstalled,
}
}
export async function checkAuthProvider(providerId: AuthProviderId): Promise<CheckResult> {
const info = getAuthProviderInfo(providerId)
const checkId = `auth-${providerId}` as keyof typeof CHECK_NAMES
const checkName = CHECK_NAMES[checkId] || info.name
if (!info.pluginInstalled) {
return {
name: checkName,
status: "skip",
message: "Auth plugin not installed",
details: [
`Plugin: ${AUTH_PLUGINS[providerId].plugin}`,
"Run: bunx oh-my-opencode install",
],
}
}
return {
name: checkName,
status: "pass",
message: "Auth plugin available",
details: [
providerId === "anthropic"
? "Run: opencode auth login (select Anthropic)"
: `Plugin: ${AUTH_PLUGINS[providerId].plugin}`,
],
}
}
export async function checkAnthropicAuth(): Promise<CheckResult> {
return checkAuthProvider("anthropic")
}
export async function checkOpenAIAuth(): Promise<CheckResult> {
return checkAuthProvider("openai")
}
export async function checkGoogleAuth(): Promise<CheckResult> {
return checkAuthProvider("google")
}
export function getAuthCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.AUTH_ANTHROPIC,
name: CHECK_NAMES[CHECK_IDS.AUTH_ANTHROPIC],
category: "authentication",
check: checkAnthropicAuth,
critical: false,
},
{
id: CHECK_IDS.AUTH_OPENAI,
name: CHECK_NAMES[CHECK_IDS.AUTH_OPENAI],
category: "authentication",
check: checkOpenAIAuth,
critical: false,
},
{
id: CHECK_IDS.AUTH_GOOGLE,
name: CHECK_NAMES[CHECK_IDS.AUTH_GOOGLE],
category: "authentication",
check: checkGoogleAuth,
critical: false,
},
]
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as config from "./config"
describe("config check", () => {
describe("validateConfig", () => {
it("returns valid: false for non-existent file", () => {
// #given non-existent file path
// #when validating
const result = config.validateConfig("/non/existent/path.json")
// #then should indicate invalid
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})
})
describe("getConfigInfo", () => {
it("returns exists: false when no config found", () => {
// #given no config file exists
// #when getting config info
const info = config.getConfigInfo()
// #then should handle gracefully
expect(typeof info.exists).toBe("boolean")
expect(typeof info.valid).toBe("boolean")
})
})
describe("checkConfigValidity", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns pass when no config exists (uses defaults)", async () => {
// #given no config file
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: false,
path: null,
format: null,
valid: true,
errors: [],
})
// #when checking validity
const result = await config.checkConfigValidity()
// #then should pass with default message
expect(result.status).toBe("pass")
expect(result.message).toContain("default")
})
it("returns pass when config is valid", async () => {
// #given valid config
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: true,
path: "/home/user/.config/opencode/oh-my-opencode.json",
format: "json",
valid: true,
errors: [],
})
// #when checking validity
const result = await config.checkConfigValidity()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("JSON")
})
it("returns fail when config has validation errors", async () => {
// #given invalid config
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: true,
path: "/home/user/.config/opencode/oh-my-opencode.json",
format: "json",
valid: false,
errors: ["agents.oracle: Invalid model format"],
})
// #when checking validity
const result = await config.checkConfigValidity()
// #then should fail with errors
expect(result.status).toBe("fail")
expect(result.details?.some((d) => d.includes("Error"))).toBe(true)
})
})
describe("getConfigCheckDefinition", () => {
it("returns valid check definition", () => {
// #given
// #when getting definition
const def = config.getConfigCheckDefinition()
// #then should have required properties
expect(def.id).toBe("config-validation")
expect(def.category).toBe("configuration")
expect(def.critical).toBe(false)
})
})
})

View File

@@ -0,0 +1,123 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, ConfigInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
import { OhMyOpenCodeConfigSchema } from "../../../config"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
function findConfigPath(): { path: string; format: "json" | "jsonc" } | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" }
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" }
}
return null
}
export function validateConfig(configPath: string): { valid: boolean; errors: string[] } {
try {
const content = readFileSync(configPath, "utf-8")
const rawConfig = parseJsonc<Record<string, unknown>>(content)
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
if (!result.success) {
const errors = result.error.issues.map(
(i) => `${i.path.join(".")}: ${i.message}`
)
return { valid: false, errors }
}
return { valid: true, errors: [] }
} catch (err) {
return {
valid: false,
errors: [err instanceof Error ? err.message : "Failed to parse config"],
}
}
}
export function getConfigInfo(): ConfigInfo {
const configPath = findConfigPath()
if (!configPath) {
return {
exists: false,
path: null,
format: null,
valid: true,
errors: [],
}
}
if (!existsSync(configPath.path)) {
return {
exists: false,
path: configPath.path,
format: configPath.format,
valid: true,
errors: [],
}
}
const validation = validateConfig(configPath.path)
return {
exists: true,
path: configPath.path,
format: configPath.format,
valid: validation.valid,
errors: validation.errors,
}
}
export async function checkConfigValidity(): Promise<CheckResult> {
const info = getConfigInfo()
if (!info.exists) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: "Using default configuration",
details: ["No custom config file found (optional)"],
}
}
if (!info.valid) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "fail",
message: "Configuration has validation errors",
details: [
`Path: ${info.path}`,
...info.errors.map((e) => `Error: ${e}`),
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: `Valid ${info.format?.toUpperCase()} config`,
details: [`Path: ${info.path}`],
}
}
export function getConfigCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.CONFIG_VALIDATION,
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
category: "configuration",
check: checkConfigValidity,
critical: false,
}
}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as deps from "./dependencies"
describe("dependencies check", () => {
describe("checkAstGrepCli", () => {
it("returns dependency info", async () => {
// #given
// #when checking ast-grep cli
const info = await deps.checkAstGrepCli()
// #then should return valid info
expect(info.name).toBe("AST-Grep CLI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
})
})
describe("checkAstGrepNapi", () => {
it("returns dependency info", () => {
// #given
// #when checking ast-grep napi
const info = deps.checkAstGrepNapi()
// #then should return valid info
expect(info.name).toBe("AST-Grep NAPI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
})
})
describe("checkCommentChecker", () => {
it("returns dependency info", async () => {
// #given
// #when checking comment checker
const info = await deps.checkCommentChecker()
// #then should return valid info
expect(info.name).toBe("Comment Checker")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
})
})
describe("checkDependencyAstGrepCli", () => {
let checkSpy: ReturnType<typeof spyOn>
afterEach(() => {
checkSpy?.mockRestore()
})
it("returns pass when installed", async () => {
// #given ast-grep installed
checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({
name: "AST-Grep CLI",
required: false,
installed: true,
version: "0.25.0",
path: "/usr/local/bin/sg",
})
// #when checking
const result = await deps.checkDependencyAstGrepCli()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("0.25.0")
})
it("returns warn when not installed", async () => {
// #given ast-grep not installed
checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({
name: "AST-Grep CLI",
required: false,
installed: false,
version: null,
path: null,
installHint: "Install: npm install -g @ast-grep/cli",
})
// #when checking
const result = await deps.checkDependencyAstGrepCli()
// #then should warn (optional)
expect(result.status).toBe("warn")
expect(result.message).toContain("optional")
})
})
describe("checkDependencyAstGrepNapi", () => {
let checkSpy: ReturnType<typeof spyOn>
afterEach(() => {
checkSpy?.mockRestore()
})
it("returns pass when installed", async () => {
// #given napi installed
checkSpy = spyOn(deps, "checkAstGrepNapi").mockReturnValue({
name: "AST-Grep NAPI",
required: false,
installed: true,
version: null,
path: null,
})
// #when checking
const result = await deps.checkDependencyAstGrepNapi()
// #then should pass
expect(result.status).toBe("pass")
})
})
describe("checkDependencyCommentChecker", () => {
let checkSpy: ReturnType<typeof spyOn>
afterEach(() => {
checkSpy?.mockRestore()
})
it("returns warn when not installed", async () => {
// #given comment checker not installed
checkSpy = spyOn(deps, "checkCommentChecker").mockResolvedValue({
name: "Comment Checker",
required: false,
installed: false,
version: null,
path: null,
installHint: "Hook will be disabled if not available",
})
// #when checking
const result = await deps.checkDependencyCommentChecker()
// #then should warn
expect(result.status).toBe("warn")
})
})
describe("getDependencyCheckDefinitions", () => {
it("returns definitions for all dependencies", () => {
// #given
// #when getting definitions
const defs = deps.getDependencyCheckDefinitions()
// #then should have 3 definitions
expect(defs.length).toBe(3)
expect(defs.every((d) => d.category === "dependencies")).toBe(true)
expect(defs.every((d) => d.critical === false)).toBe(true)
})
})
})

View File

@@ -0,0 +1,163 @@
import type { CheckResult, CheckDefinition, DependencyInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { exists: true, path: output.trim() }
}
} catch {
// intentionally empty - binary not found
}
return { exists: false, path: null }
}
async function getBinaryVersion(binary: string): Promise<string | null> {
try {
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return output.trim().split("\n")[0]
}
} catch {
// intentionally empty - version unavailable
}
return null
}
export async function checkAstGrepCli(): Promise<DependencyInfo> {
const binaryCheck = await checkBinaryExists("sg")
const altBinaryCheck = !binaryCheck.exists ? await checkBinaryExists("ast-grep") : null
const binary = binaryCheck.exists ? binaryCheck : altBinaryCheck
if (!binary || !binary.exists) {
return {
name: "AST-Grep CLI",
required: false,
installed: false,
version: null,
path: null,
installHint: "Install: npm install -g @ast-grep/cli",
}
}
const version = await getBinaryVersion(binary.path!)
return {
name: "AST-Grep CLI",
required: false,
installed: true,
version,
path: binary.path,
}
}
export function checkAstGrepNapi(): DependencyInfo {
try {
require.resolve("@ast-grep/napi")
return {
name: "AST-Grep NAPI",
required: false,
installed: true,
version: null,
path: null,
}
} catch {
return {
name: "AST-Grep NAPI",
required: false,
installed: false,
version: null,
path: null,
installHint: "Will use CLI fallback if available",
}
}
}
export async function checkCommentChecker(): Promise<DependencyInfo> {
const binaryCheck = await checkBinaryExists("comment-checker")
if (!binaryCheck.exists) {
return {
name: "Comment Checker",
required: false,
installed: false,
version: null,
path: null,
installHint: "Hook will be disabled if not available",
}
}
const version = await getBinaryVersion("comment-checker")
return {
name: "Comment Checker",
required: false,
installed: true,
version,
path: binaryCheck.path,
}
}
function dependencyToCheckResult(dep: DependencyInfo, checkName: string): CheckResult {
if (dep.installed) {
return {
name: checkName,
status: "pass",
message: dep.version ?? "installed",
details: dep.path ? [`Path: ${dep.path}`] : undefined,
}
}
return {
name: checkName,
status: "warn",
message: "Not installed (optional)",
details: dep.installHint ? [dep.installHint] : undefined,
}
}
export async function checkDependencyAstGrepCli(): Promise<CheckResult> {
const info = await checkAstGrepCli()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI])
}
export async function checkDependencyAstGrepNapi(): Promise<CheckResult> {
const info = checkAstGrepNapi()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI])
}
export async function checkDependencyCommentChecker(): Promise<CheckResult> {
const info = await checkCommentChecker()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER])
}
export function getDependencyCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.DEP_AST_GREP_CLI,
name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI],
category: "dependencies",
check: checkDependencyAstGrepCli,
critical: false,
},
{
id: CHECK_IDS.DEP_AST_GREP_NAPI,
name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI],
category: "dependencies",
check: checkDependencyAstGrepNapi,
critical: false,
},
{
id: CHECK_IDS.DEP_COMMENT_CHECKER,
name: CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER],
category: "dependencies",
check: checkDependencyCommentChecker,
critical: false,
},
]
}

View File

@@ -0,0 +1,31 @@
import type { CheckDefinition } from "../types"
import { getOpenCodeCheckDefinition } from "./opencode"
import { getPluginCheckDefinition } from "./plugin"
import { getConfigCheckDefinition } from "./config"
import { getAuthCheckDefinitions } from "./auth"
import { getDependencyCheckDefinitions } from "./dependencies"
import { getLspCheckDefinition } from "./lsp"
import { getMcpCheckDefinitions } from "./mcp"
import { getVersionCheckDefinition } from "./version"
export * from "./opencode"
export * from "./plugin"
export * from "./config"
export * from "./auth"
export * from "./dependencies"
export * from "./lsp"
export * from "./mcp"
export * from "./version"
export function getAllCheckDefinitions(): CheckDefinition[] {
return [
getOpenCodeCheckDefinition(),
getPluginCheckDefinition(),
getConfigCheckDefinition(),
...getAuthCheckDefinitions(),
...getDependencyCheckDefinitions(),
getLspCheckDefinition(),
...getMcpCheckDefinitions(),
getVersionCheckDefinition(),
]
}

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as lsp from "./lsp"
import type { LspServerInfo } from "../types"
describe("lsp check", () => {
describe("getLspServersInfo", () => {
it("returns array of server info", async () => {
// #given
// #when getting servers info
const servers = await lsp.getLspServersInfo()
// #then should return array with expected structure
expect(Array.isArray(servers)).toBe(true)
servers.forEach((s) => {
expect(s.id).toBeDefined()
expect(typeof s.installed).toBe("boolean")
expect(Array.isArray(s.extensions)).toBe(true)
})
})
})
describe("getLspServerStats", () => {
it("counts installed servers correctly", () => {
// #given servers with mixed installation status
const servers = [
{ id: "ts", installed: true, extensions: [".ts"], source: "builtin" as const },
{ id: "py", installed: false, extensions: [".py"], source: "builtin" as const },
{ id: "go", installed: true, extensions: [".go"], source: "builtin" as const },
]
// #when getting stats
const stats = lsp.getLspServerStats(servers)
// #then should count correctly
expect(stats.installed).toBe(2)
expect(stats.total).toBe(3)
})
it("handles empty array", () => {
// #given no servers
const servers: LspServerInfo[] = []
// #when getting stats
const stats = lsp.getLspServerStats(servers)
// #then should return zeros
expect(stats.installed).toBe(0)
expect(stats.total).toBe(0)
})
})
describe("checkLspServers", () => {
let getServersSpy: ReturnType<typeof spyOn>
afterEach(() => {
getServersSpy?.mockRestore()
})
it("returns warn when no servers installed", async () => {
// #given no servers installed
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
{ id: "typescript-language-server", installed: false, extensions: [".ts"], source: "builtin" },
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
])
// #when checking
const result = await lsp.checkLspServers()
// #then should warn
expect(result.status).toBe("warn")
expect(result.message).toContain("No LSP servers")
})
it("returns pass when servers installed", async () => {
// #given some servers installed
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
{ id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" },
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
])
// #when checking
const result = await lsp.checkLspServers()
// #then should pass with count
expect(result.status).toBe("pass")
expect(result.message).toContain("1/2")
})
it("lists installed and missing servers in details", async () => {
// #given mixed installation
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
{ id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" },
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
])
// #when checking
const result = await lsp.checkLspServers()
// #then should list both
expect(result.details?.some((d) => d.includes("Installed"))).toBe(true)
expect(result.details?.some((d) => d.includes("Not found"))).toBe(true)
})
})
describe("getLspCheckDefinition", () => {
it("returns valid check definition", () => {
// #given
// #when getting definition
const def = lsp.getLspCheckDefinition()
// #then should have required properties
expect(def.id).toBe("lsp-servers")
expect(def.category).toBe("tools")
expect(def.critical).toBe(false)
})
})
})

View File

@@ -0,0 +1,85 @@
import type { CheckResult, CheckDefinition, LspServerInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
const DEFAULT_LSP_SERVERS: Array<{
id: string
binary: string
extensions: string[]
}> = [
{ id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] },
{ id: "pyright", binary: "pyright-langserver", extensions: [".py"] },
{ id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] },
{ id: "gopls", binary: "gopls", extensions: [".go"] },
]
async function checkBinaryExists(binary: string): Promise<boolean> {
try {
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
await proc.exited
return proc.exitCode === 0
} catch {
return false
}
}
export async function getLspServersInfo(): Promise<LspServerInfo[]> {
const servers: LspServerInfo[] = []
for (const server of DEFAULT_LSP_SERVERS) {
const installed = await checkBinaryExists(server.binary)
servers.push({
id: server.id,
installed,
extensions: server.extensions,
source: "builtin",
})
}
return servers
}
export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } {
const installed = servers.filter((s) => s.installed).length
return { installed, total: servers.length }
}
export async function checkLspServers(): Promise<CheckResult> {
const servers = await getLspServersInfo()
const stats = getLspServerStats(servers)
const installedServers = servers.filter((s) => s.installed)
const missingServers = servers.filter((s) => !s.installed)
if (stats.installed === 0) {
return {
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
status: "warn",
message: "No LSP servers detected",
details: [
"LSP tools will have limited functionality",
...missingServers.map((s) => `Missing: ${s.id}`),
],
}
}
const details = [
...installedServers.map((s) => `Installed: ${s.id}`),
...missingServers.map((s) => `Not found: ${s.id} (optional)`),
]
return {
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
status: "pass",
message: `${stats.installed}/${stats.total} servers available`,
details,
}
}
export function getLspCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.LSP_SERVERS,
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
category: "tools",
check: checkLspServers,
critical: false,
}
}

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as mcp from "./mcp"
describe("mcp check", () => {
describe("getBuiltinMcpInfo", () => {
it("returns builtin servers", () => {
// #given
// #when getting builtin info
const servers = mcp.getBuiltinMcpInfo()
// #then should include expected servers
expect(servers.length).toBe(3)
expect(servers.every((s) => s.type === "builtin")).toBe(true)
expect(servers.every((s) => s.enabled === true)).toBe(true)
expect(servers.map((s) => s.id)).toContain("context7")
expect(servers.map((s) => s.id)).toContain("websearch_exa")
expect(servers.map((s) => s.id)).toContain("grep_app")
})
})
describe("getUserMcpInfo", () => {
it("returns empty array when no user config", () => {
// #given no user config exists
// #when getting user info
const servers = mcp.getUserMcpInfo()
// #then should return array (may be empty)
expect(Array.isArray(servers)).toBe(true)
})
})
describe("checkBuiltinMcpServers", () => {
it("returns pass with server count", async () => {
// #given
// #when checking builtin servers
const result = await mcp.checkBuiltinMcpServers()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("3")
expect(result.message).toContain("enabled")
})
it("lists enabled servers in details", async () => {
// #given
// #when checking builtin servers
const result = await mcp.checkBuiltinMcpServers()
// #then should list servers
expect(result.details?.some((d) => d.includes("context7"))).toBe(true)
expect(result.details?.some((d) => d.includes("websearch_exa"))).toBe(true)
expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true)
})
})
describe("checkUserMcpServers", () => {
let getUserSpy: ReturnType<typeof spyOn>
afterEach(() => {
getUserSpy?.mockRestore()
})
it("returns skip when no user config", async () => {
// #given no user servers
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([])
// #when checking
const result = await mcp.checkUserMcpServers()
// #then should skip
expect(result.status).toBe("skip")
expect(result.message).toContain("No user MCP")
})
it("returns pass when valid user servers", async () => {
// #given valid user servers
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
{ id: "custom-mcp", type: "user", enabled: true, valid: true },
])
// #when checking
const result = await mcp.checkUserMcpServers()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("1")
})
it("returns warn when servers have issues", async () => {
// #given invalid server config
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
{ id: "bad-mcp", type: "user", enabled: true, valid: false, error: "Missing command" },
])
// #when checking
const result = await mcp.checkUserMcpServers()
// #then should warn
expect(result.status).toBe("warn")
expect(result.details?.some((d) => d.includes("Invalid"))).toBe(true)
})
})
describe("getMcpCheckDefinitions", () => {
it("returns definitions for builtin and user", () => {
// #given
// #when getting definitions
const defs = mcp.getMcpCheckDefinitions()
// #then should have 2 definitions
expect(defs.length).toBe(2)
expect(defs.every((d) => d.category === "tools")).toBe(true)
expect(defs.map((d) => d.id)).toContain("mcp-builtin")
expect(defs.map((d) => d.id)).toContain("mcp-user")
})
})
})

View File

@@ -0,0 +1,128 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, McpServerInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
const BUILTIN_MCP_SERVERS = ["context7", "websearch_exa", "grep_app"]
const MCP_CONFIG_PATHS = [
join(homedir(), ".claude", ".mcp.json"),
join(process.cwd(), ".mcp.json"),
join(process.cwd(), ".claude", ".mcp.json"),
]
interface McpConfig {
mcpServers?: Record<string, unknown>
}
function loadUserMcpConfig(): Record<string, unknown> {
const servers: Record<string, unknown> = {}
for (const configPath of MCP_CONFIG_PATHS) {
if (!existsSync(configPath)) continue
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<McpConfig>(content)
if (config.mcpServers) {
Object.assign(servers, config.mcpServers)
}
} catch {
// intentionally empty - skip invalid configs
}
}
return servers
}
export function getBuiltinMcpInfo(): McpServerInfo[] {
return BUILTIN_MCP_SERVERS.map((id) => ({
id,
type: "builtin" as const,
enabled: true,
valid: true,
}))
}
export function getUserMcpInfo(): McpServerInfo[] {
const userServers = loadUserMcpConfig()
const servers: McpServerInfo[] = []
for (const [id, config] of Object.entries(userServers)) {
const isValid = typeof config === "object" && config !== null
servers.push({
id,
type: "user",
enabled: true,
valid: isValid,
error: isValid ? undefined : "Invalid configuration format",
})
}
return servers
}
export async function checkBuiltinMcpServers(): Promise<CheckResult> {
const servers = getBuiltinMcpInfo()
return {
name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN],
status: "pass",
message: `${servers.length} built-in servers enabled`,
details: servers.map((s) => `Enabled: ${s.id}`),
}
}
export async function checkUserMcpServers(): Promise<CheckResult> {
const servers = getUserMcpInfo()
if (servers.length === 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
status: "skip",
message: "No user MCP configuration found",
details: ["Optional: Add .mcp.json for custom MCP servers"],
}
}
const invalidServers = servers.filter((s) => !s.valid)
if (invalidServers.length > 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
status: "warn",
message: `${invalidServers.length} server(s) have configuration issues`,
details: [
...servers.filter((s) => s.valid).map((s) => `Valid: ${s.id}`),
...invalidServers.map((s) => `Invalid: ${s.id} - ${s.error}`),
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
status: "pass",
message: `${servers.length} user server(s) configured`,
details: servers.map((s) => `Configured: ${s.id}`),
}
}
export function getMcpCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.MCP_BUILTIN,
name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN],
category: "tools",
check: checkBuiltinMcpServers,
critical: false,
},
{
id: CHECK_IDS.MCP_USER,
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
category: "tools",
check: checkUserMcpServers,
critical: false,
},
]
}

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
import * as opencode from "./opencode"
import { MIN_OPENCODE_VERSION } from "../constants"
describe("opencode check", () => {
describe("compareVersions", () => {
it("returns true when current >= minimum", () => {
// #given versions where current is greater
// #when comparing
// #then should return true
expect(opencode.compareVersions("1.0.200", "1.0.150")).toBe(true)
expect(opencode.compareVersions("1.1.0", "1.0.150")).toBe(true)
expect(opencode.compareVersions("2.0.0", "1.0.150")).toBe(true)
})
it("returns true when versions are equal", () => {
// #given equal versions
// #when comparing
// #then should return true
expect(opencode.compareVersions("1.0.150", "1.0.150")).toBe(true)
})
it("returns false when current < minimum", () => {
// #given version below minimum
// #when comparing
// #then should return false
expect(opencode.compareVersions("1.0.100", "1.0.150")).toBe(false)
expect(opencode.compareVersions("0.9.0", "1.0.150")).toBe(false)
})
it("handles version prefixes", () => {
// #given version with v prefix
// #when comparing
// #then should strip prefix and compare correctly
expect(opencode.compareVersions("v1.0.200", "1.0.150")).toBe(true)
})
it("handles prerelease versions", () => {
// #given prerelease version
// #when comparing
// #then should use base version
expect(opencode.compareVersions("1.0.200-beta.1", "1.0.150")).toBe(true)
})
})
describe("getOpenCodeInfo", () => {
it("returns installed: false when binary not found", async () => {
// #given no opencode binary
const spy = spyOn(opencode, "findOpenCodeBinary").mockResolvedValue(null)
// #when getting info
const info = await opencode.getOpenCodeInfo()
// #then should indicate not installed
expect(info.installed).toBe(false)
expect(info.version).toBeNull()
expect(info.path).toBeNull()
expect(info.binary).toBeNull()
spy.mockRestore()
})
})
describe("checkOpenCodeInstallation", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns fail when not installed", async () => {
// #given opencode not installed
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
installed: false,
version: null,
path: null,
binary: null,
})
// #when checking installation
const result = await opencode.checkOpenCodeInstallation()
// #then should fail with installation hint
expect(result.status).toBe("fail")
expect(result.message).toContain("not installed")
expect(result.details).toBeDefined()
expect(result.details?.some((d) => d.includes("opencode.ai"))).toBe(true)
})
it("returns warn when version below minimum", async () => {
// #given old version installed
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
installed: true,
version: "1.0.100",
path: "/usr/local/bin/opencode",
binary: "opencode",
})
// #when checking installation
const result = await opencode.checkOpenCodeInstallation()
// #then should warn about old version
expect(result.status).toBe("warn")
expect(result.message).toContain("below minimum")
expect(result.details?.some((d) => d.includes(MIN_OPENCODE_VERSION))).toBe(true)
})
it("returns pass when properly installed", async () => {
// #given current version installed
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
installed: true,
version: "1.0.200",
path: "/usr/local/bin/opencode",
binary: "opencode",
})
// #when checking installation
const result = await opencode.checkOpenCodeInstallation()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("1.0.200")
})
})
describe("getOpenCodeCheckDefinition", () => {
it("returns valid check definition", () => {
// #given
// #when getting definition
const def = opencode.getOpenCodeCheckDefinition()
// #then should have required properties
expect(def.id).toBe("opencode-installation")
expect(def.category).toBe("installation")
expect(def.critical).toBe(true)
expect(typeof def.check).toBe("function")
})
})
})

View File

@@ -0,0 +1,118 @@
import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants"
export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { binary, path: output.trim() }
}
} catch {
continue
}
}
return null
}
export async function getOpenCodeVersion(binary: string): Promise<string | null> {
try {
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return output.trim()
}
} catch {
return null
}
return null
}
export function compareVersions(current: string, minimum: string): boolean {
const parseVersion = (v: string): number[] => {
const cleaned = v.replace(/^v/, "").split("-")[0]
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
}
const curr = parseVersion(current)
const min = parseVersion(minimum)
for (let i = 0; i < Math.max(curr.length, min.length); i++) {
const c = curr[i] ?? 0
const m = min[i] ?? 0
if (c > m) return true
if (c < m) return false
}
return true
}
export async function getOpenCodeInfo(): Promise<OpenCodeInfo> {
const binaryInfo = await findOpenCodeBinary()
if (!binaryInfo) {
return {
installed: false,
version: null,
path: null,
binary: null,
}
}
const version = await getOpenCodeVersion(binaryInfo.binary)
return {
installed: true,
version,
path: binaryInfo.path,
binary: binaryInfo.binary as "opencode" | "opencode-desktop",
}
}
export async function checkOpenCodeInstallation(): Promise<CheckResult> {
const info = await getOpenCodeInfo()
if (!info.installed) {
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "fail",
message: "OpenCode is not installed",
details: [
"Visit: https://opencode.ai/docs for installation instructions",
"Run: npm install -g opencode",
],
}
}
if (info.version && !compareVersions(info.version, MIN_OPENCODE_VERSION)) {
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "warn",
message: `Version ${info.version} is below minimum ${MIN_OPENCODE_VERSION}`,
details: [
`Current: ${info.version}`,
`Required: >= ${MIN_OPENCODE_VERSION}`,
"Run: npm update -g opencode",
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "pass",
message: info.version ?? "installed",
details: info.path ? [`Path: ${info.path}`] : undefined,
}
}
export function getOpenCodeCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.OPENCODE_INSTALLATION,
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
category: "installation",
check: checkOpenCodeInstallation,
critical: true,
}
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as plugin from "./plugin"
describe("plugin check", () => {
describe("getPluginInfo", () => {
it("returns registered: false when config not found", () => {
// #given no config file exists
// #when getting plugin info
// #then should indicate not registered
const info = plugin.getPluginInfo()
expect(typeof info.registered).toBe("boolean")
expect(typeof info.isPinned).toBe("boolean")
})
})
describe("checkPluginRegistration", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns fail when config file not found", async () => {
// #given no config file
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: false,
configPath: null,
entry: null,
isPinned: false,
pinnedVersion: null,
})
// #when checking registration
const result = await plugin.checkPluginRegistration()
// #then should fail with hint
expect(result.status).toBe("fail")
expect(result.message).toContain("not found")
})
it("returns fail when plugin not registered", async () => {
// #given config exists but plugin not registered
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: false,
configPath: "/home/user/.config/opencode/opencode.json",
entry: null,
isPinned: false,
pinnedVersion: null,
})
// #when checking registration
const result = await plugin.checkPluginRegistration()
// #then should fail
expect(result.status).toBe("fail")
expect(result.message).toContain("not registered")
})
it("returns pass when plugin registered", async () => {
// #given plugin registered
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: true,
configPath: "/home/user/.config/opencode/opencode.json",
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
})
// #when checking registration
const result = await plugin.checkPluginRegistration()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("Registered")
})
it("indicates pinned version when applicable", async () => {
// #given plugin pinned to version
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: true,
configPath: "/home/user/.config/opencode/opencode.json",
entry: "oh-my-opencode@2.7.0",
isPinned: true,
pinnedVersion: "2.7.0",
})
// #when checking registration
const result = await plugin.checkPluginRegistration()
// #then should show pinned version
expect(result.status).toBe("pass")
expect(result.message).toContain("pinned")
expect(result.message).toContain("2.7.0")
})
})
describe("getPluginCheckDefinition", () => {
it("returns valid check definition", () => {
// #given
// #when getting definition
const def = plugin.getPluginCheckDefinition()
// #then should have required properties
expect(def.id).toBe("plugin-registration")
expect(def.category).toBe("installation")
expect(def.critical).toBe(true)
})
})
})

View File

@@ -0,0 +1,127 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
if (existsSync(OPENCODE_JSONC)) {
return { path: OPENCODE_JSONC, format: "jsonc" }
}
if (existsSync(OPENCODE_JSON)) {
return { path: OPENCODE_JSON, format: "json" }
}
return null
}
function findPluginEntry(plugins: string[]): { entry: string; isPinned: boolean; version: string | null } | null {
for (const plugin of plugins) {
if (plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`)) {
const isPinned = plugin.includes("@")
const version = isPinned ? plugin.split("@")[1] : null
return { entry: plugin, isPinned, version }
}
}
return null
}
export function getPluginInfo(): PluginInfo {
const configInfo = detectConfigPath()
if (!configInfo) {
return {
registered: false,
configPath: null,
entry: null,
isPinned: false,
pinnedVersion: null,
}
}
try {
const content = readFileSync(configInfo.path, "utf-8")
const config = parseJsonc<{ plugin?: string[] }>(content)
const plugins = config.plugin ?? []
const pluginEntry = findPluginEntry(plugins)
if (!pluginEntry) {
return {
registered: false,
configPath: configInfo.path,
entry: null,
isPinned: false,
pinnedVersion: null,
}
}
return {
registered: true,
configPath: configInfo.path,
entry: pluginEntry.entry,
isPinned: pluginEntry.isPinned,
pinnedVersion: pluginEntry.version,
}
} catch {
return {
registered: false,
configPath: configInfo.path,
entry: null,
isPinned: false,
pinnedVersion: null,
}
}
}
export async function checkPluginRegistration(): Promise<CheckResult> {
const info = getPluginInfo()
if (!info.configPath) {
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
status: "fail",
message: "OpenCode config file not found",
details: [
"Run: bunx oh-my-opencode install",
`Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`,
],
}
}
if (!info.registered) {
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
status: "fail",
message: "Plugin not registered in config",
details: [
"Run: bunx oh-my-opencode install",
`Config: ${info.configPath}`,
],
}
}
const message = info.isPinned
? `Registered (pinned: ${info.pinnedVersion})`
: "Registered"
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
status: "pass",
message,
details: [`Config: ${info.configPath}`],
}
}
export function getPluginCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.PLUGIN_REGISTRATION,
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
category: "installation",
check: checkPluginRegistration,
critical: true,
}
}

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as version from "./version"
describe("version check", () => {
describe("getVersionInfo", () => {
it("returns version check info structure", async () => {
// #given
// #when getting version info
const info = await version.getVersionInfo()
// #then should have expected structure
expect(typeof info.isUpToDate).toBe("boolean")
expect(typeof info.isLocalDev).toBe("boolean")
expect(typeof info.isPinned).toBe("boolean")
})
})
describe("checkVersionStatus", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns pass when in local dev mode", async () => {
// #given local dev mode
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "local-dev",
latestVersion: "2.7.0",
isUpToDate: true,
isLocalDev: true,
isPinned: false,
})
// #when checking
const result = await version.checkVersionStatus()
// #then should pass with dev message
expect(result.status).toBe("pass")
expect(result.message).toContain("local development")
})
it("returns pass when pinned", async () => {
// #given pinned version
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.6.0",
latestVersion: "2.7.0",
isUpToDate: true,
isLocalDev: false,
isPinned: true,
})
// #when checking
const result = await version.checkVersionStatus()
// #then should pass with pinned message
expect(result.status).toBe("pass")
expect(result.message).toContain("Pinned")
})
it("returns warn when unable to determine version", async () => {
// #given no version info
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: null,
latestVersion: "2.7.0",
isUpToDate: false,
isLocalDev: false,
isPinned: false,
})
// #when checking
const result = await version.checkVersionStatus()
// #then should warn
expect(result.status).toBe("warn")
expect(result.message).toContain("Unable to determine")
})
it("returns warn when network error", async () => {
// #given network error
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.6.0",
latestVersion: null,
isUpToDate: true,
isLocalDev: false,
isPinned: false,
})
// #when checking
const result = await version.checkVersionStatus()
// #then should warn
expect(result.status).toBe("warn")
expect(result.details?.some((d) => d.includes("network"))).toBe(true)
})
it("returns warn when update available", async () => {
// #given update available
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.6.0",
latestVersion: "2.7.0",
isUpToDate: false,
isLocalDev: false,
isPinned: false,
})
// #when checking
const result = await version.checkVersionStatus()
// #then should warn with update info
expect(result.status).toBe("warn")
expect(result.message).toContain("Update available")
expect(result.message).toContain("2.6.0")
expect(result.message).toContain("2.7.0")
})
it("returns pass when up to date", async () => {
// #given up to date
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.7.0",
latestVersion: "2.7.0",
isUpToDate: true,
isLocalDev: false,
isPinned: false,
})
// #when checking
const result = await version.checkVersionStatus()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("Up to date")
})
})
describe("getVersionCheckDefinition", () => {
it("returns valid check definition", () => {
// #given
// #when getting definition
const def = version.getVersionCheckDefinition()
// #then should have required properties
expect(def.id).toBe("version-status")
expect(def.category).toBe("updates")
expect(def.critical).toBe(false)
})
})
})

View File

@@ -0,0 +1,177 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
async function fetchLatestVersion(): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
signal: AbortSignal.timeout(5000),
})
if (!res.ok) return null
const data = (await res.json()) as { version: string }
return data.version
} catch {
return null
}
}
function getCurrentVersion(): {
version: string | null
isLocalDev: boolean
isPinned: boolean
pinnedVersion: string | null
} {
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
if (!existsSync(configPath)) {
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<{ plugin?: string[] }>(content)
const plugins = config.plugin ?? []
for (const plugin of plugins) {
if (plugin.startsWith("file:") && plugin.includes(PACKAGE_NAME)) {
return { version: "local-dev", isLocalDev: true, isPinned: false, pinnedVersion: null }
}
if (plugin.startsWith(`${PACKAGE_NAME}@`)) {
const pinnedVersion = plugin.split("@")[1]
return { version: pinnedVersion, isLocalDev: false, isPinned: true, pinnedVersion }
}
if (plugin === PACKAGE_NAME) {
if (existsSync(OPENCODE_PACKAGE_JSON)) {
try {
const pkgContent = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(pkgContent) as { dependencies?: Record<string, string> }
const depVersion = pkg.dependencies?.[PACKAGE_NAME]
if (depVersion) {
const cleanVersion = depVersion.replace(/^[\^~]/, "")
return { version: cleanVersion, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
} catch {
// intentionally empty - parse errors ignored
}
}
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
}
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
} catch {
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
}
function compareVersions(current: string, latest: string): boolean {
const parseVersion = (v: string): number[] => {
const cleaned = v.replace(/^v/, "").split("-")[0]
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
}
const curr = parseVersion(current)
const lat = parseVersion(latest)
for (let i = 0; i < Math.max(curr.length, lat.length); i++) {
const c = curr[i] ?? 0
const l = lat[i] ?? 0
if (c < l) return false
if (c > l) return true
}
return true
}
export async function getVersionInfo(): Promise<VersionCheckInfo> {
const current = getCurrentVersion()
const latestVersion = await fetchLatestVersion()
const isUpToDate =
current.isLocalDev ||
current.isPinned ||
!current.version ||
!latestVersion ||
compareVersions(current.version, latestVersion)
return {
currentVersion: current.version,
latestVersion,
isUpToDate,
isLocalDev: current.isLocalDev,
isPinned: current.isPinned,
}
}
export async function checkVersionStatus(): Promise<CheckResult> {
const info = await getVersionInfo()
if (info.isLocalDev) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "pass",
message: "Running in local development mode",
details: ["Using file:// protocol from config"],
}
}
if (info.isPinned) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "pass",
message: `Pinned to version ${info.currentVersion}`,
details: ["Update check skipped for pinned versions"],
}
}
if (!info.currentVersion) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "warn",
message: "Unable to determine current version",
details: ["Run: bunx oh-my-opencode get-local-version"],
}
}
if (!info.latestVersion) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "warn",
message: `Current: ${info.currentVersion}`,
details: ["Unable to check for updates (network error)"],
}
}
if (!info.isUpToDate) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "warn",
message: `Update available: ${info.currentVersion} -> ${info.latestVersion}`,
details: ["Run: cd ~/.config/opencode && bun update oh-my-opencode"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "pass",
message: `Up to date (${info.currentVersion})`,
details: info.latestVersion ? [`Latest: ${info.latestVersion}`] : undefined,
}
}
export function getVersionCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.VERSION_STATUS,
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
category: "updates",
check: checkVersionStatus,
critical: false,
}
}

View File

@@ -0,0 +1,70 @@
import color from "picocolors"
export const SYMBOLS = {
check: color.green("\u2713"),
cross: color.red("\u2717"),
warn: color.yellow("\u26A0"),
info: color.blue("\u2139"),
arrow: color.cyan("\u2192"),
bullet: color.dim("\u2022"),
skip: color.dim("\u25CB"),
} as const
export const STATUS_COLORS = {
pass: color.green,
fail: color.red,
warn: color.yellow,
skip: color.dim,
} as const
export const CHECK_IDS = {
OPENCODE_INSTALLATION: "opencode-installation",
PLUGIN_REGISTRATION: "plugin-registration",
CONFIG_VALIDATION: "config-validation",
AUTH_ANTHROPIC: "auth-anthropic",
AUTH_OPENAI: "auth-openai",
AUTH_GOOGLE: "auth-google",
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
DEP_COMMENT_CHECKER: "dep-comment-checker",
LSP_SERVERS: "lsp-servers",
MCP_BUILTIN: "mcp-builtin",
MCP_USER: "mcp-user",
VERSION_STATUS: "version-status",
} as const
export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation",
[CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration",
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity",
[CHECK_IDS.AUTH_ANTHROPIC]: "Anthropic (Claude) Auth",
[CHECK_IDS.AUTH_OPENAI]: "OpenAI (ChatGPT) Auth",
[CHECK_IDS.AUTH_GOOGLE]: "Google (Gemini) Auth",
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
[CHECK_IDS.VERSION_STATUS]: "Version Status",
} as const
export const CATEGORY_NAMES: Record<string, string> = {
installation: "Installation",
configuration: "Configuration",
authentication: "Authentication",
dependencies: "Dependencies",
tools: "Tools & Servers",
updates: "Updates",
} as const
export const EXIT_CODES = {
SUCCESS: 0,
FAILURE: 1,
} as const
export const MIN_OPENCODE_VERSION = "1.0.150"
export const PACKAGE_NAME = "oh-my-opencode"
export const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const

View File

@@ -0,0 +1,218 @@
import { describe, it, expect } from "bun:test"
import {
formatStatusSymbol,
formatCheckResult,
formatCategoryHeader,
formatSummary,
formatHeader,
formatFooter,
formatJsonOutput,
formatBox,
formatHelpSuggestions,
} from "./formatter"
import type { CheckResult, DoctorSummary, DoctorResult } from "./types"
describe("formatter", () => {
describe("formatStatusSymbol", () => {
it("returns green check for pass", () => {
const symbol = formatStatusSymbol("pass")
expect(symbol).toContain("\u2713")
})
it("returns red cross for fail", () => {
const symbol = formatStatusSymbol("fail")
expect(symbol).toContain("\u2717")
})
it("returns yellow warning for warn", () => {
const symbol = formatStatusSymbol("warn")
expect(symbol).toContain("\u26A0")
})
it("returns dim circle for skip", () => {
const symbol = formatStatusSymbol("skip")
expect(symbol).toContain("\u25CB")
})
})
describe("formatCheckResult", () => {
it("includes name and message", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "All good",
}
const output = formatCheckResult(result, false)
expect(output).toContain("Test Check")
expect(output).toContain("All good")
})
it("includes details when verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1", "Detail 2"],
}
const output = formatCheckResult(result, true)
expect(output).toContain("Detail 1")
expect(output).toContain("Detail 2")
})
it("hides details when not verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1"],
}
const output = formatCheckResult(result, false)
expect(output).not.toContain("Detail 1")
})
})
describe("formatCategoryHeader", () => {
it("formats category name with styling", () => {
const header = formatCategoryHeader("installation")
expect(header).toContain("Installation")
})
})
describe("formatSummary", () => {
it("shows all counts", () => {
const summary: DoctorSummary = {
total: 10,
passed: 7,
failed: 1,
warnings: 2,
skipped: 0,
duration: 150,
}
const output = formatSummary(summary)
expect(output).toContain("7 passed")
expect(output).toContain("1 failed")
expect(output).toContain("2 warnings")
expect(output).toContain("10 checks")
expect(output).toContain("150ms")
})
})
describe("formatHeader", () => {
it("includes doctor branding", () => {
const header = formatHeader()
expect(header).toContain("Doctor")
})
})
describe("formatFooter", () => {
it("shows error message when failures", () => {
const summary: DoctorSummary = {
total: 5,
passed: 4,
failed: 1,
warnings: 0,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("Issues detected")
})
it("shows warning message when warnings only", () => {
const summary: DoctorSummary = {
total: 5,
passed: 4,
failed: 0,
warnings: 1,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("warnings")
})
it("shows success message when all pass", () => {
const summary: DoctorSummary = {
total: 5,
passed: 5,
failed: 0,
warnings: 0,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("operational")
})
})
describe("formatJsonOutput", () => {
it("returns valid JSON", () => {
const result: DoctorResult = {
results: [{ name: "Test", status: "pass", message: "OK" }],
summary: { total: 1, passed: 1, failed: 0, warnings: 0, skipped: 0, duration: 50 },
exitCode: 0,
}
const output = formatJsonOutput(result)
const parsed = JSON.parse(output)
expect(parsed.results.length).toBe(1)
expect(parsed.summary.total).toBe(1)
expect(parsed.exitCode).toBe(0)
})
})
describe("formatBox", () => {
it("wraps content in box", () => {
const box = formatBox("Test content")
expect(box).toContain("Test content")
expect(box).toContain("\u2500")
})
it("includes title when provided", () => {
const box = formatBox("Content", "My Title")
expect(box).toContain("My Title")
})
})
describe("formatHelpSuggestions", () => {
it("extracts suggestions from failed checks", () => {
const results: CheckResult[] = [
{ name: "Test", status: "fail", message: "Error", details: ["Run: fix-command"] },
{ name: "OK", status: "pass", message: "Good" },
]
const suggestions = formatHelpSuggestions(results)
expect(suggestions).toContain("Run: fix-command")
})
it("returns empty array when no failures", () => {
const results: CheckResult[] = [
{ name: "OK", status: "pass", message: "Good" },
]
const suggestions = formatHelpSuggestions(results)
expect(suggestions.length).toBe(0)
})
})
})

140
src/cli/doctor/formatter.ts Normal file
View File

@@ -0,0 +1,140 @@
import color from "picocolors"
import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types"
import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants"
export function formatStatusSymbol(status: CheckResult["status"]): string {
switch (status) {
case "pass":
return SYMBOLS.check
case "fail":
return SYMBOLS.cross
case "warn":
return SYMBOLS.warn
case "skip":
return SYMBOLS.skip
}
}
export function formatCheckResult(result: CheckResult, verbose: boolean): string {
const symbol = formatStatusSymbol(result.status)
const colorFn = STATUS_COLORS[result.status]
const name = colorFn(result.name)
const message = color.dim(result.message)
let line = ` ${symbol} ${name}`
if (result.message) {
line += ` ${SYMBOLS.arrow} ${message}`
}
if (verbose && result.details && result.details.length > 0) {
const detailLines = result.details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
line += "\n" + detailLines
}
return line
}
export function formatCategoryHeader(category: CheckCategory): string {
const name = CATEGORY_NAMES[category] || category
return `\n${color.bold(color.white(name))}\n${color.dim("\u2500".repeat(40))}`
}
export function formatSummary(summary: DoctorSummary): string {
const lines: string[] = []
lines.push(color.bold(color.white("Summary")))
lines.push(color.dim("\u2500".repeat(40)))
lines.push("")
const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : color.dim("0 passed")
const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : color.dim("0 failed")
const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : color.dim("0 warnings")
const skipText = summary.skipped > 0 ? color.dim(`${summary.skipped} skipped`) : ""
const parts = [passText, failText, warnText]
if (skipText) parts.push(skipText)
lines.push(` ${parts.join(", ")}`)
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
return lines.join("\n")
}
export function formatHeader(): string {
return `\n${color.bgMagenta(color.white(" oMoMoMoMo... Doctor "))}\n`
}
export function formatFooter(summary: DoctorSummary): string {
if (summary.failed > 0) {
return `\n${SYMBOLS.cross} ${color.red("Issues detected. Please review the errors above.")}\n`
}
if (summary.warnings > 0) {
return `\n${SYMBOLS.warn} ${color.yellow("All systems operational with warnings.")}\n`
}
return `\n${SYMBOLS.check} ${color.green("All systems operational!")}\n`
}
export function formatProgress(current: number, total: number, name: string): string {
const progress = color.dim(`[${current}/${total}]`)
return `${progress} Checking ${name}...`
}
export function formatJsonOutput(result: DoctorResult): string {
return JSON.stringify(result, null, 2)
}
export function formatDetails(details: string[]): string {
return details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
}
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, "")
}
export function formatBox(content: string, title?: string): string {
const lines = content.split("\n")
const maxWidth = Math.max(...lines.map((l) => stripAnsi(l).length), title?.length ?? 0) + 4
const border = color.dim("\u2500".repeat(maxWidth))
const output: string[] = []
output.push("")
if (title) {
output.push(
color.dim("\u250C\u2500") +
color.bold(` ${title} `) +
color.dim("\u2500".repeat(maxWidth - title.length - 4)) +
color.dim("\u2510")
)
} else {
output.push(color.dim("\u250C") + border + color.dim("\u2510"))
}
for (const line of lines) {
const stripped = stripAnsi(line)
const padding = maxWidth - stripped.length
output.push(color.dim("\u2502") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("\u2502"))
}
output.push(color.dim("\u2514") + border + color.dim("\u2518"))
output.push("")
return output.join("\n")
}
export function formatHelpSuggestions(results: CheckResult[]): string[] {
const suggestions: string[] = []
for (const result of results) {
if (result.status === "fail" && result.details) {
for (const detail of result.details) {
if (detail.includes("Run:") || detail.includes("Install:") || detail.includes("Visit:")) {
suggestions.push(detail)
}
}
}
}
return suggestions
}

11
src/cli/doctor/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { DoctorOptions } from "./types"
import { runDoctor } from "./runner"
export async function doctor(options: DoctorOptions = {}): Promise<number> {
const result = await runDoctor(options)
return result.exitCode
}
export * from "./types"
export { runDoctor } from "./runner"
export { formatJsonOutput } from "./formatter"

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import {
runCheck,
calculateSummary,
determineExitCode,
filterChecksByCategory,
groupChecksByCategory,
} from "./runner"
import type { CheckResult, CheckDefinition, CheckCategory } from "./types"
describe("runner", () => {
describe("runCheck", () => {
it("returns result from check function", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => ({ name: "Test Check", status: "pass", message: "OK" }),
}
const result = await runCheck(check)
expect(result.name).toBe("Test Check")
expect(result.status).toBe("pass")
})
it("measures duration", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => {
await new Promise((r) => setTimeout(r, 10))
return { name: "Test", status: "pass", message: "OK" }
},
}
const result = await runCheck(check)
expect(result.duration).toBeGreaterThanOrEqual(10)
})
it("returns fail on error", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => {
throw new Error("Test error")
},
}
const result = await runCheck(check)
expect(result.status).toBe("fail")
expect(result.message).toContain("Test error")
})
})
describe("calculateSummary", () => {
it("counts each status correctly", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "pass", message: "" },
{ name: "3", status: "fail", message: "" },
{ name: "4", status: "warn", message: "" },
{ name: "5", status: "skip", message: "" },
]
const summary = calculateSummary(results, 100)
expect(summary.total).toBe(5)
expect(summary.passed).toBe(2)
expect(summary.failed).toBe(1)
expect(summary.warnings).toBe(1)
expect(summary.skipped).toBe(1)
expect(summary.duration).toBe(100)
})
})
describe("determineExitCode", () => {
it("returns 0 when all pass", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "pass", message: "" },
]
expect(determineExitCode(results)).toBe(0)
})
it("returns 0 when only warnings", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "warn", message: "" },
]
expect(determineExitCode(results)).toBe(0)
})
it("returns 1 when any failures", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "fail", message: "" },
]
expect(determineExitCode(results)).toBe(1)
})
})
describe("filterChecksByCategory", () => {
const checks: CheckDefinition[] = [
{ id: "1", name: "Install", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "2", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "3", name: "Auth", category: "authentication", check: async () => ({ name: "", status: "pass", message: "" }) },
]
it("returns all checks when no category", () => {
const filtered = filterChecksByCategory(checks)
expect(filtered.length).toBe(3)
})
it("filters to specific category", () => {
const filtered = filterChecksByCategory(checks, "installation")
expect(filtered.length).toBe(1)
expect(filtered[0].name).toBe("Install")
})
})
describe("groupChecksByCategory", () => {
const checks: CheckDefinition[] = [
{ id: "1", name: "Install1", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "2", name: "Install2", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "3", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
]
it("groups checks by category", () => {
const groups = groupChecksByCategory(checks)
expect(groups.get("installation")?.length).toBe(2)
expect(groups.get("configuration")?.length).toBe(1)
})
it("maintains order within categories", () => {
const groups = groupChecksByCategory(checks)
const installChecks = groups.get("installation")!
expect(installChecks[0].name).toBe("Install1")
expect(installChecks[1].name).toBe("Install2")
})
})
})

132
src/cli/doctor/runner.ts Normal file
View File

@@ -0,0 +1,132 @@
import type {
DoctorOptions,
DoctorResult,
CheckDefinition,
CheckResult,
DoctorSummary,
CheckCategory,
} from "./types"
import { getAllCheckDefinitions } from "./checks"
import { EXIT_CODES, CATEGORY_NAMES } from "./constants"
import {
formatHeader,
formatCategoryHeader,
formatCheckResult,
formatSummary,
formatFooter,
formatJsonOutput,
} from "./formatter"
export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
const start = performance.now()
try {
const result = await check.check()
result.duration = Math.round(performance.now() - start)
return result
} catch (err) {
return {
name: check.name,
status: "fail",
message: err instanceof Error ? err.message : "Unknown error",
duration: Math.round(performance.now() - start),
}
}
}
export function calculateSummary(results: CheckResult[], duration: number): DoctorSummary {
return {
total: results.length,
passed: results.filter((r) => r.status === "pass").length,
failed: results.filter((r) => r.status === "fail").length,
warnings: results.filter((r) => r.status === "warn").length,
skipped: results.filter((r) => r.status === "skip").length,
duration: Math.round(duration),
}
}
export function determineExitCode(results: CheckResult[]): number {
const hasFailures = results.some((r) => r.status === "fail")
return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
}
export function filterChecksByCategory(
checks: CheckDefinition[],
category?: CheckCategory
): CheckDefinition[] {
if (!category) return checks
return checks.filter((c) => c.category === category)
}
export function groupChecksByCategory(
checks: CheckDefinition[]
): Map<CheckCategory, CheckDefinition[]> {
const groups = new Map<CheckCategory, CheckDefinition[]>()
for (const check of checks) {
const existing = groups.get(check.category) ?? []
existing.push(check)
groups.set(check.category, existing)
}
return groups
}
const CATEGORY_ORDER: CheckCategory[] = [
"installation",
"configuration",
"authentication",
"dependencies",
"tools",
"updates",
]
export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const start = performance.now()
const allChecks = getAllCheckDefinitions()
const filteredChecks = filterChecksByCategory(allChecks, options.category)
const groupedChecks = groupChecksByCategory(filteredChecks)
const results: CheckResult[] = []
if (!options.json) {
console.log(formatHeader())
}
for (const category of CATEGORY_ORDER) {
const checks = groupedChecks.get(category)
if (!checks || checks.length === 0) continue
if (!options.json) {
console.log(formatCategoryHeader(category))
}
for (const check of checks) {
const result = await runCheck(check)
results.push(result)
if (!options.json) {
console.log(formatCheckResult(result, options.verbose ?? false))
}
}
}
const duration = performance.now() - start
const summary = calculateSummary(results, duration)
const exitCode = determineExitCode(results)
const doctorResult: DoctorResult = {
results,
summary,
exitCode,
}
if (options.json) {
console.log(formatJsonOutput(doctorResult))
} else {
console.log("")
console.log(formatSummary(summary))
console.log(formatFooter(summary))
}
return doctorResult
}

113
src/cli/doctor/types.ts Normal file
View File

@@ -0,0 +1,113 @@
export type CheckStatus = "pass" | "fail" | "warn" | "skip"
export interface CheckResult {
name: string
status: CheckStatus
message: string
details?: string[]
duration?: number
}
export type CheckFunction = () => Promise<CheckResult>
export type CheckCategory =
| "installation"
| "configuration"
| "authentication"
| "dependencies"
| "tools"
| "updates"
export interface CheckDefinition {
id: string
name: string
category: CheckCategory
check: CheckFunction
critical?: boolean
}
export interface DoctorOptions {
verbose?: boolean
json?: boolean
category?: CheckCategory
}
export interface DoctorSummary {
total: number
passed: number
failed: number
warnings: number
skipped: number
duration: number
}
export interface DoctorResult {
results: CheckResult[]
summary: DoctorSummary
exitCode: number
}
export interface OpenCodeInfo {
installed: boolean
version: string | null
path: string | null
binary: "opencode" | "opencode-desktop" | null
}
export interface PluginInfo {
registered: boolean
configPath: string | null
entry: string | null
isPinned: boolean
pinnedVersion: string | null
}
export interface ConfigInfo {
exists: boolean
path: string | null
format: "json" | "jsonc" | null
valid: boolean
errors: string[]
}
export type AuthProviderId = "anthropic" | "openai" | "google"
export interface AuthProviderInfo {
id: AuthProviderId
name: string
pluginInstalled: boolean
configured: boolean
error?: string
}
export interface DependencyInfo {
name: string
required: boolean
installed: boolean
version: string | null
path: string | null
installHint?: string
}
export interface LspServerInfo {
id: string
installed: boolean
extensions: string[]
source: "builtin" | "config" | "plugin"
}
export interface McpServerInfo {
id: string
type: "builtin" | "user"
enabled: boolean
valid: boolean
error?: string
}
export interface VersionCheckInfo {
currentVersion: string | null
latestVersion: string | null
isUpToDate: boolean
isLocalDev: boolean
isPinned: boolean
}

View File

@@ -0,0 +1,66 @@
import color from "picocolors"
import type { VersionInfo } from "./types"
const SYMBOLS = {
check: color.green("✓"),
cross: color.red("✗"),
arrow: color.cyan("→"),
info: color.blue(""),
warn: color.yellow("⚠"),
pin: color.magenta("📌"),
dev: color.cyan("🔧"),
}
export function formatVersionOutput(info: VersionInfo): string {
const lines: string[] = []
lines.push("")
lines.push(color.bold(color.white("oh-my-opencode Version Information")))
lines.push(color.dim("─".repeat(50)))
lines.push("")
if (info.currentVersion) {
lines.push(` Current Version: ${color.cyan(info.currentVersion)}`)
} else {
lines.push(` Current Version: ${color.dim("unknown")}`)
}
if (!info.isLocalDev && info.latestVersion) {
lines.push(` Latest Version: ${color.cyan(info.latestVersion)}`)
}
lines.push("")
switch (info.status) {
case "up-to-date":
lines.push(` ${SYMBOLS.check} ${color.green("You're up to date!")}`)
break
case "outdated":
lines.push(` ${SYMBOLS.warn} ${color.yellow("Update available")}`)
lines.push(` ${color.dim("Run:")} ${color.cyan("cd ~/.config/opencode && bun update oh-my-opencode")}`)
break
case "local-dev":
lines.push(` ${SYMBOLS.dev} ${color.cyan("Running in local development mode")}`)
lines.push(` ${color.dim("Using file:// protocol from config")}`)
break
case "pinned":
lines.push(` ${SYMBOLS.pin} ${color.magenta(`Version pinned to ${info.pinnedVersion}`)}`)
lines.push(` ${color.dim("Update check skipped for pinned versions")}`)
break
case "error":
lines.push(` ${SYMBOLS.cross} ${color.red("Unable to check for updates")}`)
lines.push(` ${color.dim("Network error or npm registry unavailable")}`)
break
case "unknown":
lines.push(` ${SYMBOLS.info} ${color.yellow("Version information unavailable")}`)
break
}
lines.push("")
return lines.join("\n")
}
export function formatJsonOutput(info: VersionInfo): string {
return JSON.stringify(info, null, 2)
}

View File

@@ -0,0 +1,104 @@
import { getCachedVersion, getLatestVersion, isLocalDevMode, findPluginEntry } from "../../hooks/auto-update-checker/checker"
import type { GetLocalVersionOptions, VersionInfo } from "./types"
import { formatVersionOutput, formatJsonOutput } from "./formatter"
export async function getLocalVersion(options: GetLocalVersionOptions = {}): Promise<number> {
const directory = options.directory ?? process.cwd()
try {
if (isLocalDevMode(directory)) {
const currentVersion = getCachedVersion()
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: true,
isPinned: false,
pinnedVersion: null,
status: "local-dev",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const pluginInfo = findPluginEntry(directory)
if (pluginInfo?.isPinned) {
const info: VersionInfo = {
currentVersion: pluginInfo.pinnedVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: true,
pinnedVersion: pluginInfo.pinnedVersion,
status: "pinned",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const currentVersion = getCachedVersion()
if (!currentVersion) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "unknown",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
const latestVersion = await getLatestVersion()
if (!latestVersion) {
const info: VersionInfo = {
currentVersion,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
}
const isUpToDate = currentVersion === latestVersion
const info: VersionInfo = {
currentVersion,
latestVersion,
isUpToDate,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: isUpToDate ? "up-to-date" : "outdated",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 0
} catch (error) {
const info: VersionInfo = {
currentVersion: null,
latestVersion: null,
isUpToDate: false,
isLocalDev: false,
isPinned: false,
pinnedVersion: null,
status: "error",
}
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
return 1
}
}
export * from "./types"

View File

@@ -0,0 +1,14 @@
export interface VersionInfo {
currentVersion: string | null
latestVersion: string | null
isUpToDate: boolean
isLocalDev: boolean
isPinned: boolean
pinnedVersion: string | null
status: "up-to-date" | "outdated" | "local-dev" | "pinned" | "error" | "unknown"
}
export interface GetLocalVersionOptions {
directory?: string
json?: boolean
}

144
src/cli/index.ts Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bun
import { Command } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
import type { DoctorOptions } from "./doctor"
const packageJson = await import("../../package.json")
const VERSION = packageJson.version
const program = new Command()
program
.name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
.version(VERSION, "-v, --version", "Show version number")
program
.command("install")
.description("Install and configure oh-my-opencode with interactive setup")
.option("--no-tui", "Run in non-interactive mode (requires all options)")
.option("--claude <value>", "Claude subscription: no, yes, max20")
.option("--chatgpt <value>", "ChatGPT subscription: no, yes")
.option("--gemini <value>", "Gemini integration: no, yes")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode install
$ bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes
$ bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no
Model Providers:
Claude Required for Sisyphus (main orchestrator) and Librarian agents
ChatGPT Powers the Oracle agent for debugging and architecture
Gemini Powers frontend, documentation, and multimodal agents
`)
.action(async (options) => {
const args: InstallArgs = {
tui: options.tui !== false,
claude: options.claude,
chatgpt: options.chatgpt,
gemini: options.gemini,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
process.exit(exitCode)
})
program
.command("run <message>")
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: Sisyphus)")
.option("-d, --directory <path>", "Working directory")
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode run "Fix the bug in index.ts"
$ bunx oh-my-opencode run --agent Sisyphus "Implement feature X"
$ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task"
Unlike 'opencode run', this command waits until:
- All todos are completed or cancelled
- All child sessions (background tasks) are idle
`)
.action(async (message: string, options) => {
const runOptions: RunOptions = {
message,
agent: options.agent,
directory: options.directory,
timeout: options.timeout,
}
const exitCode = await run(runOptions)
process.exit(exitCode)
})
program
.command("get-local-version")
.description("Show current installed version and check for updates")
.option("-d, --directory <path>", "Working directory to check config from")
.option("--json", "Output in JSON format for scripting")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode get-local-version
$ bunx oh-my-opencode get-local-version --json
$ bunx oh-my-opencode get-local-version --directory /path/to/project
This command shows:
- Current installed version
- Latest available version on npm
- Whether you're up to date
- Special modes (local dev, pinned version)
`)
.action(async (options) => {
const versionOptions: GetLocalVersionOptions = {
directory: options.directory,
json: options.json ?? false,
}
const exitCode = await getLocalVersion(versionOptions)
process.exit(exitCode)
})
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode doctor
$ bunx oh-my-opencode doctor --verbose
$ bunx oh-my-opencode doctor --json
$ bunx oh-my-opencode doctor --category authentication
Categories:
installation Check OpenCode and plugin installation
configuration Validate configuration files
authentication Check auth provider status
dependencies Check external dependencies
tools Check LSP and MCP servers
updates Check for version updates
`)
.action(async (options) => {
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")
.action(() => {
console.log(`oh-my-opencode v${VERSION}`)
})
program.parse()

456
src/cli/install.ts Normal file
View File

@@ -0,0 +1,456 @@
import * as p from "@clack/prompts"
import color from "picocolors"
import type { InstallArgs, InstallConfig, ClaudeSubscription, BooleanArg, DetectedConfig } from "./types"
import {
addPluginToOpenCodeConfig,
writeOmoConfig,
isOpenCodeInstalled,
getOpenCodeVersion,
addAuthPlugins,
setupChatGPTHotfix,
runBunInstall,
addProviderConfig,
detectCurrentConfig,
} from "./config-manager"
const SYMBOLS = {
check: color.green("✓"),
cross: color.red("✗"),
arrow: color.cyan("→"),
bullet: color.dim("•"),
info: color.blue(""),
warn: color.yellow("⚠"),
star: color.yellow("★"),
}
function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○")
const label = enabled ? color.white(name) : color.dim(name)
const suffix = detail ? color.dim(` (${detail})`) : ""
return ` ${status} ${label}${suffix}`
}
function formatConfigSummary(config: InstallConfig): string {
const lines: string[] = []
lines.push(color.bold(color.white("Configuration Summary")))
lines.push("")
const claudeDetail = config.hasClaude ? (config.isMax20 ? "max20" : "standard") : undefined
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
lines.push(formatProvider("ChatGPT", config.hasChatGPT))
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push("")
lines.push(color.dim("─".repeat(40)))
lines.push("")
lines.push(color.bold(color.white("Agent Configuration")))
lines.push("")
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "big-pickle"
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
const librarianModel = config.hasClaude && config.isMax20 ? "claude-sonnet-4-5" : "big-pickle"
const frontendModel = config.hasGemini ? "gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
lines.push(` ${SYMBOLS.bullet} Sisyphus ${SYMBOLS.arrow} ${color.cyan(sisyphusModel)}`)
lines.push(` ${SYMBOLS.bullet} Oracle ${SYMBOLS.arrow} ${color.cyan(oracleModel)}`)
lines.push(` ${SYMBOLS.bullet} Librarian ${SYMBOLS.arrow} ${color.cyan(librarianModel)}`)
lines.push(` ${SYMBOLS.bullet} Frontend ${SYMBOLS.arrow} ${color.cyan(frontendModel)}`)
return lines.join("\n")
}
function printHeader(isUpdate: boolean): void {
const mode = isUpdate ? "Update" : "Install"
console.log()
console.log(color.bgMagenta(color.white(` oMoMoMoMo... ${mode} `)))
console.log()
}
function printStep(step: number, total: number, message: string): void {
const progress = color.dim(`[${step}/${total}]`)
console.log(`${progress} ${message}`)
}
function printSuccess(message: string): void {
console.log(`${SYMBOLS.check} ${message}`)
}
function printError(message: string): void {
console.log(`${SYMBOLS.cross} ${color.red(message)}`)
}
function printInfo(message: string): void {
console.log(`${SYMBOLS.info} ${message}`)
}
function printWarning(message: string): void {
console.log(`${SYMBOLS.warn} ${color.yellow(message)}`)
}
function printBox(content: string, title?: string): void {
const lines = content.split("\n")
const maxWidth = Math.max(...lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, "").length), title?.length ?? 0) + 4
const border = color.dim("─".repeat(maxWidth))
console.log()
if (title) {
console.log(color.dim("┌─") + color.bold(` ${title} `) + color.dim("─".repeat(maxWidth - title.length - 4)) + color.dim("┐"))
} else {
console.log(color.dim("┌") + border + color.dim("┐"))
}
for (const line of lines) {
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "")
const padding = maxWidth - stripped.length
console.log(color.dim("│") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("│"))
}
console.log(color.dim("└") + border + color.dim("┘"))
console.log()
}
function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)")
} else if (!["no", "yes", "max20"].includes(args.claude)) {
errors.push(`Invalid --claude value: ${args.claude} (expected: no, yes, max20)`)
}
if (args.chatgpt === undefined) {
errors.push("--chatgpt is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.chatgpt)) {
errors.push(`Invalid --chatgpt value: ${args.chatgpt} (expected: no, yes)`)
}
if (args.gemini === undefined) {
errors.push("--gemini is required (values: no, yes)")
} else if (!["no", "yes"].includes(args.gemini)) {
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
}
return { valid: errors.length === 0, errors }
}
function argsToConfig(args: InstallArgs): InstallConfig {
return {
hasClaude: args.claude !== "no",
isMax20: args.claude === "max20",
hasChatGPT: args.chatgpt === "yes",
hasGemini: args.gemini === "yes",
}
}
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; chatgpt: BooleanArg; gemini: BooleanArg } {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
claude = detected.isMax20 ? "max20" : "yes"
}
return {
claude,
chatgpt: detected.hasChatGPT ? "yes" : "no",
gemini: detected.hasGemini ? "yes" : "no",
}
}
async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | null> {
const initial = detectedToInitialValues(detected)
const claude = await p.select({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" },
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
initialValue: initial.claude,
})
if (p.isCancel(claude)) {
p.cancel("Installation cancelled.")
return null
}
const chatgpt = await p.select({
message: "Do you have a ChatGPT Plus/Pro subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Oracle will use fallback model" },
{ value: "yes" as const, label: "Yes", hint: "GPT-5.2 for debugging and architecture" },
],
initialValue: initial.chatgpt,
})
if (p.isCancel(chatgpt)) {
p.cancel("Installation cancelled.")
return null
}
const gemini = await p.select({
message: "Will you integrate Google Gemini?",
options: [
{ value: "no" as const, label: "No", hint: "Frontend/docs agents will use fallback" },
{ value: "yes" as const, label: "Yes", hint: "Beautiful UI generation with Gemini 3 Pro" },
],
initialValue: initial.gemini,
})
if (p.isCancel(gemini)) {
p.cancel("Installation cancelled.")
return null
}
return {
hasClaude: claude !== "no",
isMax20: claude === "max20",
hasChatGPT: chatgpt === "yes",
hasGemini: gemini === "yes",
}
}
async function runNonTuiInstall(args: InstallArgs): Promise<number> {
const validation = validateNonTuiArgs(args)
if (!validation.valid) {
printHeader(false)
printError("Validation failed:")
for (const err of validation.errors) {
console.log(` ${SYMBOLS.bullet} ${err}`)
}
console.log()
printInfo("Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --chatgpt=<no|yes> --gemini=<no|yes>")
console.log()
return 1
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
printHeader(isUpdate)
const totalSteps = 6
let step = 1
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
if (!installed) {
printError("OpenCode is not installed on this system.")
printInfo("Visit https://opencode.ai/docs for installation instructions")
return 1
}
const version = await getOpenCodeVersion()
printSuccess(`OpenCode ${version ?? ""} detected`)
if (isUpdate) {
const initial = detectedToInitialValues(detected)
printInfo(`Current config: Claude=${initial.claude}, ChatGPT=${initial.chatgpt}, Gemini=${initial.gemini}`)
}
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
const pluginResult = addPluginToOpenCodeConfig()
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
}
printSuccess(`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`)
if (config.hasGemini || config.hasChatGPT) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
printError(`Failed: ${authResult.error}`)
return 1
}
printSuccess(`Auth plugins configured ${SYMBOLS.arrow} ${color.dim(authResult.configPath)}`)
printStep(step++, totalSteps, "Adding provider configurations...")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
printError(`Failed: ${providerResult.error}`)
return 1
}
printSuccess(`Providers configured ${SYMBOLS.arrow} ${color.dim(providerResult.configPath)}`)
} else {
step += 2
}
if (config.hasChatGPT) {
printStep(step++, totalSteps, "Setting up ChatGPT hotfix...")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
printError(`Failed: ${hotfixResult.error}`)
return 1
}
printSuccess(`Hotfix configured ${SYMBOLS.arrow} ${color.dim(hotfixResult.configPath)}`)
printInfo("Installing dependencies with bun...")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
printSuccess("Dependencies installed")
} else {
printWarning("bun install failed - run manually: cd ~/.config/opencode && bun i")
}
} else {
step++
}
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
printError(`Failed: ${omoResult.error}`)
return 1
}
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
}
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
console.log(color.bold("Next Steps - Authenticate your providers:"))
console.log()
if (config.hasClaude) {
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`)
}
if (config.hasChatGPT) {
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`)
}
if (config.hasGemini) {
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`)
}
console.log()
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
return 0
}
export async function install(args: InstallArgs): Promise<number> {
if (!args.tui) {
return runNonTuiInstall(args)
}
const detected = detectCurrentConfig()
const isUpdate = detected.isInstalled
p.intro(color.bgMagenta(color.white(isUpdate ? " oMoMoMoMo... Update " : " oMoMoMoMo... ")))
if (isUpdate) {
const initial = detectedToInitialValues(detected)
p.log.info(`Existing configuration detected: Claude=${initial.claude}, ChatGPT=${initial.chatgpt}, Gemini=${initial.gemini}`)
}
const s = p.spinner()
s.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
if (!installed) {
s.stop("OpenCode is not installed")
p.log.error("OpenCode is not installed on this system.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
p.outro(color.red("Please install OpenCode first."))
return 1
}
const version = await getOpenCodeVersion()
s.stop(`OpenCode ${version ?? "installed"} ${color.green("✓")}`)
const config = await runTuiMode(detected)
if (!config) return 1
s.start("Adding oh-my-opencode to OpenCode config")
const pluginResult = addPluginToOpenCodeConfig()
if (!pluginResult.success) {
s.stop(`Failed to add plugin: ${pluginResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
if (config.hasGemini || config.hasChatGPT) {
s.start("Adding auth plugins (fetching latest versions)")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {
s.stop(`Failed to add auth plugins: ${authResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Auth plugins added to ${color.cyan(authResult.configPath)}`)
s.start("Adding provider configurations")
const providerResult = addProviderConfig(config)
if (!providerResult.success) {
s.stop(`Failed to add provider config: ${providerResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`)
}
if (config.hasChatGPT) {
s.start("Setting up ChatGPT hotfix")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
s.stop(`Failed to setup hotfix: ${hotfixResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Hotfix configured in ${color.cyan(hotfixResult.configPath)}`)
s.start("Installing dependencies with bun")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
s.stop("Dependencies installed")
} else {
s.stop(color.yellow("bun install failed - run manually: cd ~/.config/opencode && bun i"))
}
}
s.start("Writing oh-my-opencode configuration")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
s.stop(`Failed to write config: ${omoResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
const steps: string[] = []
if (config.hasClaude) {
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`)
}
if (config.hasChatGPT) {
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`)
}
if (config.hasGemini) {
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`)
}
p.note(steps.join("\n"), "Next Steps - Authenticate your providers")
}
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`)
p.outro(color.green("oMoMoMoMo... Enjoy!"))
return 0
}

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
const createMockContext = (overrides: {
todo?: Todo[]
childrenBySession?: Record<string, ChildSession[]>
statuses?: Record<string, SessionStatus>
} = {}): RunContext => {
const {
todo = [],
childrenBySession = { "test-session": [] },
statuses = {},
} = overrides
return {
client: {
session: {
todo: mock(() => Promise.resolve({ data: todo })),
children: mock((opts: { path: { id: string } }) =>
Promise.resolve({ data: childrenBySession[opts.path.id] ?? [] })
),
status: mock(() => Promise.resolve({ data: statuses })),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
directory: "/test",
abortController: new AbortController(),
}
}
describe("checkCompletionConditions", () => {
it("returns true when no todos and no children", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext()
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when incomplete todos exist", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
todo: [
{ id: "1", content: "Done", status: "completed", priority: "high" },
{ id: "2", content: "WIP", status: "in_progress", priority: "high" },
],
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all todos completed or cancelled", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
todo: [
{ id: "1", content: "Done", status: "completed", priority: "high" },
{ id: "2", content: "Skip", status: "cancelled", priority: "medium" },
],
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when child session is busy", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [],
},
statuses: { "child-1": { type: "busy" } },
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all children idle", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }, { id: "child-2" }],
"child-1": [],
"child-2": [],
},
statuses: {
"child-1": { type: "idle" },
"child-2": { type: "idle" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
it("returns false when grandchild is busy (recursive)", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [{ id: "grandchild-1" }],
"grandchild-1": [],
},
statuses: {
"child-1": { type: "idle" },
"grandchild-1": { type: "busy" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(false)
})
it("returns true when all descendants idle (recursive)", async () => {
// #given
spyOn(console, "log").mockImplementation(() => {})
const ctx = createMockContext({
childrenBySession: {
"test-session": [{ id: "child-1" }],
"child-1": [{ id: "grandchild-1" }],
"grandchild-1": [{ id: "great-grandchild-1" }],
"great-grandchild-1": [],
},
statuses: {
"child-1": { type: "idle" },
"grandchild-1": { type: "idle" },
"great-grandchild-1": { type: "idle" },
},
})
const { checkCompletionConditions } = await import("./completion")
// #when
const result = await checkCompletionConditions(ctx)
// #then
expect(result).toBe(true)
})
})

79
src/cli/run/completion.ts Normal file
View File

@@ -0,0 +1,79 @@
import pc from "picocolors"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> {
try {
if (!await areAllTodosComplete(ctx)) {
return false
}
if (!await areAllChildrenIdle(ctx)) {
return false
}
return true
} catch (err) {
console.error(pc.red(`[completion] API error: ${err}`))
return false
}
}
async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } })
const todos = (todosRes.data ?? []) as Todo[]
const incompleteTodos = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (incompleteTodos.length > 0) {
console.log(pc.dim(` Waiting: ${incompleteTodos.length} todos remaining`))
return false
}
return true
}
async function areAllChildrenIdle(ctx: RunContext): Promise<boolean> {
const allStatuses = await fetchAllStatuses(ctx)
return areAllDescendantsIdle(ctx, ctx.sessionID, allStatuses)
}
async function fetchAllStatuses(
ctx: RunContext
): Promise<Record<string, SessionStatus>> {
const statusRes = await ctx.client.session.status()
return (statusRes.data ?? {}) as Record<string, SessionStatus>
}
async function areAllDescendantsIdle(
ctx: RunContext,
sessionID: string,
allStatuses: Record<string, SessionStatus>
): Promise<boolean> {
const childrenRes = await ctx.client.session.children({
path: { id: sessionID },
})
const children = (childrenRes.data ?? []) as ChildSession[]
for (const child of children) {
const status = allStatuses[child.id]
if (status && status.type !== "idle") {
console.log(
pc.dim(` Waiting: session ${child.id.slice(0, 8)}... is ${status.type}`)
)
return false
}
const descendantsIdle = await areAllDescendantsIdle(
ctx,
child.id,
allStatuses
)
if (!descendantsIdle) {
return false
}
}
return true
}

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from "bun:test"
import { createEventState, type EventState } from "./events"
import type { RunContext, EventPayload } from "./types"
const createMockContext = (sessionID: string = "test-session"): RunContext => ({
client: {} as RunContext["client"],
sessionID,
directory: "/test",
abortController: new AbortController(),
})
async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) {
yield item
}
}
describe("createEventState", () => {
it("creates initial state with correct defaults", () => {
// #given / #when
const state = createEventState()
// #then
expect(state.mainSessionIdle).toBe(false)
expect(state.lastOutput).toBe("")
expect(state.lastPartText).toBe("")
expect(state.currentTool).toBe(null)
})
})
describe("event handling", () => {
it("session.idle sets mainSessionIdle to true for matching session", async () => {
// #given
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "session.idle",
properties: { sessionID: "my-session" },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.mainSessionIdle).toBe(true)
})
it("session.idle does not affect state for different session", async () => {
// #given
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "session.idle",
properties: { sessionID: "other-session" },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.mainSessionIdle).toBe(false)
})
it("session.status with busy type sets mainSessionIdle to false", async () => {
// #given
const ctx = createMockContext("my-session")
const state: EventState = {
mainSessionIdle: true,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
}
const payload: EventPayload = {
type: "session.status",
properties: { sessionID: "my-session", status: { type: "busy" } },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.mainSessionIdle).toBe(false)
})
})

275
src/cli/run/events.ts Normal file
View File

@@ -0,0 +1,275 @@
import pc from "picocolors"
import type {
RunContext,
EventPayload,
SessionIdleProps,
SessionStatusProps,
SessionErrorProps,
MessageUpdatedProps,
MessagePartUpdatedProps,
ToolExecuteProps,
ToolResultProps,
} from "./types"
export interface EventState {
mainSessionIdle: boolean
mainSessionError: boolean
lastError: string | null
lastOutput: string
lastPartText: string
currentTool: string | null
}
export function createEventState(): EventState {
return {
mainSessionIdle: false,
mainSessionError: false,
lastError: null,
lastOutput: "",
lastPartText: "",
currentTool: null,
}
}
export async function processEvents(
ctx: RunContext,
stream: AsyncIterable<unknown>,
state: EventState
): Promise<void> {
for await (const event of stream) {
if (ctx.abortController.signal.aborted) break
try {
const payload = event as EventPayload
if (!payload?.type) {
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
continue
}
logEventVerbose(ctx, payload)
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
handleSessionStatus(ctx, payload, state)
handleMessagePartUpdated(ctx, payload, state)
handleMessageUpdated(ctx, payload, state)
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)
} catch (err) {
console.error(pc.red(`[event error] ${err}`))
}
}
}
function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const props = payload.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = props?.sessionID ?? info?.sessionID
const isMainSession = sessionID === ctx.sessionID
const sessionTag = isMainSession
? pc.green("[MAIN]")
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
switch (payload.type) {
case "session.idle":
case "session.status": {
const status = (props?.status as { type?: string })?.type ?? "idle"
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
break
}
case "message.part.updated": {
// Skip verbose logging for partial message updates
// Only log tool invocation state changes, not text streaming
const partProps = props as MessagePartUpdatedProps | undefined
const part = partProps?.part
if (part?.type === "tool-invocation") {
const toolPart = part as { toolName?: string; state?: string }
console.error(
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
)
}
break
}
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const content = msgProps?.content ?? ""
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
)
break
}
case "tool.execute": {
const toolProps = props as ToolExecuteProps | undefined
const toolName = toolProps?.name ?? "unknown"
const input = toolProps?.input ?? {}
const inputStr = JSON.stringify(input).slice(0, 150)
console.error(
pc.cyan(`${sessionTag} ⚡ TOOL.EXECUTE: ${pc.bold(toolName)}`)
)
console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`))
break
}
case "tool.result": {
const resultProps = props as ToolResultProps | undefined
const output = resultProps?.output ?? ""
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
console.error(
pc.green(`${sessionTag} ✓ TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
)
break
}
default:
console.error(pc.dim(`${sessionTag} ${payload.type}`))
}
}
function handleSessionIdle(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.idle") return
const props = payload.properties as SessionIdleProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionIdle = true
}
}
function handleSessionStatus(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.status") return
const props = payload.properties as SessionStatusProps | undefined
if (props?.sessionID === ctx.sessionID && props?.status?.type === "busy") {
state.mainSessionIdle = false
}
}
function handleSessionError(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "session.error") return
const props = payload.properties as SessionErrorProps | undefined
if (props?.sessionID === ctx.sessionID) {
state.mainSessionError = true
state.lastError = props?.error
? String(props.error instanceof Error ? props.error.message : props.error)
: "Unknown error"
console.error(pc.red(`\n[session.error] ${state.lastError}`))
}
}
function handleMessagePartUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.part.updated") return
const props = payload.properties as MessagePartUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const part = props.part
if (!part) return
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
}
state.lastPartText = part.text
}
}
function handleMessageUpdated(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "message.updated") return
const props = payload.properties as MessageUpdatedProps | undefined
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const content = props.content
if (!content || content === state.lastOutput) return
if (state.lastPartText.length === 0) {
const newContent = content.slice(state.lastOutput.length)
if (newContent) {
process.stdout.write(newContent)
}
}
state.lastOutput = content
}
function handleToolExecute(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.execute") return
const props = payload.properties as ToolExecuteProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const toolName = props?.name || "unknown"
state.currentTool = toolName
let inputPreview = ""
if (props?.input) {
const input = props.input
if (input.command) {
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
} else if (input.pattern) {
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
} else if (input.filePath) {
inputPreview = ` ${pc.dim(String(input.filePath))}`
} else if (input.query) {
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
}
}
process.stdout.write(`\n${pc.cyan("⚡")} ${pc.bold(toolName)}${inputPreview}\n`)
}
function handleToolResult(
ctx: RunContext,
payload: EventPayload,
state: EventState
): void {
if (payload.type !== "tool.result") return
const props = payload.properties as ToolResultProps | undefined
if (props?.sessionID !== ctx.sessionID) return
const output = props?.output || ""
const maxLen = 200
const preview = output.length > maxLen
? output.slice(0, maxLen) + "..."
: output
if (preview.trim()) {
const lines = preview.split("\n").slice(0, 3)
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
}
state.currentTool = null
state.lastPartText = ""
}

2
src/cli/run/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { run } from "./runner"
export type { RunOptions, RunContext } from "./types"

121
src/cli/run/runner.ts Normal file
View File

@@ -0,0 +1,121 @@
import { createOpencode } from "@opencode-ai/sdk"
import pc from "picocolors"
import type { RunOptions, RunContext } from "./types"
import { checkCompletionConditions } from "./completion"
import { createEventState, processEvents } from "./events"
const POLL_INTERVAL_MS = 500
const DEFAULT_TIMEOUT_MS = 0
export async function run(options: RunOptions): Promise<number> {
const {
message,
agent,
directory = process.cwd(),
timeout = DEFAULT_TIMEOUT_MS,
} = options
console.log(pc.cyan("Starting opencode server..."))
const abortController = new AbortController()
let timeoutId: ReturnType<typeof setTimeout> | null = null
// timeout=0 means no timeout (run until completion)
if (timeout > 0) {
timeoutId = setTimeout(() => {
console.log(pc.yellow("\nTimeout reached. Aborting..."))
abortController.abort()
}, timeout)
}
try {
const { client, server } = await createOpencode({
signal: abortController.signal,
})
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
server.close()
}
process.on("SIGINT", () => {
console.log(pc.yellow("\nInterrupted. Shutting down..."))
cleanup()
process.exit(130)
})
try {
const sessionRes = await client.session.create({
body: { title: "oh-my-opencode run" },
})
const sessionID = sessionRes.data?.id
if (!sessionID) {
console.error(pc.red("Failed to create session"))
return 1
}
console.log(pc.dim(`Session: ${sessionID}`))
const ctx: RunContext = {
client,
sessionID,
directory,
abortController,
}
const events = await client.event.subscribe()
const eventState = createEventState()
const eventProcessor = processEvents(ctx, events.stream, eventState)
console.log(pc.dim("\nSending prompt..."))
await client.session.promptAsync({
path: { id: sessionID },
body: {
agent,
parts: [{ type: "text", text: message }],
},
query: { directory },
})
console.log(pc.dim("Waiting for completion...\n"))
while (!abortController.signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
if (!eventState.mainSessionIdle) {
continue
}
// Check if session errored - exit with failure if so
if (eventState.mainSessionError) {
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
console.error(pc.yellow("Check if todos were completed before the error."))
cleanup()
process.exit(1)
}
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
console.log(pc.green("\n\nAll tasks completed."))
cleanup()
process.exit(0)
}
}
await eventProcessor.catch(() => {})
cleanup()
return 130
} catch (err) {
cleanup()
throw err
}
} catch (err) {
if (timeoutId) clearTimeout(timeoutId)
if (err instanceof Error && err.name === "AbortError") {
return 130
}
console.error(pc.red(`Error: ${err}`))
return 1
}
}

76
src/cli/run/types.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { OpencodeClient } from "@opencode-ai/sdk"
export interface RunOptions {
message: string
agent?: string
directory?: string
timeout?: number
}
export interface RunContext {
client: OpencodeClient
sessionID: string
directory: string
abortController: AbortController
}
export interface Todo {
id: string
content: string
status: string
priority: string
}
export interface SessionStatus {
type: "idle" | "busy" | "retry"
}
export interface ChildSession {
id: string
}
export interface EventPayload {
type: string
properties?: Record<string, unknown>
}
export interface SessionIdleProps {
sessionID?: string
}
export interface SessionStatusProps {
sessionID?: string
status?: { type?: string }
}
export interface MessageUpdatedProps {
info?: { sessionID?: string; role?: string }
content?: string
}
export interface MessagePartUpdatedProps {
info?: { sessionID?: string; role?: string }
part?: {
type?: string
text?: string
name?: string
input?: unknown
}
}
export interface ToolExecuteProps {
sessionID?: string
name?: string
input?: Record<string, unknown>
}
export interface ToolResultProps {
sessionID?: string
name?: string
output?: string
}
export interface SessionErrorProps {
sessionID?: string
error?: unknown
}

31
src/cli/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export type ClaudeSubscription = "no" | "yes" | "max20"
export type BooleanArg = "no" | "yes"
export interface InstallArgs {
tui: boolean
claude?: ClaudeSubscription
chatgpt?: BooleanArg
gemini?: BooleanArg
skipAuth?: boolean
}
export interface InstallConfig {
hasClaude: boolean
isMax20: boolean
hasChatGPT: boolean
hasGemini: boolean
}
export interface ConfigMergeResult {
success: boolean
configPath: string
error?: string
}
export interface DetectedConfig {
isInstalled: boolean
hasClaude: boolean
isMax20: boolean
hasChatGPT: boolean
hasGemini: boolean
}

View File

@@ -5,8 +5,10 @@ export {
McpNameSchema,
AgentNameSchema,
HookNameSchema,
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
} from "./schema"
export type {
@@ -16,6 +18,9 @@ export type {
McpName,
AgentName,
HookName,
BuiltinCommandName,
SisyphusAgentConfig,
ExperimentalConfig,
DynamicContextPruningConfig,
RalphLoopConfig,
} from "./schema"

View File

@@ -30,6 +30,7 @@ export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"Sisyphus",
"OpenCode-Builder",
"Planner-Sisyphus",
"oracle",
"librarian",
@@ -53,7 +54,7 @@ export const HookNameSchema = z.enum([
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"anthropic-auto-compact",
"anthropic-context-window-limit-recovery",
"rules-injector",
"background-notification",
"auto-update-checker",
@@ -63,6 +64,12 @@ export const HookNameSchema = z.enum([
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer",
"thinking-block-validator",
"ralph-loop",
])
export const BuiltinCommandNameSchema = z.enum([
"init-deep",
])
export const AgentOverrideConfigSchema = z.object({
@@ -70,6 +77,7 @@ export const AgentOverrideConfigSchema = z.object({
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
prompt: z.string().optional(),
prompt_append: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
description: z.string().optional(),
@@ -85,6 +93,7 @@ export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
Sisyphus: AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
"Planner-Sisyphus": AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
@@ -100,21 +109,118 @@ export const ClaudeCodeConfigSchema = z.object({
skills: z.boolean().optional(),
agents: z.boolean().optional(),
hooks: z.boolean().optional(),
plugins: z.boolean().optional(),
plugins_override: z.record(z.string(), z.boolean()).optional(),
})
export const SisyphusAgentConfigSchema = z.object({
disabled: z.boolean().optional(),
default_builder_enabled: z.boolean().optional(),
planner_enabled: z.boolean().optional(),
replace_plan: z.boolean().optional(),
})
export const CommentCheckerConfigSchema = z.object({
/** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */
custom_prompt: z.string().optional(),
})
export const DynamicContextPruningConfigSchema = z.object({
/** Enable dynamic context pruning (default: false) */
enabled: z.boolean().default(false),
/** Notification level: off, minimal, or detailed (default: detailed) */
notification: z.enum(["off", "minimal", "detailed"]).default("detailed"),
/** Turn protection - prevent pruning recent tool outputs */
turn_protection: z.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(10).default(3),
}).optional(),
/** Tools that should never be pruned */
protected_tools: z.array(z.string()).default([
"task", "todowrite", "todoread",
"lsp_rename", "lsp_code_action_resolve",
"session_read", "session_write", "session_search",
]),
/** Pruning strategies configuration */
strategies: z.object({
/** Remove duplicate tool calls (same tool + same args) */
deduplication: z.object({
enabled: z.boolean().default(true),
}).optional(),
/** Prune write inputs when file subsequently read */
supersede_writes: z.object({
enabled: z.boolean().default(true),
/** Aggressive mode: prune any write if ANY subsequent read */
aggressive: z.boolean().default(false),
}).optional(),
/** Prune errored tool inputs after N turns */
purge_errors: z.object({
enabled: z.boolean().default(true),
turns: z.number().min(1).max(20).default(5),
}).optional(),
}).optional(),
})
export const ExperimentalConfigSchema = z.object({
aggressive_truncation: z.boolean().optional(),
auto_resume: z.boolean().optional(),
/** Enable preemptive compaction at threshold (default: true) */
/** Enable preemptive compaction at threshold (default: false) */
preemptive_compaction: z.boolean().optional(),
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: false) */
/** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */
truncate_all_tool_outputs: z.boolean().optional(),
/** Dynamic context pruning configuration */
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
/** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */
dcp_for_compaction: z.boolean().optional(),
})
export const SkillSourceSchema = z.union([
z.string(),
z.object({
path: z.string(),
recursive: z.boolean().optional(),
glob: z.string().optional(),
}),
])
export const SkillDefinitionSchema = z.object({
description: z.string().optional(),
template: z.string().optional(),
from: z.string().optional(),
model: z.string().optional(),
agent: z.string().optional(),
subtask: z.boolean().optional(),
"argument-hint": z.string().optional(),
license: z.string().optional(),
compatibility: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
"allowed-tools": z.array(z.string()).optional(),
disable: z.boolean().optional(),
})
export const SkillEntrySchema = z.union([
z.boolean(),
SkillDefinitionSchema,
])
export const SkillsConfigSchema = z.union([
z.array(z.string()),
z.record(z.string(), SkillEntrySchema).and(z.object({
sources: z.array(SkillSourceSchema).optional(),
enable: z.array(z.string()).optional(),
disable: z.array(z.string()).optional(),
}).partial()),
])
export const RalphLoopConfigSchema = z.object({
/** Enable ralph loop functionality (default: false - opt-in feature) */
enabled: z.boolean().default(false),
/** Default max iterations if not specified in command (default: 100) */
default_max_iterations: z.number().min(1).max(1000).default(100),
/** Custom state file directory relative to project root (default: .opencode/) */
state_dir: z.string().optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
@@ -122,12 +228,16 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_mcps: z.array(McpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
agents: AgentOverridesSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
google_auth: z.boolean().optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
comment_checker: CommentCheckerConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
auto_update: z.boolean().optional(),
skills: SkillsConfigSchema.optional(),
ralph_loop: RalphLoopConfigSchema.optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
@@ -135,7 +245,13 @@ export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type AgentName = z.infer<typeof AgentNameSchema>
export type HookName = z.infer<typeof HookNameSchema>
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export { McpNameSchema, type McpName } from "../mcp/types"

80
src/features/AGENTS.md Normal file
View File

@@ -0,0 +1,80 @@
# FEATURES KNOWLEDGE BASE
## OVERVIEW
Claude Code compatibility layer and core feature modules. Enables Claude Code configs/commands/skills/MCPs/hooks to work seamlessly in OpenCode.
## STRUCTURE
```
features/
├── background-agent/ # Background task management
│ ├── manager.ts # Task lifecycle, notifications
│ ├── manager.test.ts
│ └── types.ts
├── builtin-commands/ # Built-in slash command definitions
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
│ └── env-expander.ts # ${VAR} expansion
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
├── claude-code-session-state/ # Session state persistence
├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths
└── hook-message-injector/ # Inject messages into conversation
```
## LOADER PRIORITY
Each loader reads from multiple directories (highest priority first):
| Loader | Priority Order |
|--------|---------------|
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
| Skills | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` |
| Agents | `.claude/agents/` > `~/.claude/agents/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
## HOW TO ADD A LOADER
1. Create directory: `src/features/claude-code-my-loader/`
2. Create files:
- `loader.ts`: Main loader logic with `load()` function
- `types.ts`: TypeScript interfaces
- `index.ts`: Barrel export
3. Pattern: Read from multiple dirs, merge with priority, return normalized config
## BACKGROUND AGENT SPECIFICS
- **Task lifecycle**: pending → running → completed/failed
- **Notifications**: OS notification on task complete (configurable)
- **Result retrieval**: `background_output` tool with task_id
- **Cancellation**: `background_cancel` with task_id or all=true
## CONFIG TOGGLES
Disable features in `oh-my-opencode.json`:
```json
{
"claude_code": {
"mcp": false, // Skip .mcp.json loading
"commands": false, // Skip commands/*.md loading
"skills": false, // Skip skills/*/SKILL.md loading
"agents": false, // Skip agents/*.md loading
"hooks": false // Skip settings.json hooks
}
}
```
## HOOK MESSAGE INJECTOR
- **Purpose**: Inject system messages into conversation at specific points
- **Timing**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
- **Format**: Returns `{ messages: [{ role: "user", content: "..." }] }`
## ANTI-PATTERNS (FEATURES)
- **Blocking on load**: Loaders run at startup, keep them fast
- **No error handling**: Always try/catch, log failures, return empty on error
- **Ignoring priority**: Higher priority dirs must override lower
- **Modifying user files**: Loaders read-only, never write to ~/.claude/

View File

@@ -57,7 +57,7 @@ export class BackgroundManager {
private notifications: Map<string, BackgroundTask[]>
private client: OpencodeClient
private directory: string
private pollingInterval?: Timer
private pollingInterval?: ReturnType<typeof setInterval>
constructor(ctx: PluginInput) {
this.tasks = new Map()
@@ -99,6 +99,7 @@ export class BackgroundManager {
toolCalls: 0,
lastUpdate: new Date(),
},
parentModel: input.parentModel,
}
this.tasks.set(task.id, task)
@@ -286,6 +287,7 @@ export class BackgroundManager {
this.pollingInterval = setInterval(() => {
this.pollRunningTasks()
}, 2000)
this.pollingInterval.unref()
}
private stopPolling(): void {
@@ -295,6 +297,12 @@ export class BackgroundManager {
}
}
cleanup(): void {
this.stopPolling()
this.tasks.clear()
this.notifications.clear()
}
private notifyParentSession(task: BackgroundTask): void {
const duration = this.formatDuration(task.startedAt, task.completedAt)
@@ -317,23 +325,33 @@ export class BackgroundManager {
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
const taskId = task.id
setTimeout(async () => {
try {
const messageDir = getMessageDir(task.parentSessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const modelContext = task.parentModel ?? prevMessage?.model
const modelField = modelContext?.providerID && modelContext?.modelID
? { providerID: modelContext.providerID, modelID: modelContext.modelID }
: undefined
await this.client.session.prompt({
path: { id: task.parentSessionID },
body: {
agent: prevMessage?.agent,
model: modelField,
parts: [{ type: "text", text: message }],
},
query: { directory: this.directory },
})
this.clearNotificationsForTask(task.id)
this.clearNotificationsForTask(taskId)
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
} catch (error) {
log("[background-agent] prompt failed:", String(error))
} finally {
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
}, 200)
}

View File

@@ -26,6 +26,7 @@ export interface BackgroundTask {
result?: string
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
}
export interface LaunchInput {
@@ -34,4 +35,5 @@ export interface LaunchInput {
agent: string
parentSessionID: string
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
}

View File

@@ -0,0 +1,53 @@
import type { CommandDefinition } from "../claude-code-command-loader"
import type { BuiltinCommandName, BuiltinCommands } from "./types"
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
"init-deep": {
description: "(builtin) Initialize hierarchical AGENTS.md knowledge base",
template: `<command-instruction>
${INIT_DEEP_TEMPLATE}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`,
argumentHint: "[--create-new] [--max-depth=N]",
},
"ralph-loop": {
description: "(builtin) Start self-referential development loop until completion",
template: `<command-instruction>
${RALPH_LOOP_TEMPLATE}
</command-instruction>
<user-task>
$ARGUMENTS
</user-task>`,
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
},
"cancel-ralph": {
description: "(builtin) Cancel active Ralph Loop",
template: `<command-instruction>
${CANCEL_RALPH_TEMPLATE}
</command-instruction>`,
},
}
export function loadBuiltinCommands(
disabledCommands?: BuiltinCommandName[]
): BuiltinCommands {
const disabled = new Set(disabledCommands ?? [])
const commands: BuiltinCommands = {}
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
if (!disabled.has(name as BuiltinCommandName)) {
commands[name] = {
name,
...definition,
}
}
}
return commands
}

View File

@@ -0,0 +1,2 @@
export * from "./types"
export * from "./commands"

View File

@@ -0,0 +1,394 @@
export const INIT_DEEP_TEMPLATE = `# Initialize Deep Knowledge Base
Generate comprehensive AGENTS.md files across project hierarchy. Combines root-level project knowledge (gen-knowledge) with complexity-based subdirectory documentation (gen-knowledge-deep).
## Usage
\`\`\`
/init-deep # Analyze and generate hierarchical AGENTS.md
/init-deep --create-new # Force create from scratch (ignore existing)
/init-deep --max-depth=2 # Limit to N directory levels (default: 3)
\`\`\`
---
## Core Principles
- **Telegraphic Style**: Sacrifice grammar for concision ("Project uses React" → "React 18")
- **Predict-then-Compare**: Predict standard → find actual → document ONLY deviations
- **Hierarchy Aware**: Parent covers general, children cover specific
- **No Redundancy**: Child AGENTS.md NEVER repeats parent content
- **LSP-First**: Use LSP tools for accurate code intelligence when available (semantic > text search)
---
## Process
<critical>
**MANDATORY: TodoWrite for ALL phases. Mark in_progress → completed in real-time.**
</critical>
### Phase 0: Initialize
\`\`\`
TodoWrite([
{ id: "p1-analysis", content: "Parallel project structure & complexity analysis", status: "pending", priority: "high" },
{ id: "p2-scoring", content: "Score directories, determine AGENTS.md locations", status: "pending", priority: "high" },
{ id: "p3-root", content: "Generate root AGENTS.md with Predict-then-Compare", status: "pending", priority: "high" },
{ id: "p4-subdirs", content: "Generate subdirectory AGENTS.md files in parallel", status: "pending", priority: "high" },
{ id: "p5-review", content: "Review, deduplicate, validate all files", status: "pending", priority: "medium" }
])
\`\`\`
---
## Phase 1: Parallel Project Analysis
**Mark "p1-analysis" as in_progress.**
Launch **ALL tasks simultaneously**:
<parallel-tasks>
### Structural Analysis (bash - run in parallel)
\`\`\`bash
# Task A: Directory depth analysis
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
# Task B: File count per directory
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
# Task C: Code concentration
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.go" -o -name "*.rs" -o -name "*.java" \\) -not -path '*/node_modules/*' -not -path '*/venv/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
# Task D: Existing knowledge files
find . -type f \\( -name "AGENTS.md" -o -name "CLAUDE.md" \\) -not -path '*/node_modules/*' 2>/dev/null
\`\`\`
### Context Gathering (Explore agents - background_task in parallel)
\`\`\`
background_task(agent="explore", prompt="Project structure: PREDICT standard {lang} patterns → FIND package.json/pyproject.toml/go.mod → REPORT deviations only")
background_task(agent="explore", prompt="Entry points: PREDICT typical (main.py, index.ts) → FIND actual → REPORT non-standard organization")
background_task(agent="explore", prompt="Conventions: FIND .cursor/rules, .cursorrules, eslintrc, pyproject.toml → REPORT project-specific rules DIFFERENT from defaults")
background_task(agent="explore", prompt="Anti-patterns: FIND comments with 'DO NOT', 'NEVER', 'ALWAYS', 'LEGACY', 'DEPRECATED' → REPORT forbidden patterns")
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile, justfile → REPORT non-standard build/deploy patterns")
background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.config, test structure → REPORT unique testing conventions")
\`\`\`
### Code Intelligence Analysis (LSP tools - run in parallel)
LSP provides semantic understanding beyond text search. Use for accurate code mapping.
\`\`\`
# Step 1: Check LSP availability
lsp_servers() # Verify language server is available
# Step 2: Analyze entry point files (run in parallel)
# Find entry points first, then analyze each with lsp_document_symbols
lsp_document_symbols(filePath="src/index.ts") # Main entry
lsp_document_symbols(filePath="src/main.py") # Python entry
lsp_document_symbols(filePath="cmd/main.go") # Go entry
# Step 3: Discover key symbols across workspace (run in parallel)
lsp_workspace_symbols(filePath=".", query="class") # All classes
lsp_workspace_symbols(filePath=".", query="interface") # All interfaces
lsp_workspace_symbols(filePath=".", query="function") # Top-level functions
lsp_workspace_symbols(filePath=".", query="type") # Type definitions
# Step 4: Analyze symbol centrality (for top 5-10 key symbols)
# High reference count = central/important concept
lsp_find_references(filePath="src/index.ts", line=X, character=Y) # Main export
\`\`\`
#### LSP Analysis Output Format
\`\`\`
CODE_INTELLIGENCE = {
entry_points: [
{ file: "src/index.ts", exports: ["Plugin", "createHook"], symbol_count: 12 }
],
key_symbols: [
{ name: "Plugin", type: "class", file: "src/index.ts", refs: 45, role: "Central orchestrator" },
{ name: "createHook", type: "function", file: "src/utils.ts", refs: 23, role: "Hook factory" }
],
module_boundaries: [
{ dir: "src/hooks", exports: 21, imports_from: ["shared/"] },
{ dir: "src/tools", exports: 15, imports_from: ["shared/", "hooks/"] }
]
}
\`\`\`
<critical>
**LSP Fallback**: If LSP unavailable (no server installed), skip this section and rely on explore agents + AST-grep patterns.
</critical>
</parallel-tasks>
**Collect all results. Mark "p1-analysis" as completed.**
---
## Phase 2: Complexity Scoring & Location Decision
**Mark "p2-scoring" as in_progress.**
### Scoring Matrix
| Factor | Weight | Threshold | Source |
|--------|--------|-----------|--------|
| File count | 3x | >20 files = high | bash |
| Subdirectory count | 2x | >5 subdirs = high | bash |
| Code file ratio | 2x | >70% code = high | bash |
| Unique patterns | 1x | Has own config | explore |
| Module boundary | 2x | Has __init__.py/index.ts | bash |
| **Symbol density** | 2x | >30 symbols = high | LSP |
| **Export count** | 2x | >10 exports = high | LSP |
| **Reference centrality** | 3x | Symbols with >20 refs | LSP |
<lsp-scoring>
**LSP-Enhanced Scoring** (if available):
\`\`\`
For each directory in candidates:
symbols = lsp_document_symbols(dir/index.ts or dir/__init__.py)
symbol_score = len(symbols) > 30 ? 6 : len(symbols) > 15 ? 3 : 0
export_score = count(exported symbols) > 10 ? 4 : 0
# Check if this module is central (many things depend on it)
for each exported symbol:
refs = lsp_find_references(symbol)
if refs > 20: centrality_score += 3
total_score += symbol_score + export_score + centrality_score
\`\`\`
</lsp-scoring>
### Decision Rules
| Score | Action |
|-------|--------|
| **Root (.)** | ALWAYS create AGENTS.md |
| **High (>15)** | Create dedicated AGENTS.md |
| **Medium (8-15)** | Create if distinct domain |
| **Low (<8)** | Skip, parent sufficient |
### Output Format
\`\`\`
AGENTS_LOCATIONS = [
{ path: ".", type: "root" },
{ path: "src/api", score: 18, reason: "high complexity, 45 files" },
{ path: "src/hooks", score: 12, reason: "distinct domain, unique patterns" },
]
\`\`\`
**Mark "p2-scoring" as completed.**
---
## Phase 3: Generate Root AGENTS.md
**Mark "p3-root" as in_progress.**
Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
### Required Sections
\`\`\`markdown
# PROJECT KNOWLEDGE BASE
**Generated:** {TIMESTAMP}
**Commit:** {SHORT_SHA}
**Branch:** {BRANCH}
## OVERVIEW
{1-2 sentences: what project does, core tech stack}
## STRUCTURE
\\\`\\\`\\\`
{project-root}/
├── {dir}/ # {non-obvious purpose only}
└── {entry} # entry point
\\\`\\\`\\\`
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Add feature X | \\\`src/x/\\\` | {pattern hint} |
## CODE MAP
{Generated from LSP analysis - shows key symbols and their relationships}
| Symbol | Type | Location | Refs | Role |
|--------|------|----------|------|------|
| {MainClass} | Class | \\\`src/index.ts\\\` | {N} | {Central orchestrator} |
| {createX} | Function | \\\`src/utils.ts\\\` | {N} | {Factory pattern} |
| {Config} | Interface | \\\`src/types.ts\\\` | {N} | {Configuration contract} |
### Module Dependencies
\\\`\\\`\\\`
{entry} ──imports──> {core/}
│ │
└──imports──> {utils/} <──imports── {features/}
\\\`\\\`\\\`
<code-map-note>
**Skip CODE MAP if**: LSP unavailable OR project too small (<10 files) OR no clear module boundaries.
</code-map-note>
## CONVENTIONS
{ONLY deviations from standard - skip generic advice}
- **{rule}**: {specific detail}
## ANTI-PATTERNS (THIS PROJECT)
{Things explicitly forbidden HERE}
- **{pattern}**: {why} → {alternative}
## UNIQUE STYLES
{Project-specific coding styles}
- **{style}**: {how different}
## COMMANDS
\\\`\\\`\\\`bash
{dev-command}
{test-command}
{build-command}
\\\`\\\`\\\`
## NOTES
{Gotchas, non-obvious info}
\`\`\`
### Quality Gates
- [ ] Size: 50-150 lines
- [ ] No generic advice ("write clean code")
- [ ] No obvious info ("tests/ has tests")
- [ ] Every item is project-specific
**Mark "p3-root" as completed.**
---
## Phase 4: Generate Subdirectory AGENTS.md
**Mark "p4-subdirs" as in_progress.**
For each location in AGENTS_LOCATIONS (except root), launch **parallel document-writer agents**:
\`\`\`typescript
for (const loc of AGENTS_LOCATIONS.filter(l => l.path !== ".")) {
background_task({
agent: "document-writer",
prompt: \\\`
Generate AGENTS.md for: \${loc.path}
CONTEXT:
- Complexity reason: \${loc.reason}
- Parent AGENTS.md: ./AGENTS.md (already covers project overview)
CRITICAL RULES:
1. Focus ONLY on this directory's specific context
2. NEVER repeat parent AGENTS.md content
3. Shorter is better - 30-80 lines max
4. Telegraphic style - sacrifice grammar
REQUIRED SECTIONS:
- OVERVIEW (1 line: what this directory does)
- STRUCTURE (only if >5 subdirs)
- WHERE TO LOOK (directory-specific tasks)
- CONVENTIONS (only if DIFFERENT from root)
- ANTI-PATTERNS (directory-specific only)
OUTPUT: Write to \${loc.path}/AGENTS.md
\\\`
})
}
\`\`\`
**Wait for all agents. Mark "p4-subdirs" as completed.**
---
## Phase 5: Review & Deduplicate
**Mark "p5-review" as in_progress.**
### Validation Checklist
For EACH generated AGENTS.md:
| Check | Action if Fail |
|-------|----------------|
| Contains generic advice | REMOVE the line |
| Repeats parent content | REMOVE the line |
| Missing required section | ADD it |
| Over 150 lines (root) / 80 lines (subdir) | TRIM |
| Verbose explanations | REWRITE telegraphic |
### Cross-Reference Validation
\`\`\`
For each child AGENTS.md:
For each line in child:
If similar line exists in parent:
REMOVE from child (parent already covers)
\`\`\`
**Mark "p5-review" as completed.**
---
## Final Report
\`\`\`
=== init-deep Complete ===
Files Generated:
✓ ./AGENTS.md (root, {N} lines)
✓ ./src/hooks/AGENTS.md ({N} lines)
✓ ./src/tools/AGENTS.md ({N} lines)
Directories Analyzed: {N}
AGENTS.md Created: {N}
Total Lines: {N}
Hierarchy:
./AGENTS.md
├── src/hooks/AGENTS.md
└── src/tools/AGENTS.md
\`\`\`
---
## Anti-Patterns for THIS Command
- **Over-documenting**: Not every directory needs AGENTS.md
- **Redundancy**: Child must NOT repeat parent
- **Generic content**: Remove anything that applies to ALL projects
- **Sequential execution**: MUST use parallel agents
- **Deep nesting**: Rarely need AGENTS.md at depth 4+
- **Verbose style**: "This directory contains..." → just list it
- **Ignoring LSP**: If LSP available, USE IT - semantic analysis > text grep
- **LSP without fallback**: Always have explore agent backup if LSP unavailable
- **Over-referencing**: Don't trace refs for EVERY symbol - focus on exports only`

View File

@@ -0,0 +1,38 @@
export const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-referential development loop that runs until task completion.
## How Ralph Loop Works
1. You will work on the task continuously
2. When you believe the task is FULLY complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
3. If you don't output the promise, the loop will automatically inject another prompt to continue
4. Maximum iterations: Configurable (default 100)
## Rules
- Focus on completing the task fully, not partially
- Don't output the completion promise until the task is truly done
- Each iteration should make meaningful progress toward the goal
- If stuck, try different approaches
- Use todos to track your progress
## Exit Conditions
1. **Completion**: Output \`<promise>DONE</promise>\` (or custom promise text) when fully complete
2. **Max Iterations**: Loop stops automatically at limit
3. **Cancel**: User runs \`/cancel-ralph\` command
## Your Task
Parse the arguments below and begin working on the task. The format is:
\`"task description" [--completion-promise=TEXT] [--max-iterations=N]\`
Default completion promise is "DONE" and default max iterations is 100.`
export const CANCEL_RALPH_TEMPLATE = `Cancel the currently active Ralph Loop.
This will:
1. Stop the loop from continuing
2. Clear the loop state file
3. Allow the session to end normally
Check if a loop is active and cancel it. Inform the user of the result.`

View File

@@ -0,0 +1,9 @@
import type { CommandDefinition } from "../claude-code-command-loader"
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph"
export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]
}
export type BuiltinCommands = Record<string, CommandDefinition>

View File

@@ -0,0 +1,2 @@
export * from "./types"
export { createBuiltinSkills } from "./skills"

View File

@@ -0,0 +1,5 @@
import type { BuiltinSkill } from "./types"
export function createBuiltinSkills(): BuiltinSkill[] {
return []
}

View File

@@ -0,0 +1,13 @@
export interface BuiltinSkill {
name: string
description: string
template: string
license?: string
compatibility?: string
metadata?: Record<string, unknown>
allowedTools?: string[]
agent?: string
model?: string
subtask?: boolean
argumentHint?: string
}

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
@@ -68,7 +68,7 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
}
export function loadUserAgents(): Record<string, AgentConfig> {
const userAgentsDir = join(homedir(), ".claude", "agents")
const userAgentsDir = join(getClaudeConfigDir(), "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, AgentConfig> = {}

View File

@@ -1,9 +1,9 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
@@ -68,7 +68,7 @@ function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefi
}
export function loadUserCommands(): Record<string, CommandDefinition> {
const userCommandsDir = join(homedir(), ".claude", "commands")
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands)
}
@@ -80,6 +80,7 @@ export function loadProjectCommands(): Record<string, CommandDefinition> {
}
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
const { homedir } = require("os")
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)

View File

@@ -1,6 +1,6 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import { getClaudeConfigDir } from "../../shared"
import type {
ClaudeCodeMcpConfig,
LoadedMcpServer,
@@ -16,11 +16,11 @@ interface McpConfigPath {
}
function getMcpConfigPaths(): McpConfigPath[] {
const home = homedir()
const claudeConfigDir = getClaudeConfigDir()
const cwd = process.cwd()
return [
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
{ path: join(claudeConfigDir, ".mcp.json"), scope: "user" },
{ path: join(cwd, ".mcp.json"), scope: "project" },
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
]

View File

@@ -0,0 +1,3 @@
export * from "./types"
export * from "./loader"
export type { PluginLoaderOptions, ClaudeSettings } from "./types"

View File

@@ -0,0 +1,480 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile, resolveSymlink } from "../../shared/file-utils"
import { log } from "../../shared/logger"
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
import { transformMcpServer } from "../claude-code-mcp-loader/transformer"
import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types"
import type { SkillMetadata } from "../opencode-skill-loader/types"
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
import type { ClaudeCodeMcpConfig, McpServerConfig } from "../claude-code-mcp-loader/types"
import type {
InstalledPluginsDatabase,
PluginInstallation,
PluginManifest,
LoadedPlugin,
PluginLoadResult,
PluginLoadError,
PluginScope,
HooksConfig,
ClaudeSettings,
PluginLoaderOptions,
} from "./types"
const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"
function getPluginsBaseDir(): string {
// Allow override for testing
if (process.env.CLAUDE_PLUGINS_HOME) {
return process.env.CLAUDE_PLUGINS_HOME
}
return join(homedir(), ".claude", "plugins")
}
function getInstalledPluginsPath(): string {
return join(getPluginsBaseDir(), "installed_plugins.json")
}
function resolvePluginPath(path: string, pluginRoot: string): string {
return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot)
}
function resolvePluginPaths<T>(obj: T, pluginRoot: string): T {
if (obj === null || obj === undefined) return obj
if (typeof obj === "string") {
return resolvePluginPath(obj, pluginRoot) as T
}
if (Array.isArray(obj)) {
return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = resolvePluginPaths(value, pluginRoot)
}
return result as T
}
return obj
}
function loadInstalledPlugins(): InstalledPluginsDatabase | null {
const dbPath = getInstalledPluginsPath()
if (!existsSync(dbPath)) {
return null
}
try {
const content = readFileSync(dbPath, "utf-8")
return JSON.parse(content) as InstalledPluginsDatabase
} catch (error) {
log("Failed to load installed plugins database", error)
return null
}
}
function getClaudeSettingsPath(): string {
if (process.env.CLAUDE_SETTINGS_PATH) {
return process.env.CLAUDE_SETTINGS_PATH
}
return join(homedir(), ".claude", "settings.json")
}
function loadClaudeSettings(): ClaudeSettings | null {
const settingsPath = getClaudeSettingsPath()
if (!existsSync(settingsPath)) {
return null
}
try {
const content = readFileSync(settingsPath, "utf-8")
return JSON.parse(content) as ClaudeSettings
} catch (error) {
log("Failed to load Claude settings", error)
return null
}
}
function loadPluginManifest(installPath: string): PluginManifest | null {
const manifestPath = join(installPath, ".claude-plugin", "plugin.json")
if (!existsSync(manifestPath)) {
return null
}
try {
const content = readFileSync(manifestPath, "utf-8")
return JSON.parse(content) as PluginManifest
} catch (error) {
log(`Failed to load plugin manifest from ${manifestPath}`, error)
return null
}
}
function derivePluginNameFromKey(pluginKey: string): string {
const atIndex = pluginKey.indexOf("@")
if (atIndex > 0) {
return pluginKey.substring(0, atIndex)
}
return pluginKey
}
function isPluginEnabled(
pluginKey: string,
settingsEnabledPlugins: Record<string, boolean> | undefined,
overrideEnabledPlugins: Record<string, boolean> | undefined
): boolean {
if (overrideEnabledPlugins && pluginKey in overrideEnabledPlugins) {
return overrideEnabledPlugins[pluginKey]
}
if (settingsEnabledPlugins && pluginKey in settingsEnabledPlugins) {
return settingsEnabledPlugins[pluginKey]
}
return true
}
function extractPluginEntries(
db: InstalledPluginsDatabase
): Array<[string, PluginInstallation | undefined]> {
if (db.version === 1) {
return Object.entries(db.plugins).map(([key, installation]) => [key, installation])
}
return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]])
}
export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult {
const db = loadInstalledPlugins()
const settings = loadClaudeSettings()
const plugins: LoadedPlugin[] = []
const errors: PluginLoadError[] = []
if (!db || !db.plugins) {
return { plugins, errors }
}
const settingsEnabledPlugins = settings?.enabledPlugins
const overrideEnabledPlugins = options?.enabledPluginsOverride
for (const [pluginKey, installation] of extractPluginEntries(db)) {
if (!installation) continue
if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) {
log(`Plugin disabled: ${pluginKey}`)
continue
}
const { installPath, scope, version } = installation
if (!existsSync(installPath)) {
errors.push({
pluginKey,
installPath,
error: "Plugin installation path does not exist",
})
continue
}
const manifest = loadPluginManifest(installPath)
const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey)
const loadedPlugin: LoadedPlugin = {
name: pluginName,
version: version || manifest?.version || "unknown",
scope: scope as PluginScope,
installPath,
pluginKey,
manifest: manifest ?? undefined,
}
if (existsSync(join(installPath, "commands"))) {
loadedPlugin.commandsDir = join(installPath, "commands")
}
if (existsSync(join(installPath, "agents"))) {
loadedPlugin.agentsDir = join(installPath, "agents")
}
if (existsSync(join(installPath, "skills"))) {
loadedPlugin.skillsDir = join(installPath, "skills")
}
const hooksPath = join(installPath, "hooks", "hooks.json")
if (existsSync(hooksPath)) {
loadedPlugin.hooksPath = hooksPath
}
const mcpPath = join(installPath, ".mcp.json")
if (existsSync(mcpPath)) {
loadedPlugin.mcpPath = mcpPath
}
plugins.push(loadedPlugin)
log(`Discovered plugin: ${pluginName}@${version} (${scope})`, { installPath, hasManifest: !!manifest })
}
return { plugins, errors }
}
export function loadPluginCommands(
plugins: LoadedPlugin[]
): Record<string, CommandDefinition> {
const commands: Record<string, CommandDefinition> = {}
for (const plugin of plugins) {
if (!plugin.commandsDir || !existsSync(plugin.commandsDir)) continue
const entries = readdirSync(plugin.commandsDir, { withFileTypes: true })
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const commandPath = join(plugin.commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
const namespacedName = `${plugin.name}:${commandName}`
try {
const content = readFileSync(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
${body.trim()}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}`
commands[namespacedName] = {
name: namespacedName,
description: formattedDescription,
template: wrappedTemplate,
agent: data.agent,
model: sanitizeModelField(data.model, "claude-code"),
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
log(`Loaded plugin command: ${namespacedName}`, { path: commandPath })
} catch (error) {
log(`Failed to load plugin command: ${commandPath}`, error)
}
}
}
return commands
}
export function loadPluginSkillsAsCommands(
plugins: LoadedPlugin[]
): Record<string, CommandDefinition> {
const skills: Record<string, CommandDefinition> = {}
for (const plugin of plugins) {
if (!plugin.skillsDir || !existsSync(plugin.skillsDir)) continue
const entries = readdirSync(plugin.skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const skillPath = join(plugin.skillsDir, entry.name)
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const resolvedPath = resolveSymlink(skillPath)
const skillMdPath = join(resolvedPath, "SKILL.md")
if (!existsSync(skillMdPath)) continue
try {
const content = readFileSync(skillMdPath, "utf-8")
const { data, body } = parseFrontmatter<SkillMetadata>(content)
const skillName = data.name || entry.name
const namespacedName = `${plugin.name}:${skillName}`
const originalDescription = data.description || ""
const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
skills[namespacedName] = {
name: namespacedName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model),
}
log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath })
} catch (error) {
log(`Failed to load plugin skill: ${skillPath}`, error)
}
}
}
return skills
}
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
result[tool.toLowerCase()] = true
}
return result
}
export function loadPluginAgents(
plugins: LoadedPlugin[]
): Record<string, AgentConfig> {
const agents: Record<string, AgentConfig> = {}
for (const plugin of plugins) {
if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue
const entries = readdirSync(plugin.agentsDir, { withFileTypes: true })
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const agentPath = join(plugin.agentsDir, entry.name)
const agentName = basename(entry.name, ".md")
const namespacedName = `${plugin.name}:${agentName}`
try {
const content = readFileSync(agentPath, "utf-8")
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
const name = data.name || agentName
const originalDescription = data.description || ""
const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`
const config: AgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
}
const toolsConfig = parseToolsConfig(data.tools)
if (toolsConfig) {
config.tools = toolsConfig
}
agents[namespacedName] = config
log(`Loaded plugin agent: ${namespacedName}`, { path: agentPath })
} catch (error) {
log(`Failed to load plugin agent: ${agentPath}`, error)
}
}
}
return agents
}
export async function loadPluginMcpServers(
plugins: LoadedPlugin[]
): Promise<Record<string, McpServerConfig>> {
const servers: Record<string, McpServerConfig> = {}
for (const plugin of plugins) {
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
try {
const content = await Bun.file(plugin.mcpPath).text()
let config = JSON.parse(content) as ClaudeCodeMcpConfig
config = resolvePluginPaths(config, plugin.installPath)
config = expandEnvVarsInObject(config)
if (!config.mcpServers) continue
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (serverConfig.disabled) {
log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`)
continue
}
try {
const transformed = transformMcpServer(name, serverConfig)
const namespacedName = `${plugin.name}:${name}`
servers[namespacedName] = transformed
log(`Loaded plugin MCP server: ${namespacedName}`, { path: plugin.mcpPath })
} catch (error) {
log(`Failed to transform plugin MCP server "${name}"`, error)
}
}
} catch (error) {
log(`Failed to load plugin MCP config: ${plugin.mcpPath}`, error)
}
}
return servers
}
export function loadPluginHooksConfigs(
plugins: LoadedPlugin[]
): HooksConfig[] {
const configs: HooksConfig[] = []
for (const plugin of plugins) {
if (!plugin.hooksPath || !existsSync(plugin.hooksPath)) continue
try {
const content = readFileSync(plugin.hooksPath, "utf-8")
let config = JSON.parse(content) as HooksConfig
config = resolvePluginPaths(config, plugin.installPath)
configs.push(config)
log(`Loaded plugin hooks config from ${plugin.name}`, { path: plugin.hooksPath })
} catch (error) {
log(`Failed to load plugin hooks config: ${plugin.hooksPath}`, error)
}
}
return configs
}
export interface PluginComponentsResult {
commands: Record<string, CommandDefinition>
skills: Record<string, CommandDefinition>
agents: Record<string, AgentConfig>
mcpServers: Record<string, McpServerConfig>
hooksConfigs: HooksConfig[]
plugins: LoadedPlugin[]
errors: PluginLoadError[]
}
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
const { plugins, errors } = discoverInstalledPlugins(options)
const commands = loadPluginCommands(plugins)
const skills = loadPluginSkillsAsCommands(plugins)
const agents = loadPluginAgents(plugins)
const mcpServers = await loadPluginMcpServers(plugins)
const hooksConfigs = loadPluginHooksConfigs(plugins)
log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`)
return {
commands,
skills,
agents,
mcpServers,
hooksConfigs,
plugins,
errors,
}
}

View File

@@ -0,0 +1,210 @@
/**
* Claude Code Plugin Types
*
* Type definitions for Claude Code plugin system compatibility.
* Based on https://code.claude.com/docs/en/plugins-reference
*/
export type PluginScope = "user" | "project" | "local" | "managed"
/**
* Plugin installation entry in installed_plugins.json
*/
export interface PluginInstallation {
scope: PluginScope
installPath: string
version: string
installedAt: string
lastUpdated: string
gitCommitSha?: string
isLocal?: boolean
}
/**
* Installed plugins database v1 (legacy)
* plugins stored as direct objects
*/
export interface InstalledPluginsDatabaseV1 {
version: 1
plugins: Record<string, PluginInstallation>
}
/**
* Installed plugins database v2 (current)
* plugins stored as arrays
*/
export interface InstalledPluginsDatabaseV2 {
version: 2
plugins: Record<string, PluginInstallation[]>
}
/**
* Installed plugins database structure
* Located at ~/.claude/plugins/installed_plugins.json
*/
export type InstalledPluginsDatabase = InstalledPluginsDatabaseV1 | InstalledPluginsDatabaseV2
/**
* Plugin author information
*/
export interface PluginAuthor {
name?: string
email?: string
url?: string
}
/**
* Plugin manifest (plugin.json)
* Located at <plugin_root>/.claude-plugin/plugin.json
*/
export interface PluginManifest {
name: string
version?: string
description?: string
author?: PluginAuthor
homepage?: string
repository?: string
license?: string
keywords?: string[]
// Component paths (can be string or array)
commands?: string | string[]
agents?: string | string[]
skills?: string | string[]
hooks?: string | HooksConfig
mcpServers?: string | McpServersConfig
lspServers?: string | LspServersConfig
outputStyles?: string | string[]
}
/**
* Hooks configuration
*/
export interface HookEntry {
type: "command" | "prompt" | "agent"
command?: string
prompt?: string
agent?: string
}
export interface HookMatcher {
matcher?: string
hooks: HookEntry[]
}
export interface HooksConfig {
hooks?: {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]
PostToolUseFailure?: HookMatcher[]
PermissionRequest?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Notification?: HookMatcher[]
Stop?: HookMatcher[]
SubagentStart?: HookMatcher[]
SubagentStop?: HookMatcher[]
SessionStart?: HookMatcher[]
SessionEnd?: HookMatcher[]
PreCompact?: HookMatcher[]
}
}
/**
* MCP servers configuration in plugin
*/
export interface PluginMcpServer {
command?: string
args?: string[]
env?: Record<string, string>
cwd?: string
url?: string
type?: "stdio" | "http" | "sse"
disabled?: boolean
}
export interface McpServersConfig {
mcpServers?: Record<string, PluginMcpServer>
}
/**
* LSP server configuration
*/
export interface LspServerConfig {
command: string
args?: string[]
extensionToLanguage: Record<string, string>
transport?: "stdio" | "socket"
env?: Record<string, string>
initializationOptions?: Record<string, unknown>
settings?: Record<string, unknown>
workspaceFolder?: string
startupTimeout?: number
shutdownTimeout?: number
restartOnCrash?: boolean
maxRestarts?: number
loggingConfig?: {
args?: string[]
env?: Record<string, string>
}
}
export interface LspServersConfig {
[language: string]: LspServerConfig
}
/**
* Loaded plugin with all resolved components
*/
export interface LoadedPlugin {
name: string
version: string
scope: PluginScope
installPath: string
manifest?: PluginManifest
pluginKey: string
// Resolved paths for components
commandsDir?: string
agentsDir?: string
skillsDir?: string
hooksPath?: string
mcpPath?: string
lspPath?: string
}
/**
* Plugin load result with all components
*/
export interface PluginLoadResult {
plugins: LoadedPlugin[]
errors: PluginLoadError[]
}
export interface PluginLoadError {
pluginKey: string
installPath: string
error: string
}
/**
* Claude settings from ~/.claude/settings.json
*/
export interface ClaudeSettings {
enabledPlugins?: Record<string, boolean>
// Other settings we don't use
[key: string]: unknown
}
/**
* Plugin loader options
*/
export interface PluginLoaderOptions {
/**
* Override enabled plugins from oh-my-opencode config.
* Key format: "pluginName@marketplace" (e.g., "shell-scripting@claude-code-workflows")
* Value: true = enabled, false = disabled
*
* This takes precedence over ~/.claude/settings.json enabledPlugins
*/
enabledPluginsOverride?: Record<string, boolean>
}

View File

@@ -1,86 +0,0 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlink } from "../../shared/file-utils"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
const skills: LoadedSkillAsCommand[] = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const skillPath = join(skillsDir, entry.name)
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const resolvedPath = resolveSymlink(skillPath)
const skillMdPath = join(resolvedPath, "SKILL.md")
if (!existsSync(skillMdPath)) continue
try {
const content = readFileSync(skillMdPath, "utf-8")
const { data, body } = parseFrontmatter<SkillMetadata>(content)
const skillName = data.name || entry.name
const originalDescription = data.description || ""
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const definition: CommandDefinition = {
name: skillName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model),
}
skills.push({
name: skillName,
path: resolvedPath,
definition,
scope,
})
} catch {
continue
}
}
return skills
}
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
const userSkillsDir = join(homedir(), ".claude", "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition
return acc
}, {} as Record<string, CommandDefinition>)
}
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = loadSkillsFromDir(projectSkillsDir, "project")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition
return acc
}, {} as Record<string, CommandDefinition>)
}

View File

@@ -1,16 +0,0 @@
import type { CommandDefinition } from "../claude-code-command-loader/types"
export type SkillScope = "user" | "project"
export interface SkillMetadata {
name: string
description: string
model?: string
}
export interface LoadedSkillAsCommand {
name: string
path: string
definition: CommandDefinition
scope: SkillScope
}

View File

@@ -1,8 +1,6 @@
import { join } from "node:path"
import { homedir } from "node:os"
import { getOpenCodeStorageDir } from "../../shared/data-path"
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage")
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")

View File

@@ -1,2 +1,3 @@
export * from "./types"
export * from "./loader"
export * from "./merger"

View File

@@ -0,0 +1,246 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path"
import { homedir } from "os"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
/**
* Load a skill from a markdown file path.
*
* @param skillPath - Path to the skill file (SKILL.md or {name}.md)
* @param resolvedPath - Directory for file reference resolution (@path references)
* @param defaultName - Fallback name if not specified in frontmatter
* @param scope - Source scope for priority ordering
*/
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
if (!allowedTools) return undefined
return allowedTools.split(/\s+/).filter(Boolean)
}
function loadSkillFromPath(
skillPath: string,
resolvedPath: string,
defaultName: string,
scope: SkillScope
): LoadedSkill | null {
try {
const content = readFileSync(skillPath, "utf-8")
const { data, body } = parseFrontmatter<SkillMetadata>(content)
const skillName = data.name || defaultName
const originalDescription = data.description || ""
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const definition: CommandDefinition = {
name: skillName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
agent: data.agent,
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
return {
name: skillName,
path: skillPath,
resolvedPath,
definition,
scope,
license: data.license,
compatibility: data.compatibility,
metadata: data.metadata,
allowedTools: parseAllowedTools(data["allowed-tools"]),
}
} catch {
return null
}
}
/**
* Load skills from a directory, supporting BOTH patterns:
* - Directory with SKILL.md: skill-name/SKILL.md
* - Directory with {SKILLNAME}.md: skill-name/{SKILLNAME}.md
* - Direct markdown file: skill-name.md
*/
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[] {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
const skills: LoadedSkill[] = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const entryPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) {
const resolvedPath = resolveSymlink(entryPath)
const dirName = entry.name
const skillMdPath = join(resolvedPath, "SKILL.md")
if (existsSync(skillMdPath)) {
const skill = loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
}
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
if (existsSync(namedSkillMdPath)) {
const skill = loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
}
continue
}
if (isMarkdownFile(entry)) {
const skillName = basename(entry.name, ".md")
const skill = loadSkillFromPath(entryPath, skillsDir, skillName, scope)
if (skill) skills.push(skill)
}
}
return skills
}
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
for (const skill of skills) {
result[skill.name] = skill.definition
}
return result
}
/**
* Load skills from Claude Code user directory (~/.claude/skills/)
*/
export function loadUserSkills(): Record<string, CommandDefinition> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
return skillsToRecord(skills)
}
/**
* Load skills from Claude Code project directory (.claude/skills/)
*/
export function loadProjectSkills(): Record<string, CommandDefinition> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = loadSkillsFromDir(projectSkillsDir, "project")
return skillsToRecord(skills)
}
/**
* Load skills from OpenCode global directory (~/.config/opencode/skill/)
*/
export function loadOpencodeGlobalSkills(): Record<string, CommandDefinition> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode")
return skillsToRecord(skills)
}
/**
* Load skills from OpenCode project directory (.opencode/skill/)
*/
export function loadOpencodeProjectSkills(): Record<string, CommandDefinition> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
return skillsToRecord(skills)
}
/**
* Discover all skills from all sources with priority ordering.
* Priority order: opencode-project > project > opencode > user
*
* @returns Array of LoadedSkill objects for use in slashcommand discovery
*/
export function discoverAllSkills(): LoadedSkill[] {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const projectDir = join(process.cwd(), ".claude", "skills")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
const userDir = join(getClaudeConfigDir(), "skills")
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const projectSkills = loadSkillsFromDir(projectDir, "project")
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
const userSkills = loadSkillsFromDir(userDir, "user")
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
export interface DiscoverSkillsOptions {
includeClaudeCodePaths?: boolean
}
/**
* Discover skills with optional filtering.
* When includeClaudeCodePaths is false, only loads from OpenCode paths.
*/
export function discoverSkills(options: DiscoverSkillsOptions = {}): LoadedSkill[] {
const { includeClaudeCodePaths = true } = options
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
if (!includeClaudeCodePaths) {
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
}
const projectDir = join(process.cwd(), ".claude", "skills")
const userDir = join(getClaudeConfigDir(), "skills")
const projectSkills = loadSkillsFromDir(projectDir, "project")
const userSkills = loadSkillsFromDir(userDir, "user")
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
/**
* Get a skill by name from all available sources.
*/
export function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): LoadedSkill | undefined {
const skills = discoverSkills(options)
return skills.find(s => s.name === name)
}
export function discoverUserClaudeSkills(): LoadedSkill[] {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDir(userSkillsDir, "user")
}
export function discoverProjectClaudeSkills(): LoadedSkill[] {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDir(projectSkillsDir, "project")
}
export function discoverOpencodeGlobalSkills(): LoadedSkill[] {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
}
export function discoverOpencodeProjectSkills(): LoadedSkill[] {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
}

View File

@@ -0,0 +1,266 @@
import type { LoadedSkill, SkillScope, SkillMetadata } from "./types"
import type { SkillsConfig, SkillDefinition } from "../../config/schema"
import type { BuiltinSkill } from "../builtin-skills/types"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import { readFileSync, existsSync } from "fs"
import { dirname, resolve, isAbsolute } from "path"
import { homedir } from "os"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { deepMerge } from "../../shared/deep-merge"
const SCOPE_PRIORITY: Record<SkillScope, number> = {
builtin: 1,
config: 2,
user: 3,
opencode: 4,
project: 5,
"opencode-project": 6,
}
function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
const definition: CommandDefinition = {
name: builtin.name,
description: `(builtin - 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<string, string> | undefined,
allowedTools: builtin.allowedTools,
}
}
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<SkillMetadata>(content)
return { template: body, metadata: data }
} catch {
return null
}
}
function configEntryToLoaded(
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 wrappedTemplate = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${template.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
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"] ||
(fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined)
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<string, string> | undefined || fileMetadata.metadata,
allowedTools,
}
}
function normalizeConfig(config: SkillsConfig | undefined): {
sources: Array<string | { path: string; recursive?: boolean; glob?: string }>
enable: string[]
disable: string[]
entries: Record<string, boolean | SkillDefinition>
} {
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 }
}
function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): LoadedSkill {
const mergedMetadata = base.metadata || patch.metadata
? deepMerge(base.metadata || {}, (patch.metadata as Record<string, string>) || {})
: 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<string, string> | undefined,
allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined,
}
}
export interface MergeSkillsOptions {
configDir?: string
}
export function mergeSkills(
builtinSkills: BuiltinSkill[],
config: SkillsConfig | undefined,
userClaudeSkills: LoadedSkill[],
userOpencodeSkills: LoadedSkill[],
projectClaudeSkills: LoadedSkill[],
projectOpencodeSkills: LoadedSkill[],
options: MergeSkillsOptions = {}
): LoadedSkill[] {
const skillMap = new Map<string, LoadedSkill>()
for (const builtin of builtinSkills) {
const loaded = builtinToLoaded(builtin)
skillMap.set(loaded.name, loaded)
}
const normalizedConfig = normalizeConfig(config)
for (const [name, entry] of Object.entries(normalizedConfig.entries)) {
if (entry === false) continue
if (entry === true) continue
if (entry.disable) continue
const loaded = configEntryToLoaded(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 = [
...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())
}

View File

@@ -0,0 +1,28 @@
import type { CommandDefinition } from "../claude-code-command-loader/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<string, string>
"allowed-tools"?: string
}
export interface LoadedSkill {
name: string
path?: string
resolvedPath?: string
definition: CommandDefinition
scope: SkillScope
license?: string
compatibility?: string
metadata?: Record<string, string>
allowedTools?: string[]
}

84
src/hooks/AGENTS.md Normal file
View File

@@ -0,0 +1,84 @@
# HOOKS KNOWLEDGE BASE
## OVERVIEW
Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce rules, recover from errors, notify on events.
## STRUCTURE
```
hooks/
├── agent-usage-reminder/ # Remind to use specialized agents
├── anthropic-context-window-limit-recovery/ # Auto-compact Claude at token limit
├── auto-update-checker/ # Version update notifications
├── background-notification/ # OS notify on background task complete
├── claude-code-hooks/ # Claude Code settings.json integration
├── comment-checker/ # Prevent excessive AI comments
│ ├── filters/ # Filtering rules (docstring, directive, bdd, etc.)
│ └── output/ # Output formatting
├── compaction-context-injector/ # Inject context during compaction
├── directory-agents-injector/ # Auto-inject AGENTS.md files
├── directory-readme-injector/ # Auto-inject README.md files
├── empty-message-sanitizer/ # Sanitize empty messages
├── interactive-bash-session/ # Tmux session management
├── keyword-detector/ # Detect ultrawork/search keywords
├── non-interactive-env/ # CI/headless environment handling
├── preemptive-compaction/ # Pre-emptive session compaction
├── rules-injector/ # Conditional rules from .claude/rules/
├── session-recovery/ # Recover from session errors
├── think-mode/ # Auto-detect thinking triggers
├── thinking-block-validator/ # Validate thinking blocks in messages
├── context-window-monitor.ts # Monitor context usage (standalone)
├── empty-task-response-detector.ts
├── session-notification.ts # OS notify on idle (standalone)
├── todo-continuation-enforcer.ts # Force TODO completion (standalone)
└── tool-output-truncator.ts # Truncate verbose outputs (standalone)
```
## HOOK CATEGORIES
| Category | Hooks | Purpose |
|----------|-------|---------|
| Context Injection | directory-agents-injector, directory-readme-injector, rules-injector, compaction-context-injector | Auto-inject relevant context |
| Session Management | session-recovery, anthropic-context-window-limit-recovery, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
| Output Control | comment-checker, tool-output-truncator | Control agent output quality |
| Notifications | session-notification, background-notification, auto-update-checker | OS/user notifications |
| Behavior Enforcement | todo-continuation-enforcer, keyword-detector, think-mode, agent-usage-reminder | Enforce agent behavior |
| Environment | non-interactive-env, interactive-bash-session, context-window-monitor | Adapt to runtime environment |
| Compatibility | claude-code-hooks | Claude Code settings.json support |
## HOW TO ADD A HOOK
1. Create directory: `src/hooks/my-hook/`
2. Create files:
- `index.ts`: Export `createMyHook(input: PluginInput)`
- `constants.ts`: Hook name constant
- `types.ts`: TypeScript interfaces (optional)
- `storage.ts`: Persistent state (optional)
3. Return event handlers: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
4. Export from `src/hooks/index.ts`
5. Register in main plugin
## HOOK EVENTS
| Event | Timing | Can Block | Use Case |
|-------|--------|-----------|----------|
| PreToolUse | Before tool exec | Yes | Validate, modify input |
| PostToolUse | After tool exec | No | Add context, warnings |
| UserPromptSubmit | On user prompt | Yes | Inject messages, block |
| Stop | Session idle | No | Inject follow-ups |
| onSummarize | During compaction | No | Preserve critical context |
## COMMON PATTERNS
- **Storage**: Use `storage.ts` with JSON file for persistent state across sessions
- **Once-per-session**: Track injected paths in Set to avoid duplicate injection
- **Message injection**: Return `{ messages: [...] }` from event handlers
- **Blocking**: Return `{ blocked: true, message: "reason" }` from PreToolUse
## ANTI-PATTERNS (HOOKS)
- **Heavy computation** in PreToolUse: Slows every tool call
- **Blocking without clear reason**: Always provide actionable message
- **Duplicate injection**: Track what's already injected per session
- **Ignoring errors**: Always try/catch, log failures, don't crash session

View File

@@ -1,7 +1,7 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
import { getOpenCodeStorageDir } from "../../shared/data-path";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
export const AGENT_USAGE_REMINDER_STORAGE = join(
OPENCODE_STORAGE,
"agent-usage-reminder",

Some files were not shown because too many files have changed in this diff Show More