Skip to content

feat(plugin): universal plugin system — OMP and Claude Code compatibility#105

Open
giveen wants to merge 11 commits into
mlhher:mainfrom
giveen:plugin
Open

feat(plugin): universal plugin system — OMP and Claude Code compatibility#105
giveen wants to merge 11 commits into
mlhher:mainfrom
giveen:plugin

Conversation

@giveen

@giveen giveen commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description of Changes

What

The Late plugin system now supports installing plugins from three ecosystems — not just plugins written specifically for Late:

Format Manifest Auto-detected surfaces
Late (native) package.json"late" field skills, commands, MCP, hooks, themes, tools
OMP (Oh My Pi) package.json"omp" field skills, commands, MCP, hooks
Claude Code .claude-plugin/plugin.json skills/, commands/, .mcp.json (flat + wrapped), hooks/hooks.json

Why

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 — Added OmpManifest and ClaudePluginManifest structs. LoadPlugin now tries three format loaders in order: native Late → OMP → Claude Code. Both OMP and Claude Code formats are translated into LateManifest so all surface registration (skills, MCP, commands, hooks) works identically.
  • .mcp.json detection 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.godiscoverFromDir now accepts symlinks (Go os.ReadDir().IsDir() returns false for symlinks) and recurses into @-prefixed scope directories.
  • internal/plugin/hooks.go — Hook subprocesses get sandboxing via setCmdSysProcAttr.
  • internal/plugin/sandbox_linux.go — Go 1.26 compat: NoNewPrivs removed from syscall.SysProcAttr.

Verified With Real Plugins

OMP plugin from npm:

late plugin install @a5c-ai/babysitter-omp
→ 1 skill(s) detected

Claude Code official plugins:

late plugin install ./asana     (.claude-plugin/plugin.json + flat .mcp.json)
→ 1 MCP server(s) detected

late plugin install ./discord   (.claude-plugin/plugin.json + skills/ + wrapped .mcp.json)
→ 1 skill(s), 1 MCP server(s) detected

All standard plugin lifecycle commands verified: install, link, list, disable, enable, remove, update.

Open Items

  • Claude Code hooks/hooks.json auto-detection is stubbed but not yet wired into the hook execution pipeline (the hook event model differs between systems).
  • No marketplace registry published yet (the default endpoint registry.late.dev is a placeholder that falls through to npm).

CLA

  • By checking this box, I confirm that I have read and agree to the terms of the CLA.md in this repository.

giveen and others added 11 commits July 25, 2026 23:08
…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
giveen marked this pull request as ready for review July 27, 2026 03:50
@giveen

giveen commented Jul 27, 2026

Copy link
Copy Markdown
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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant