feat(plugin): universal plugin system — OMP and Claude Code compatibility#105
Open
giveen wants to merge 11 commits into
Open
feat(plugin): universal plugin system — OMP and Claude Code compatibility#105giveen wants to merge 11 commits into
giveen wants to merge 11 commits into
Conversation
…nToolResult pipeline, quickstart docs New surfaces: - marketplace.go: MarketplaceClient resolving bare names via JSON registry (LATE_PLUGIN_REGISTRY), falls back to plain npm on 404. - hooks.go: CallOnToolResultHooks now returns ([]byte, error) — sequential mutation pipeline; empty stdout = pass-through, valid JSON = replace result, literal "blocked" = veto. - installer.go: Install() dispatcher (URL/path/npm/marketplace), Update() and UpdateAll() with atomic git temp-clone+rename and npm @latest. - command.go: handlePluginInstall routes through Install(); handlePluginUpdate routes through Update/UpdateAll; removed dead isGitURL/isLocalPath helpers. - update_test.go: 6 tests covering npm happy-path, local-source refusal, unknown-name error, exec propagation, bulk update, marketplace fallback. - marketplace_test.go: stub server + resolve tests for npm/git/404 entries. - manifest.go: InstalledPlugin.Source string field tracks original install arg. Quickstart docs: - quickstart.md + quickstart.zh-CN.md: new Plugins section covering discovery paths, install from npm/git/local/marketplace, enable/disable/remove/update commands, and surface table (skills/MCP/commands/themes/hooks/tools). - plugin-sdk.md: documented marketplace install, update command, tool-result mutation semantics.
…oop via BuildToolResultMiddlewares - hooks.go: new BuildToolResultMiddlewares() returns post-execution middleware that calls CallOnToolResultHooks after tool success, skips hooks on error. - main.go: appends the new middlewares to the root agent's middleware chain right after the existing BuildHookMiddlewares() line. - hooks_themes_test.go: two tests — one verifies result mutation after a successful tool call, one verifies hooks are skipped on tool errors.
- manager.go: drop unused 'context' import (replaced by package-level seams) - command.go: drop unused 'os' and 'os/exec' imports (removed inline updatePlugin that used exec.Command; new Update/UpdateAll APIs own it) - command.go: change `for i, a := range args` to `for _, a := range args` since the loop index was unused - hooks.go: add 'sort' to imports (sort.Slice is used at L150) - hooks.go: wrap runHook's string return in []byte before assigning to json.RawMessage (RawMessage is []byte, not string) - watcher.go: use = instead of := for struct field reassignment - watcher.go: remove redundant `_ = os.Stat(...) if _, err := os.Stat(...)` duplicate - update.go: rename `func (m *Model) PluginCommands() []string` to `func (m *Model) ListedPluginCommands() []string` to fix field/method shadowing — inside the method body, `m.PluginCommands` was resolving to the method itself, breaking `len(m.PluginCommands)` callers in view.go and other call sites - plugin_test.go: update Empty test to read field directly, SetAndGet test to call the renamed ListedPluginCommands() method
Last commit was too aggressive — removed the os import and renamed the loop index from i to _ in parseProjectFlag, but both are actually used: - `os` is required for os.Stderr, os.Stdout and os.UserHomeDir calls throughout the file (HandlePluginCommand, printPluginUsage, handlePluginList, handlePluginLink and the install/remove/enable paths) - The loop index `i` is consumed by the inner `for j, r := range args` to omit the --project/--local flag from the returned args. Renaming to _ broke that comparison. Restoring both to their pre-fix state.
- main.go: import late/internal/pathutil and call pathutil.LateSkillsDir()
at the plugin-skills registration site. The common.LateSkillsDir export
was moved upstream.
- main.go: stop using tool.ScriptTool as the registration shape for
plugin-declared inline tools. Upstream repurposed that struct's fields
(SkillName/ScriptName/ScriptPath) for skill dispatch only, so plugin
tools no longer fit. New local pluginInlineTool type at the top of
cmd/late/main.go implements the common.Tool interface (Name,
Description, Parameters, Execute) and bridges to plugin.InlineTool by
synthesizing a client.ToolCall at Execute time from the registered
name + the executor's json.RawMessage args.
- main.go: registration call updated to pluginInlineTool{ Name, Description,
Parameters, Runner }.
- model.MessageHook = pluginManager.HookedMessage unchanged — signatures
already match.
Three changes to make Late's plugin system actually installable and
compatible with omp and Claude Code ecosystems:
1. Universal manifest loading (manifest.go)
- Recognize 'omp' field in package.json (Oh My Pi plugins)
- Recognize .claude-plugin/plugin.json + auto-detect skills/,
commands/, .mcp.json, hooks/hooks.json (Claude Code format)
- Both are translated into LateManifest at load time so all
surface registration works identically
2. Install fixes (installer.go, manager.go)
- Symlink discovery: os.ReadDir.IsDir() returns false for
symlinks, so linked plugins were invisible to the watcher
- Scoped npm packages (@scope/name): relative symlink target
was computed from the wrong parent directory
- @-prefixed scope dirs are now recursed into to find plugins
- Sandbox subprocesses via setCmdSysProcAttr (hooks.go)
3. Go 1.26 compat (sandbox_linux.go, sandbox_other.go)
- NoNewPrivs removed from SysProcAttr (not present in Go 1.26)
- CLONE_NEWPID retained for PID namespace isolation
Claude Code .mcp.json supports two shapes:
{'mcpServers': {'name': {...}}} — wrapped (omp/Late convention)
{'name': {'type':'sse','url':...}} — flat map (Claude Code convention)
The asana, github, linear, and other official plugins use the flat
format and were silently skipped. Now try both.
Verified with two real official Claude Code plugins:
- asana -> surfaces: 1 MCP server(s)
- discord -> surfaces: 1 skill(s), 1 MCP server(s)
Upstream changed AvailableCommands from []string to []CommandDef.
Our plugin branch added /clear, /themes commands and ThemeEntry struct.
Kept both: use CommandDef format with all commands from both sides.
Also fixed downstream code that still treated entries as strings:
- builtinSet: c → c.Name
- plugin command append: wrap string in CommandDef{}
giveen
marked this pull request as ready for review
July 27, 2026 03:50
Contributor
Author
|
@mlhher we had talked about a plugin system, so here you go, its compatible with OMP and Claude Code plugins plus gives us room to make our own plugins as well. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description of Changes
What
The Late plugin system now supports installing plugins from three ecosystems — not just plugins written specifically for Late:
package.json→"late"fieldpackage.json→"omp"field.claude-plugin/plugin.jsonskills/,commands/,.mcp.json(flat + wrapped),hooks/hooks.jsonWhy
Late should be able to install plugins from any ecosystem without requiring authors to publish specifically for Late. This makes the entire OMP and Claude Code plugin catalogs available to Late users.
How
internal/plugin/manifest.go— AddedOmpManifestandClaudePluginManifeststructs.LoadPluginnow tries three format loaders in order: native Late → OMP → Claude Code. Both OMP and Claude Code formats are translated intoLateManifestso all surface registration (skills, MCP, commands, hooks) works identically..mcp.jsondetection handles both{"mcpServers": {...}}(wrapped) and{"name": {...}}(flat Claude Code convention) formats.internal/plugin/installer.go— Fixed relative symlink path for scoped npm packages (@scope/name).internal/plugin/manager.go—discoverFromDirnow accepts symlinks (Goos.ReadDir().IsDir()returns false for symlinks) and recurses into@-prefixed scope directories.internal/plugin/hooks.go— Hook subprocesses get sandboxing viasetCmdSysProcAttr.internal/plugin/sandbox_linux.go— Go 1.26 compat:NoNewPrivsremoved fromsyscall.SysProcAttr.Verified With Real Plugins
OMP plugin from npm:
Claude Code official plugins:
All standard plugin lifecycle commands verified:
install,link,list,disable,enable,remove,update.Open Items
hooks/hooks.jsonauto-detection is stubbed but not yet wired into the hook execution pipeline (the hook event model differs between systems).registry.late.devis a placeholder that falls through to npm).CLA